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;