bindy/
placement.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Pod placement: topology spreading, node selection, tolerations, affinity.
5//!
6//! # Why this module exists
7//!
8//! A DNS server is not an anonymous replica. A zone's `NS` records name the
9//! individual servers authoritative for it, so every primary needs a stable
10//! identity and its own address. Bindy models that by giving each nameserver
11//! its own `Bind9Instance`, its own Deployment, and its own Service — which
12//! means a `Bind9Cluster` with `primary.replicas: 3` produces **three
13//! single-Pod Deployments**, not one three-Pod Deployment.
14//!
15//! That shape breaks the obvious implementation of zone spreading. A
16//! `topologySpreadConstraint` balances the set of Pods matched by its
17//! `labelSelector`, counted per value of `topologyKey`. If the operator
18//! generated a selector matching a Deployment's own Pods, the set would have
19//! exactly one member — always trivially balanced, so the constraint would be
20//! satisfied by any placement and all three primaries could still land in one
21//! zone.
22//!
23//! The fix is [`SpreadScope`]: the selector is generated to match *sibling*
24//! instances via `bindy.firestoned.io/cluster` + `bindy.firestoned.io/role`,
25//! so the scheduler counts all primaries of a cluster as one set. Users never
26//! write that selector themselves — they cannot know the operator's internal
27//! Pod labels, and a wrong selector fails silently rather than loudly.
28//!
29//! # Defaults
30//!
31//! With no `placement` block anywhere, the operator emits a single **soft**
32//! (`ScheduleAnyway`) zone-spread constraint whenever the resolved Pod set has
33//! two or more members. Soft is deliberate: a hard constraint turns a
34//! single-zone cluster — or a zone outage, the very thing this feature guards
35//! against — into `Pending` DNS Pods, trading degraded availability for a
36//! total outage.
37//!
38//! # Scope
39//!
40//! This module handles topology spreading and nothing else. It deliberately
41//! does **not** accept `nodeSelector`, `tolerations`, or `affinity`: those are
42//! general pod-spec passthrough, they inflated the generated CRDs by ~450KB,
43//! and they are exactly the primitives a namespace tenant would need to place
44//! an operator-credentialed Pod onto a control-plane node. Keeping them out
45//! removes that threat model rather than mitigating it. See
46//! `docs/adr/0003-pod-placement-and-zone-spreading.md`.
47//!
48//! What remains to validate is correctness, not security: rules Kubernetes
49//! would reject, caught here so the user sees a clear condition on the CR they
50//! edited instead of an opaque Deployment failure. Structural limits that the
51//! CRD schema *can* express (rule count, label-key syntax, value ranges, and
52//! the `minDomains`/`DoNotSchedule` pairing) are enforced at admission by the
53//! generated schema; this module is the backstop.
54
55use std::collections::BTreeMap;
56
57use k8s_openapi::api::core::v1::TopologySpreadConstraint;
58use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
59use thiserror::Error;
60use tracing::{debug, warn};
61
62use crate::constants::{DEFAULT_SPREAD_MAX_SKEW, MAX_SPREAD_RULES, TOPOLOGY_KEY_ZONE};
63use crate::crd::{
64    Bind9Cluster, Bind9Instance, ClusterBind9Provider, NodeInclusionPolicy, PlacementConfig,
65    ServerRole, SpreadRule, SpreadScope, WhenUnsatisfiable,
66};
67use crate::labels::{BINDY_CLUSTER_LABEL, BINDY_ROLE_LABEL, ROLE_PRIMARY, ROLE_SECONDARY};
68
69// ============================================================================
70// Resolved output
71// ============================================================================
72
73/// The Pod-spec field this module produces.
74///
75/// Built by [`build_pod_placement`] and applied onto the Deployment's Pod
76/// template by `crate::bind9_resources::build_pod_spec`.
77#[derive(Clone, Debug, Default, PartialEq)]
78pub struct ResolvedPlacement {
79    /// Generated from `placement.spread` (or the operator default).
80    pub topology_spread_constraints: Option<Vec<TopologySpreadConstraint>>,
81}
82
83impl ResolvedPlacement {
84    /// True when nothing would be applied to the Pod spec.
85    #[must_use]
86    pub fn is_empty(&self) -> bool {
87        self.topology_spread_constraints.is_none()
88    }
89}
90
91/// Everything [`build_pod_placement`] needs to know about the instance whose
92/// Pod spec is being built.
93#[derive(Clone, Debug)]
94pub struct PlacementContext<'a> {
95    /// Name of the `Bind9Instance` (also the Deployment name).
96    pub instance_name: &'a str,
97    /// Owning cluster / provider name, or `None` for a standalone instance.
98    ///
99    /// `Role` and `Cluster` spread scopes are only meaningful with a cluster:
100    /// they select on `bindy.firestoned.io/cluster`, which a standalone
101    /// instance's Pods do not carry.
102    pub cluster_name: Option<&'a str>,
103    /// Role of this instance.
104    pub role: ServerRole,
105    /// Pods in this instance's own Deployment (`spec.replicas`).
106    pub instance_replicas: i32,
107    /// How many instances of this role the owning cluster asks for.
108    ///
109    /// `1` for a standalone instance. This is what makes the default fire for
110    /// a cluster of three single-Pod primaries, where `instance_replicas` on
111    /// its own would always be 1 and never reach the threshold.
112    pub role_instance_count: i32,
113    /// Total instances the cluster asks for across both roles.
114    ///
115    /// Only used to decide whether a `Cluster`-scoped default is worth
116    /// emitting.
117    pub cluster_instance_count: i32,
118    /// This Deployment's own Pod selector labels, used for `Instance` scope.
119    pub instance_selector_labels: &'a BTreeMap<String, String>,
120}
121
122// ============================================================================
123// Resolution (precedence)
124// ============================================================================
125
126/// Resolves which `placement` block applies to an instance.
127///
128/// Precedence, highest first:
129///
130/// 1. `Bind9Instance.spec.placement`
131/// 2. `spec.primary.placement` / `spec.secondary.placement` on the owning
132///    `Bind9Cluster` or `ClusterBind9Provider`
133///
134/// Resolution is **whole-block**: the more specific level wins outright and
135/// the other is not merged into it. Merging would make "what will actually be
136/// scheduled" unanswerable without mentally combining two blocks, and a
137/// silently half-inherited scheduling rule is a bad failure mode — a Pod that
138/// lands somewhere unexpected is hard to notice until a zone goes down.
139#[must_use]
140pub fn resolve_placement<'a>(
141    instance: &'a Bind9Instance,
142    cluster: Option<&'a Bind9Cluster>,
143    cluster_provider: Option<&'a ClusterBind9Provider>,
144) -> Option<&'a PlacementConfig> {
145    // 1. Instance level.
146    if let Some(p) = instance.spec.placement.as_ref() {
147        debug!(instance = %instance.spec.cluster_ref, "Using instance-level placement");
148        return Some(p);
149    }
150
151    // 2. Role level, then 3. cluster level — checked against the namespace-
152    //    scoped cluster first, then the cluster-scoped provider. An instance
153    //    only ever belongs to one of the two, so at most one arm contributes.
154    let role_level = match instance.spec.role {
155        ServerRole::Primary => cluster
156            .and_then(|c| c.spec.common.primary.as_ref())
157            .and_then(|p| p.placement.as_ref())
158            .or_else(|| {
159                cluster_provider
160                    .and_then(|p| p.spec.common.primary.as_ref())
161                    .and_then(|p| p.placement.as_ref())
162            }),
163        ServerRole::Secondary => cluster
164            .and_then(|c| c.spec.common.secondary.as_ref())
165            .and_then(|s| s.placement.as_ref())
166            .or_else(|| {
167                cluster_provider
168                    .and_then(|p| p.spec.common.secondary.as_ref())
169                    .and_then(|s| s.placement.as_ref())
170            }),
171    };
172    role_level
173}
174
175// ============================================================================
176// Building
177// ============================================================================
178
179/// Builds the Pod-spec placement fields for an instance.
180///
181/// `config` is the block returned by [`resolve_placement`]; `None` means no
182/// user configuration at any level, in which case only the operator default
183/// spread applies.
184#[must_use]
185pub fn build_pod_placement(
186    config: Option<&PlacementConfig>,
187    ctx: &PlacementContext<'_>,
188) -> ResolvedPlacement {
189    let constraints = match config.and_then(|c| c.spread.as_ref()) {
190        // Explicit rules: exactly these, no default.
191        Some(rules) if !rules.is_empty() => rules
192            .iter()
193            .filter_map(|rule| build_constraint(rule, ctx))
194            .collect::<Vec<_>>(),
195        // Explicit empty list: the user opted out.
196        Some(_) => {
197            debug!(
198                instance = %ctx.instance_name,
199                "placement.spread is an empty list; emitting no topology spread constraints"
200            );
201            Vec::new()
202        }
203        // Absent: operator default.
204        None => default_constraints(ctx),
205    };
206
207    ResolvedPlacement {
208        topology_spread_constraints: (!constraints.is_empty()).then_some(constraints),
209    }
210}
211
212/// The operator's default spread: one soft zone rule for **primaries only**,
213/// when the Pod set is large enough for spreading to mean anything.
214///
215/// # Why primaries only
216///
217/// Issue #467 asked for primaries, and the asymmetry is real: a primary is
218/// authoritative and holds the writable copy of a zone, whereas a secondary
219/// can be re-created from a primary at any time. Losing every primary to one
220/// zone outage is the failure this feature exists to prevent.
221///
222/// The other half of the reasoning is about upgrades. Applying a default to
223/// secondaries would silently change where already-running secondary Pods are
224/// scheduled the moment an operator is upgraded — an unannounced scheduling
225/// policy change on a live cluster, which is not something an operator should
226/// do on a user's behalf. Secondaries opt in via `secondary.placement.spread`.
227fn default_constraints(ctx: &PlacementContext<'_>) -> Vec<TopologySpreadConstraint> {
228    if ctx.role != ServerRole::Primary {
229        debug!(
230            instance = %ctx.instance_name,
231            "Default zone spread applies to primaries only; set secondary.placement.spread to opt in"
232        );
233        return Vec::new();
234    }
235
236    let default_rule = SpreadRule {
237        topology_key: TOPOLOGY_KEY_ZONE.to_string(),
238        max_skew: None,
239        when_unsatisfiable: None,
240        scope: None,
241        min_domains: None,
242        node_affinity_policy: None,
243        node_taints_policy: None,
244    };
245
246    let scope = effective_scope(&default_rule, ctx);
247    let set_size = pod_set_size(scope, ctx);
248
249    if set_size < 2 {
250        debug!(
251            instance = %ctx.instance_name,
252            set_size,
253            "Resolved Pod set has fewer than 2 members; skipping default zone spread"
254        );
255        return Vec::new();
256    }
257
258    debug!(
259        instance = %ctx.instance_name,
260        set_size,
261        scope = ?scope,
262        "Applying default soft zone spread"
263    );
264    build_constraint(&default_rule, ctx).into_iter().collect()
265}
266
267/// Number of Pods the scheduler will count for a given scope.
268///
269/// Used only to decide whether the *default* is worth emitting. Explicit user
270/// rules are always honoured, however small the set — the user asked for them.
271fn pod_set_size(scope: SpreadScope, ctx: &PlacementContext<'_>) -> i32 {
272    let replicas = ctx.instance_replicas.max(0);
273    match scope {
274        SpreadScope::Instance => replicas,
275        SpreadScope::Role => ctx.role_instance_count.max(1).saturating_mul(replicas),
276        SpreadScope::Cluster => ctx.cluster_instance_count.max(1).saturating_mul(replicas),
277    }
278}
279
280/// Picks the scope for a rule: explicit if set, else `Role` for a
281/// cluster-managed instance and `Instance` for a standalone one.
282fn effective_scope(rule: &SpreadRule, ctx: &PlacementContext<'_>) -> SpreadScope {
283    match rule.scope {
284        Some(scope) => scope,
285        None => {
286            if ctx.cluster_name.is_some() {
287                SpreadScope::Role
288            } else {
289                SpreadScope::Instance
290            }
291        }
292    }
293}
294
295/// Turns one [`SpreadRule`] into a Kubernetes `TopologySpreadConstraint`.
296///
297/// Returns `None` when the rule cannot produce a meaningful constraint — a
298/// cluster-scoped rule on a standalone instance, for instance, whose Pods
299/// carry no cluster label to select on.
300fn build_constraint(
301    rule: &SpreadRule,
302    ctx: &PlacementContext<'_>,
303) -> Option<TopologySpreadConstraint> {
304    let requested = effective_scope(rule, ctx);
305    let scope = match (requested, ctx.cluster_name) {
306        // Role / Cluster scope needs the cluster label, which only exists on
307        // Pods belonging to a cluster. Fall back rather than emitting a
308        // constraint whose selector matches nothing.
309        (SpreadScope::Role | SpreadScope::Cluster, None) => {
310            warn!(
311                instance = %ctx.instance_name,
312                requested_scope = ?requested,
313                "Spread scope requires an owning Bind9Cluster; falling back to Instance scope"
314            );
315            SpreadScope::Instance
316        }
317        (scope, _) => scope,
318    };
319
320    let label_selector = build_selector(scope, ctx);
321
322    let when_unsatisfiable = rule
323        .when_unsatisfiable
324        .unwrap_or(WhenUnsatisfiable::ScheduleAnyway);
325
326    Some(TopologySpreadConstraint {
327        topology_key: rule.topology_key.clone(),
328        max_skew: rule.max_skew.unwrap_or(DEFAULT_SPREAD_MAX_SKEW),
329        when_unsatisfiable: when_unsatisfiable_str(when_unsatisfiable).to_string(),
330        label_selector: Some(label_selector),
331        // `minDomains` only has meaning for a hard constraint; the API server
332        // rejects it alongside ScheduleAnyway.
333        min_domains: match when_unsatisfiable {
334            WhenUnsatisfiable::DoNotSchedule => rule.min_domains,
335            WhenUnsatisfiable::ScheduleAnyway => None,
336        },
337        node_affinity_policy: rule.node_affinity_policy.map(node_policy_str_owned),
338        node_taints_policy: rule.node_taints_policy.map(node_policy_str_owned),
339        match_label_keys: None,
340    })
341}
342
343/// Generates the `labelSelector` that defines the balanced Pod set.
344///
345/// This is the crux of the whole feature: get the selector wrong and the
346/// constraint is silently satisfied by every placement.
347fn build_selector(scope: SpreadScope, ctx: &PlacementContext<'_>) -> LabelSelector {
348    let match_labels = match scope {
349        // This Deployment's own Pods.
350        SpreadScope::Instance => ctx.instance_selector_labels.clone(),
351        // Every Pod of this role across the cluster — i.e. all the sibling
352        // single-Pod Deployments the cluster controller created.
353        SpreadScope::Role => {
354            let mut m = BTreeMap::new();
355            if let Some(cluster) = ctx.cluster_name {
356                m.insert(BINDY_CLUSTER_LABEL.to_string(), cluster.to_string());
357            }
358            m.insert(BINDY_ROLE_LABEL.to_string(), role_str(ctx.role).to_string());
359            m
360        }
361        // Every DNS Pod of the cluster, both roles together.
362        SpreadScope::Cluster => {
363            let mut m = BTreeMap::new();
364            if let Some(cluster) = ctx.cluster_name {
365                m.insert(BINDY_CLUSTER_LABEL.to_string(), cluster.to_string());
366            }
367            m
368        }
369    };
370
371    LabelSelector {
372        match_labels: Some(match_labels),
373        ..Default::default()
374    }
375}
376
377fn role_str(role: ServerRole) -> &'static str {
378    match role {
379        ServerRole::Primary => ROLE_PRIMARY,
380        ServerRole::Secondary => ROLE_SECONDARY,
381    }
382}
383
384fn when_unsatisfiable_str(value: WhenUnsatisfiable) -> &'static str {
385    match value {
386        WhenUnsatisfiable::DoNotSchedule => "DoNotSchedule",
387        WhenUnsatisfiable::ScheduleAnyway => "ScheduleAnyway",
388    }
389}
390
391fn node_policy_str_owned(value: NodeInclusionPolicy) -> String {
392    match value {
393        NodeInclusionPolicy::Honor => "Honor".to_string(),
394        NodeInclusionPolicy::Ignore => "Ignore".to_string(),
395    }
396}
397
398// ============================================================================
399// Validation
400// ============================================================================
401
402/// Rejection reasons returned by [`validate_placement`].
403///
404/// These are **correctness** checks, not security checks. Since `placement`
405/// accepts topology spreading only, there is no scheduling primitive here that
406/// could place a Pod somewhere it is not otherwise allowed — see the module
407/// docs. Each variant carries enough context for the reconciler to render an
408/// actionable `Ready=False` condition on the offending CR.
409///
410/// Most of these are also enforced structurally by the generated CRD schema
411/// (`maxItems`, the `topologyKey` pattern, value ranges, and an
412/// `x-kubernetes-validations` rule for the `minDomains` pairing), so they are
413/// normally rejected at admission. This validator remains the backstop for
414/// clusters running an older CRD revision, and covers the one rule a
415/// structural schema cannot express cheaply: uniqueness across rules.
416#[derive(Debug, Error, PartialEq, Eq)]
417pub enum PlacementRejection {
418    #[error(
419        "placement.spread has {count} rules, which exceeds the maximum of {MAX_SPREAD_RULES}; \
420         every rule is evaluated on each scheduling attempt"
421    )]
422    TooManySpreadRules { count: usize },
423
424    #[error(
425        "placement.spread[{index}].topologyKey {key:?} is not a valid Kubernetes label key: {reason}"
426    )]
427    InvalidTopologyKey {
428        index: usize,
429        key: String,
430        reason: &'static str,
431    },
432
433    #[error(
434        "placement.spread has two rules with the same topologyKey {key:?} and whenUnsatisfiable \
435         {when:?}; Kubernetes requires this pair to be unique across constraints"
436    )]
437    DuplicateSpreadRule { key: String, when: String },
438
439    #[error("placement.spread[{index}].maxSkew must be greater than 0, got {value}")]
440    InvalidMaxSkew { index: usize, value: i32 },
441
442    #[error("placement.spread[{index}].minDomains must be greater than 0, got {value}")]
443    InvalidMinDomains { index: usize, value: i32 },
444
445    #[error(
446        "placement.spread[{index}] sets minDomains, which Kubernetes only permits together with \
447         whenUnsatisfiable: DoNotSchedule"
448    )]
449    MinDomainsRequiresHardConstraint { index: usize },
450}
451
452/// Validates a user-supplied `placement` block.
453///
454/// Catches inputs Kubernetes itself would reject, so the user sees a clear
455/// condition on their CR instead of a Deployment that silently fails to apply.
456///
457/// # Errors
458///
459/// Returns the first [`PlacementRejection`] encountered.
460pub fn validate_placement(config: &PlacementConfig) -> Result<(), PlacementRejection> {
461    if let Some(rules) = config.spread.as_ref() {
462        validate_spread_rules(rules)?;
463    }
464    Ok(())
465}
466
467/// Convenience wrapper for the common `Option<&PlacementConfig>` shape.
468///
469/// # Errors
470///
471/// Returns the first [`PlacementRejection`] encountered, or `Ok(())` when the
472/// block is absent.
473pub fn validate_optional_placement(
474    config: Option<&PlacementConfig>,
475) -> Result<(), PlacementRejection> {
476    config.map_or(Ok(()), validate_placement)
477}
478
479fn validate_spread_rules(rules: &[SpreadRule]) -> Result<(), PlacementRejection> {
480    if rules.len() > MAX_SPREAD_RULES {
481        return Err(PlacementRejection::TooManySpreadRules { count: rules.len() });
482    }
483
484    let mut seen: Vec<(String, String)> = Vec::with_capacity(rules.len());
485
486    for (index, rule) in rules.iter().enumerate() {
487        if let Err(reason) = validate_label_key(&rule.topology_key) {
488            return Err(PlacementRejection::InvalidTopologyKey {
489                index,
490                key: rule.topology_key.clone(),
491                reason,
492            });
493        }
494
495        if let Some(skew) = rule.max_skew {
496            if skew <= 0 {
497                return Err(PlacementRejection::InvalidMaxSkew { index, value: skew });
498            }
499        }
500
501        let when = rule
502            .when_unsatisfiable
503            .unwrap_or(WhenUnsatisfiable::ScheduleAnyway);
504
505        if let Some(min_domains) = rule.min_domains {
506            if min_domains <= 0 {
507                return Err(PlacementRejection::InvalidMinDomains {
508                    index,
509                    value: min_domains,
510                });
511            }
512            if when != WhenUnsatisfiable::DoNotSchedule {
513                return Err(PlacementRejection::MinDomainsRequiresHardConstraint { index });
514            }
515        }
516
517        // Kubernetes requires (topologyKey, whenUnsatisfiable) to be unique
518        // across a Pod's constraints and rejects the Pod otherwise. Catching
519        // it here turns an opaque Deployment-level failure into a condition on
520        // the CR the user actually edited.
521        let key = (
522            rule.topology_key.clone(),
523            when_unsatisfiable_str(when).to_string(),
524        );
525        if seen.contains(&key) {
526            return Err(PlacementRejection::DuplicateSpreadRule {
527                key: key.0,
528                when: key.1,
529            });
530        }
531        seen.push(key);
532    }
533
534    Ok(())
535}
536
537/// Validates a Kubernetes label key, matching `IsQualifiedName` in
538/// `k8s.io/apimachinery/pkg/util/validation`.
539///
540/// An optional lowercase RFC 1123 subdomain prefix of at most 253 characters,
541/// a `/`, and a name segment of at most 63 characters — so 317 overall.
542///
543/// # Deliberately not enforced: a 63-character cap per DNS label
544///
545/// RFC 1123 caps a single DNS label at 63 octets, but Kubernetes'
546/// `IsDNS1123Subdomain` checks only the 253-character total and the subdomain
547/// charset — it does not bound individual labels. A key such as
548/// `<64 a's>/zone` is therefore accepted by the API server, both as a node
549/// label and as a `topologyKey` on a Pod (verified against a live cluster).
550/// Rejecting it here would make Bindy refuse input Kubernetes accepts, which
551/// is worse than mirroring the platform's own looseness.
552fn validate_label_key(key: &str) -> Result<(), &'static str> {
553    if key.is_empty() {
554        return Err("must not be empty");
555    }
556    // 253 (prefix) + 1 ('/') + 63 (name). An earlier revision used 316 here,
557    // which rejected a maximally-sized but perfectly valid key.
558    if key.len() > 317 {
559        return Err("must be at most 317 characters");
560    }
561
562    let name = match key.split_once('/') {
563        Some((prefix, name)) => {
564            if prefix.is_empty() {
565                return Err("prefix before '/' must not be empty");
566            }
567            if prefix.len() > 253 {
568                return Err("prefix before '/' must be at most 253 characters");
569            }
570            if !prefix.split('.').all(is_dns1123_label) {
571                return Err(
572                    "prefix before '/' must be a lowercase RFC 1123 subdomain: dot-separated \
573                     segments of alphanumerics and '-', each starting and ending with an \
574                     alphanumeric",
575                );
576            }
577            name
578        }
579        None => key,
580    };
581
582    if name.is_empty() {
583        return Err("name segment must not be empty");
584    }
585    if name.len() > 63 {
586        return Err("name segment must be at most 63 characters");
587    }
588    if !name.chars().all(is_label_name_char) {
589        return Err("name segment may contain only alphanumerics, '-', '_' and '.'");
590    }
591    if !starts_and_ends_alphanumeric(name) {
592        return Err("name segment must start and end with an alphanumeric character");
593    }
594
595    Ok(())
596}
597
598/// One dot-separated segment of an RFC 1123 subdomain, as Kubernetes validates
599/// it: non-empty, lowercase alphanumerics and `-`, starting and ending with an
600/// alphanumeric. Length is bounded by the caller's 253-character prefix cap;
601/// see `validate_label_key` for why there is no per-segment cap.
602fn is_dns1123_label(part: &str) -> bool {
603    !part.is_empty() && part.chars().all(is_dns_label_char) && starts_and_ends_alphanumeric(part)
604}
605
606fn starts_and_ends_alphanumeric(value: &str) -> bool {
607    value
608        .chars()
609        .next()
610        .is_some_and(|c| c.is_ascii_alphanumeric())
611        && value
612            .chars()
613            .next_back()
614            .is_some_and(|c| c.is_ascii_alphanumeric())
615}
616
617fn is_dns_label_char(c: char) -> bool {
618    c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'
619}
620
621fn is_label_name_char(c: char) -> bool {
622    c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.'
623}
624
625#[cfg(test)]
626#[path = "placement_tests.rs"]
627mod placement_tests;