Skip to main content

max / makenotwork

Configure pom as Sando's second app, and take an intake through the seam Sando ships a product it does not build. pom runs on astra (aarch64) and on Hetzner (x86_64), and build::run compiles on one configured host, so Sando could never build half of a pom release without breaking its own never-cross-compile rule. Bento builds both natively; Sando gates what arrives and performs the advance. The seam. stage_and_gate assembled a bundle out of a worktree and then published, recorded, gated and advanced it, welded together. It splits into assemble-from-source, publish, and record-and-gate, and intake_and_gate joins at the third — intake::accept already publishes content-addressed once it has proved the bytes are the ones the record vouches for, which is exactly the state publish leaves a Sando-built bundle in. Nothing after the join knows which path it came from. Platforms, because one pom version is two bundles with two digests. The artifact record already carried provenance.target and Sando dropped it; it is now on the build row (migration 012), and each node declares what it runs. The pairing is not checked before the deploy call — deploy_node takes a Placement, whose only constructor compares the two, so shipping aarch64 bytes to an x86_64 box is a value that cannot be constructed rather than a mistake to remember to avoid. Silence on either side is a refusal; both silent is the single-platform world MNW still lives in. Promote and both rollback paths resolve per node before any node is touched, and a sibling bundle qualifies only if its own run settled green. migration_dry_run read its migrations out of the worktree, which proved something about a checkout next to the artifact rather than about the artifact. It resolves from the bundle first now, and MNW stages server/migrations and multithreaded/migrations into the bundle, which puts them inside the digest. An accepted artifact has no worktree to fall back to, so a builder that does not ship its migrations is told the gate has nothing to dry-run instead of getting a green run over nothing. Same shape for the gates that read source: GateCtx.worktree is an Option and a source-reading gate refuses against an accepted artifact. That is the boundary in the type — artifact-scoped evidence belongs to the builder. An intake-only product declares no build_host and no [repo]. Absent is not "build anywhere": it says Sando does not build this, and /rebuild refuses rather than picking a machine. The backup and migration_check validations now apply only to a product whose tiers configure migration_dry_run, so pom is not asked for a prod dump for a gate it never runs. POST /intake is the entry point. Nothing moves the bytes into staging yet; that is the transport, and it is still to come.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-07 00:59 UTC
Signed with PGP, not checked
Commit: 6c27312d4b841e88b45ec3ca9dc04c13eca42928
Parent: c86f5eb
19 files changed, +1491 insertions, -127 deletions
@@ -299,6 +299,7 @@
299 299 |--------|------|------|---------|
300 300 | GET | `/state` | — | Tier list + current/previous version + last gate outcomes, plus `build` (latest build run: phase/result/failure_summary/elapsed_s, `null` until first `/rebuild`) so a poller sees in-flight/failed builds, not a frozen version |
301 301 | POST | `/rebuild` | `{sha?: string}` | Force a build; if `sha` is absent, resolves the configured deploy branch. Aborts any in-flight build (latest wins). Returns `{accepted, sha, run_id}`. |
302 + | POST | `/intake` | `{staged, record}` | Accept an artifact built elsewhere and take it through the same host-tier gating a locally-built one gets. `staged` is a directory already under this app's `release_root/staging/`; `record` is the builder's `ArtifactRecord` verbatim. The bytes are proved against the record before anything else happens — a bundle that drifted in transit is refused with the offending file named. Returns `{accepted, run_id}`. |
302 303 | GET | `/runs/{id}` | — | Build-status of the run a `/rebuild` returned: `{run_id, sha, version, phase, result, failure_summary, gates[], started_at, finished_at}`. The pollable resource for a non-TUI driver — `/state` only reflects the last *successful* version. |
303 304 | GET | `/runs/{id}/wait` | `?timeout_ms=` | Long-poll: blocks until the run settles or `timeout_ms` (default 30s, cap 120s) elapses, then returns the same `RunView`. Fire `/rebuild` → block on `/wait`. |
304 305 | POST | `/promote/{tier}` | `{version?, hotfix?, reset_burn_in?}` | Verify predecessor gates, deploy to tier nodes, advance state. `version` defaults to the predecessor tier's `current_version`. Red post-deploy gates advance the tier (the nodes really are running it) but return 409 and flag the tier `partial` — the rollout landed, the tier cannot promote onward. |
@@ -335,6 +336,66 @@
335 336 for whatever else is still burning in there. Use this only when the hotfix
336 337 meaningfully changes the surface area under burn-in.
337 338
339 + ## Shipping more than one product
340 +
341 + One daemon, one database, one bind address — and beneath that, N independent
342 + pipelines, each with its own repo, tiers, nodes, gates, release root and version
343 + history. `[app.<id>]` in `sando-daemon.toml` points at each product's own config;
344 + a file with no `[app.*]` tables is read as the single app `mnw`, so a config
345 + written before any of this still loads.
346 +
347 + The unprefixed routes address the default product, because `/promote/b` is what
348 + the runbook says and what an operator types under pressure. Every product is also
349 + at `/apps/<id>/...`, and `GET /apps` reports what is mounted.
350 +
351 + **A product does not have to be one Sando builds.** `pom` is the first that is
352 + not: it runs on aarch64 and on x86_64, and Sando compiles on one configured host,
353 + so it could never build half of a pom release without breaking its own
354 + never-cross-compile rule. Bento builds it natively on both; Sando gates what
355 + arrives and performs the advance. Such a product declares no `build_host` and no
356 + `[repo]` — absent is not "build anywhere", it is a statement that Sando does not
357 + build this at all, and `/rebuild` refuses rather than choosing a machine.
358 +
359 + ### Platforms, and why a bundle cannot land on the wrong box
360 +
361 + One pom version is two bundles with two digests. Which one a node gets is not a
362 + check before the deploy call — it is the only way to make the call:
363 +
364 + ```rust
365 + let placement = Placement::check(node, bundle, artifact_platform)?;
366 + deploy_node(executor, placement, version, primary_bin).await
367 + ```
368 +
369 + `deploy_node` takes a `Placement`, and `Placement::check` is its only
370 + constructor, so a mismatched deploy is not a bug to avoid but a value that
371 + cannot be built. Both sides state a platform (`platform = "linux/aarch64"` on a
372 + node, the artifact record's provenance for a bundle) and they must be equal.
373 + Silence on one side is a refusal, not a pass; both silent is the single-platform
374 + world MNW still lives in, and the moment either side starts stating, the other
375 + has to as well.
376 +
377 + Promote resolves per node before any node is touched, so a version missing its
378 + x86_64 half fails whole rather than halfway down a rollout. A sibling bundle only
379 + qualifies if its own run settled green: each architecture stands on its own
380 + intake and its own gate run, because the source tier's evidence says nothing
381 + about bytes it never saw.
382 +
383 + ### Which gates go where
384 +
385 + Evidence *about the artifact* — `cargo_test`, `clippy`, `fmt`, the audits —
386 + belongs to the builder. Evidence *about the artifact in an environment* —
387 + `migration_dry_run`, `boot_smoke`, `node_health`, `burn_in`, `manual_confirm` —
388 + is Sando's, which also keeps production dumps on the machine that already has
389 + them instead of handing them to build hosts.
390 +
391 + An accepted artifact has no worktree, so a source-reading gate configured on its
392 + tier refuses rather than resolving against nothing and reporting green.
393 + `migration_dry_run` resolves its migrations from the bundle first and the
394 + worktree second, which is why MNW now stages `server/migrations` and
395 + `multithreaded/migrations` into the bundle: inside the digest, the gate proves
396 + something about the bytes that ship rather than about a checkout sitting beside
397 + them.
398 +
338 399 ## v0 limitations
339 400
340 401 - `migration_dry_run` requires a scratch Postgres at `scratch_db_url`. The
@@ -161,3 +161,18 @@
161 161
162 162 [[test_target]]
163 163 dir = "shared/tagtree"
164 +
165 + # ---- products ----
166 + # Products this daemon ships. Declaring any of these means this file is no longer
167 + # both halves: the daemon keys stay here and each product points at its own
168 + # pipeline config. `mnw` points back at this same file, which is what keeps the
169 + # rest of it meaningful and what makes the change a pure addition — the routes
170 + # an operator types (`/promote/b`) still address MNW, and pom lives under
171 + # `/apps/pom/`.
172 + [app.mnw]
173 + config = "sando-daemon.toml"
174 +
175 + # pom is intake-only: Bento builds it on astra (aarch64) and Hetzner (x86_64),
176 + # Sando gates and promotes what arrives. See sando-pom.toml.
177 + [app.pom]
178 + config = "sando-pom.toml"
@@ -205,3 +205,26 @@
205 205 src = "server/docs/business/assumptions.toml"
206 206 dst = "docs/assumptions.toml"
207 207 required = true
208 +
209 + # Migrations ride in the bundle, which is what puts them inside the digest.
210 + #
211 + # `migration_dry_run` used to read them out of the worktree. That proved
212 + # something about a checkout sitting next to the artifact rather than about the
213 + # artifact, and the checkout can be edited between the dry run and the deploy.
214 + # The gate now resolves `[[migration_check]].dir` against the bundle first and
215 + # falls back to the worktree, so these two entries are what move the guarantee
216 + # from "the source we had" to "the bytes that ship".
217 + #
218 + # It is also what lets the gate run at all against an artifact Sando did not
219 + # build: an accepted bundle has no worktree to fall back to, and a builder that
220 + # does not ship its migrations gets told the gate has nothing to dry-run rather
221 + # than a green run over nothing.
222 + [[release_contents]]
223 + src = "server/migrations"
224 + dst = "server/migrations"
225 + required = true
226 +
227 + [[release_contents]]
228 + src = "multithreaded/migrations"
229 + dst = "multithreaded/migrations"
230 + required = true
@@ -374,11 +374,11 @@
374 374
375 375 fn topo_with_backup(source: String, local_path: String) -> Topology {
376 376 Topology {
377 - repo: RepoConfig {
377 + repo: Some(RepoConfig {
378 378 bare_path: "/tmp/x.git".into(),
379 379 branch: "main".into(),
380 380 upstream: None,
381 - },
381 + }),
382 382 backup: vec![BackupConfig {
383 383 name: "server".into(),
384 384 source,
@@ -6,7 +6,7 @@
6 6
7 7 use crate::config::AppConfig;
8 8 use crate::deploy;
9 - use crate::domain::{GitSha, RunId, TierId, Version};
9 + use crate::domain::{GitSha, Platform, RunId, TierId, Version};
10 10 use crate::gates::{self, GateCtx};
11 11 use crate::git;
12 12 use crate::topology::Topology;
@@ -71,10 +71,23 @@
71 71 // misdeployed onto a prod/serving node would otherwise build there — exactly
72 72 // the "never build on prod" rule. Enforced before any cargo invocation so a
73 73 // wrong-host daemon fails fast with a clear message rather than compiling.
74 - enforce_build_host(&cfg.build_host)?;
74 + let Some(build_host) = cfg.build_host.as_deref() else {
75 + anyhow::bail!(
76 + "{} declares no build_host, which makes it intake-only: Sando does not compile \
77 + it. Ship it with POST /intake, from a builder that does.",
78 + cfg.id
79 + );
80 + };
81 + enforce_build_host(build_host)?;
75 82
83 + let repo = topo.repo.as_ref().with_context(|| {
84 + format!(
85 + "{} declares no [repo]: it is intake-only and Sando has no source to check out",
86 + cfg.id
87 + )
88 + })?;
76 89 let worktree = cfg.workdir.join(sha.as_str());
77 - let bare = PathBuf::from(&topo.repo.bare_path);
90 + let bare = PathBuf::from(&repo.bare_path);
78 91
79 92 crate::runs::set_phase(&pool, run_id, crate::runs::Phase::Fetching)
80 93 .await
@@ -84,8 +97,8 @@
84 97 // deploy branch so a just-pushed sha is locally resolvable. A fetch
85 98 // failure is non-fatal — the sha may already be present from a prior
86 99 // fetch or a direct push; the presence check below is the real gate.
87 - if let Some(upstream) = topo.repo.upstream.as_deref()
88 - && let Err(e) = git::fetch_upstream(&bare, upstream, &topo.repo.branch).await
100 + if let Some(upstream) = repo.upstream.as_deref()
101 + && let Err(e) = git::fetch_upstream(&bare, upstream, &repo.branch).await
89 102 {
90 103 tracing::warn!(error = %e, upstream, "upstream fetch failed; proceeding with current bare-repo state");
91 104 }
@@ -378,6 +391,24 @@
378 391 stage_and_gate(pool, cfg, topo, art, events, run_id, deploy_lock).await
379 392 }
380 393
394 + /// A bundle assembled on disk and not yet published: the point both the build
395 + /// path and the intake path have to reach before anything else can happen to it.
396 + ///
397 + /// The two paths reach it differently. A build assembles it out of a worktree
398 + /// (binaries, `release_contents`, companions); an intake is handed it already
399 + /// assembled, with no worktree anywhere. Everything after this point — hashing,
400 + /// publishing, recording identity, gating, advancing — is the same work, and
401 + /// used to be welded to the assembling half inside `stage_and_gate`.
402 + struct StagedBundle {
403 + version: Version,
404 + /// The staging dir under `release_root/staging/`, pre-publish.
405 + staging: PathBuf,
406 + /// What the bundle is built to run on, when it is known. A Sando build
407 + /// inherits the app's declared platform; an intake takes it from the
408 + /// record's provenance.
409 + platform: Option<Platform>,
410 + }
411 +
381 412 /// Post-build half of the host pipeline: stage the artifact into the host's
382 413 /// release_root, run the host tier's gates, and advance `tier_state` for
383 414 /// "host" iff all pass. Split from [`build_and_run_host`] at the `run()`
@@ -395,14 +426,143 @@
395 426 crate::runs::set_phase(&pool, run_id, crate::runs::Phase::Staging)
396 427 .await
397 428 .ok();
429 + let staged = assemble_from_source(&cfg, &art, run_id).await?;
430 + let published = publish(&pool, &cfg, staged, run_id).await?;
431 + record_and_gate(
432 + pool,
433 + cfg,
434 + topo,
435 + published,
436 + events,
437 + run_id,
438 + deploy_lock,
439 + Some(art.worktree),
440 + )
441 + .await
442 + }
398 443
444 + /// Accept an artifact built elsewhere and take it through the same host-tier
445 + /// gating and advance a Sando-built one gets.
446 + ///
447 + /// This is the whole point of the seam. `intake::accept` publishes the bundle
448 + /// content-addressed once it has proved the bytes are the ones the record
449 + /// vouches for, which lands it at exactly the state [`publish`] leaves a
450 + /// Sando-built bundle in — so the two paths join at [`record_and_gate`] and
451 + /// nothing downstream knows or cares which one it came from.
452 + ///
453 + /// `staged` must already sit under `release_root/staging/` (publishing is an
454 + /// atomic same-filesystem rename); getting the bytes there is the transport's
455 + /// job, not this function's.
456 + #[allow(clippy::too_many_arguments)]
457 + pub async fn intake_and_gate(
458 + pool: SqlitePool,
459 + cfg: Arc<AppConfig>,
460 + topo: Arc<Topology>,
461 + staged: PathBuf,
462 + record_json: String,
463 + events: crate::events::EventTx,
464 + run_id: RunId,
465 + deploy_lock: Arc<tokio::sync::Mutex<()>>,
466 + ) -> Result<()> {
467 + crate::runs::set_phase(&pool, run_id, crate::runs::Phase::Staging)
468 + .await
469 + .ok();
470 +
471 + let accepted = crate::intake::accept(&cfg.release_root, &staged, &record_json)
472 + .await
473 + .map_err(|e| anyhow::anyhow!("{e}"))?;
474 +
475 + let version = Version::parse(&accepted.record.provenance.version).with_context(|| {
476 + format!(
477 + "artifact record carries version `{}`, which is not semver",
478 + accepted.record.provenance.version
479 + )
480 + })?;
481 + let platform = Platform::parse(&accepted.record.provenance.target).with_context(|| {
482 + format!(
483 + "artifact record carries target `{}`, which is not `os/arch`",
484 + accepted.record.provenance.target
485 + )
486 + })?;
487 + let git_sha = GitSha::parse(&accepted.record.provenance.git_sha).with_context(|| {
488 + format!(
489 + "artifact record carries git_sha `{}`",
490 + accepted.record.provenance.git_sha
491 + )
492 + })?;
493 +
494 + crate::runs::set_version(&pool, run_id, &version).await.ok();
495 + upsert_version_row(
496 + &pool,
497 + &cfg.id,
498 + &version,
499 + &git_sha,
500 + &accepted.released.join(cfg.primary_bin()),
501 + )
502 + .await?;
503 +
504 + let published = Published {
505 + version,
506 + released: accepted.released,
507 + digest_full: accepted.record.digest.to_string(),
508 + platform: Some(platform),
509 + };
510 + record_identity(&pool, &cfg, &published, run_id).await?;
511 +
512 + // An intake carries no worktree, and the gates that need one refuse rather
513 + // than resolve against nothing. That is the boundary showing up in the type:
514 + // artifact-scoped gates belong to the builder (wiki [[sando-bento-boundary]]),
515 + // so a tier that asks Sando to re-run them against an accepted artifact is
516 + // misconfigured and should be told so.
517 + record_and_gate(
518 + pool,
519 + cfg,
520 + topo,
521 + published,
522 + events,
523 + run_id,
524 + deploy_lock,
525 + None,
526 + )
527 + .await
528 + }
529 +
530 + /// Record the `versions` label row for an artifact that arrived rather than was
531 + /// built here. The build path writes its own inside [`run`]; this is the same
532 + /// row for the path that never ran a compiler.
533 + async fn upsert_version_row(
534 + pool: &SqlitePool,
535 + app: &crate::domain::AppId,
536 + version: &Version,
537 + git_sha: &GitSha,
538 + artifact_path: &Path,
539 + ) -> Result<()> {
540 + sqlx::query(
541 + "INSERT OR IGNORE INTO versions (app, version, git_sha, built_at, artifact_path)
542 + VALUES (?, ?, ?, ?, ?)",
543 + )
544 + .bind(app)
545 + .bind(version)
546 + .bind(git_sha)
547 + .bind(Utc::now().to_rfc3339())
548 + .bind(artifact_path.to_string_lossy().as_ref())
549 + .execute(pool)
550 + .await?;
551 + Ok(())
552 + }
553 +
554 + /// Assemble a bundle out of a worktree: binaries, `release_contents`, companions.
555 + async fn assemble_from_source(
556 + cfg: &AppConfig,
557 + art: &BuildArtifact,
558 + run_id: RunId,
559 + ) -> Result<StagedBundle> {
399 560 // Stage the bundle into `staging/<build_id>/` — a private scratch dir, not
400 561 // yet a release. It is published content-addressed below, once its digest is
401 562 // known. This is what makes overwrite unexpressible (wiki
402 563 // [[release-artifact-identity]]): a build never touches another build's dir.
403 - let host_release_root = &cfg.release_root;
404 564 let staging =
405 - deploy::stage_local_bundle(host_release_root, run_id.0, &art.binary_paths).await?;
565 + deploy::stage_local_bundle(&cfg.release_root, run_id.0, &art.binary_paths).await?;
406 566
407 567 // Stage every entry from cfg.release_contents into the staged bundle. This is
408 568 // how non-binary version-coupled content (static assets, docs, error-pages,
@@ -434,40 +594,106 @@
434 594 }
435 595 }
436 596
597 + Ok(StagedBundle {
598 + version: art.version.clone(),
599 + staging,
600 + platform: cfg.platform.clone(),
601 + })
602 + }
603 +
604 + /// A bundle that has been hashed and published content-addressed. Both paths
605 + /// produce one; nothing downstream can tell them apart.
606 + struct Published {
607 + version: Version,
608 + released: PathBuf,
609 + digest_full: String,
610 + platform: Option<Platform>,
611 + }
612 +
613 + /// Hash the assembled bundle, write its MANIFEST, and publish it at
614 + /// `releases/<digest16>`.
615 + ///
616 + /// The intake path does not call this: `intake::accept` does the same three
617 + /// steps itself, because it has to hash the bytes to verify them and hashing
618 + /// them twice would be the one place the two implementations could disagree.
619 + async fn publish(
620 + pool: &SqlitePool,
621 + cfg: &AppConfig,
622 + staged: StagedBundle,
623 + run_id: RunId,
624 + ) -> Result<Published> {
437 625 // Content identity: hash the fully-staged bundle, write its MANIFEST into the
438 626 // bundle (for node-side verification), then publish it at `releases/<digest16>`.
439 627 // The digest is now load-bearing — a hashing failure fails the build rather
440 628 // than shipping an unidentifiable artifact.
441 - let digest = crate::bundle::digest_dir(&staging)
629 + let digest = crate::bundle::digest_dir(&staged.staging)
442 630 .await
443 631 .context("hashing the staged bundle for content addressing")?;
444 632 tokio::fs::write(
445 - staging.join(crate::bundle::MANIFEST_NAME),
633 + staged.staging.join(crate::bundle::MANIFEST_NAME),
446 634 digest.manifest.as_bytes(),
447 635 )
448 636 .await
449 637 .context("writing bundle MANIFEST")?;
450 638 let released =
451 - deploy::finalize_local_release(host_release_root, &staging, digest.short()).await?;
639 + deploy::finalize_local_release(&cfg.release_root, &staged.staging, digest.short()).await?;
452 640
453 641 let staged_bin = released.join(cfg.primary_bin());
454 642 sqlx::query("UPDATE versions SET artifact_path = ? WHERE app = ? AND version = ?")
455 643 .bind(staged_bin.to_string_lossy().as_ref())
456 644 .bind(&cfg.id)
457 - .bind(&art.version)
458 - .execute(&pool)
645 + .bind(&staged.version)
646 + .execute(pool)
459 647 .await?;
460 648
461 - // Record the identity on the build row: the full digest and the
462 - // content-addressed dir the bundle was published to. This is what promote
463 - // resolves the artifact through, and burn-in/retention key on.
464 - {
465 - let released_path = released.to_string_lossy();
466 - crate::runs::set_identity(&pool, run_id, &digest.full, &released_path)
467 - .await
468 - .ok();
469 - }
649 + let published = Published {
650 + version: staged.version,
651 + released,
652 + digest_full: digest.full,
653 + platform: staged.platform,
654 + };
655 + record_identity(pool, cfg, &published, run_id).await?;
656 + Ok(published)
657 + }
470 658
659 + /// Record the identity on the build row: the digest, the content-addressed dir
660 + /// the bundle was published to, and what it runs on. This is what promote
661 + /// resolves the artifact through, and burn-in/retention key on.
662 + async fn record_identity(
663 + pool: &SqlitePool,
664 + cfg: &AppConfig,
665 + published: &Published,
666 + run_id: RunId,
667 + ) -> Result<()> {
668 + let released_path = published.released.to_string_lossy();
669 + crate::runs::set_identity(pool, run_id, &published.digest_full, &released_path)
670 + .await
671 + .ok();
672 + // Platform is what lets two bundles of one version be told apart, so a
673 + // dropped write here would leave a pom artifact that can be placed nowhere
674 + // (a node declaring a platform refuses an artifact that records none). Fail
675 + // rather than ship an unplaceable bundle.
676 + if let Some(p) = &published.platform {
677 + crate::runs::set_platform(pool, run_id, p)
678 + .await
679 + .with_context(|| format!("recording platform {p} for {}", cfg.id))?;
680 + }
681 + Ok(())
682 + }
683 +
684 + /// The shared tail of both paths: run the host tier's gates against a published
685 + /// bundle and advance `tier_state` iff all pass.
686 + #[allow(clippy::too_many_arguments)]
687 + async fn record_and_gate(
688 + pool: SqlitePool,
689 + cfg: Arc<AppConfig>,
690 + topo: Arc<Topology>,
691 + published: Published,
692 + events: crate::events::EventTx,
693 + run_id: RunId,
694 + deploy_lock: Arc<tokio::sync::Mutex<()>>,
695 + worktree: Option<PathBuf>,
696 + ) -> Result<()> {
471 697 let host = topo
472 698 .tiers
473 699 .iter()
@@ -481,8 +707,12 @@
481 707 pool: pool.clone(),
482 708 cfg: cfg.clone(),
483 709 tier: TierId::new("host"),
484 - version: art.version.clone(),
485 - worktree: art.worktree.clone(),
710 + version: published.version.clone(),
711 + worktree,
712 + // The published bundle. `migration_dry_run` prefers it over the
713 + // worktree, so the migrations it proves are the ones inside the digest
714 + // rather than ones sitting beside them in a checkout.
715 + bundle: Some(published.released.clone()),
486 716 events: events.clone(),
487 717 // Host runs build-time gates (cargo_test / migration_dry_run /
488 718 // boot_smoke) only — `node_health` never appears here, so there are no
@@ -504,7 +734,8 @@
504 734 // ultra-fuzz Run 2, S1). Held only for the atomic UPDATE, never the gates.
505 735 {
506 736 let _deploy_guard = deploy_lock.lock().await;
507 - crate::runs::advance_tier(&pool, &cfg.id, "host", &art.version, Some(run_id.0)).await?;
737 + crate::runs::advance_tier(&pool, &cfg.id, "host", &published.version, Some(run_id.0))
738 + .await?;
508 739 }
509 740 // Terminal verdict: unlike the phase pings above (best-effort), a dropped
510 741 // pass/fail write leaves the run wedged at `building`. Log it loudly if it
@@ -513,17 +744,17 @@
513 744 if let Err(e) = crate::runs::mark_passed(&pool, run_id).await {
514 745 tracing::error!(run_id = %run_id, error = %e, "persisting host-green verdict failed; run may show stale 'building' until restart-reconcile");
515 746 }
516 - tracing::info!(version = %art.version, "host pipeline green; ready to promote to next tier");
747 + tracing::info!(version = %published.version, "host pipeline green; ready to promote to next tier");
517 748 } else {
518 749 // Pull the first red gate's typed summary into the run so the API
519 750 // answers "which gate, and why" — not just "failed".
520 - let summary = crate::runs::first_failed_gate_summary(&pool, &cfg.id, &art.version)
751 + let summary = crate::runs::first_failed_gate_summary(&pool, &cfg.id, &published.version)
521 752 .await
522 753 .unwrap_or_else(|| "host pipeline red".to_string());
523 754 if let Err(e) = crate::runs::mark_failed(&pool, run_id, &summary).await {
524 755 tracing::error!(run_id = %run_id, error = %e, "persisting host-red verdict failed; run may show stale 'building' until restart-reconcile");
525 756 }
526 - tracing::warn!(version = %art.version, "host pipeline red; not advancing tier_state");
757 + tracing::warn!(version = %published.version, "host pipeline red; not advancing tier_state");
527 758 }
528 759 Ok(())
529 760 }
@@ -622,7 +853,8 @@
622 853 #[cfg(test)]
623 854 mod tests {
624 855 use super::{
625 - BuildArtifact, check_build_host, checkout_aux_repos, runtime_hostname, stage_and_gate, tail,
856 + BuildArtifact, check_build_host, checkout_aux_repos, intake_and_gate, runtime_hostname,
857 + stage_and_gate, tail,
626 858 };
627 859 use crate::config::{AppConfig, TestTarget};
628 860 use crate::domain::{GitSha, RunId, Version};
@@ -700,9 +932,10 @@
700 932 .unwrap();
701 933
702 934 let cfg = AppConfig {
935 + platform: None,
703 936 id: crate::domain::AppId::default(),
704 937 topology_path: PathBuf::from("/tmp/test-sando.toml"),
705 - build_host: "test-host".into(),
938 + build_host: Some("test-host".into()),
706 939 workdir: tmp.path().to_path_buf(),
707 940 release_root: release_root.clone(),
708 941 scratch_db_url: None,
@@ -728,11 +961,11 @@
728 961 };
729 962
730 963 let topo = Topology {
731 - repo: RepoConfig {
964 + repo: Some(RepoConfig {
732 965 bare_path: "/tmp/test.git".into(),
733 966 branch: "main".into(),
734 967 upstream: None,
735 - },
968 + }),
736 969 backup: vec![BackupConfig {
737 970 name: "server".into(),
738 971 source: "file:///tmp/test-backup.sql".into(),
@@ -767,6 +1000,236 @@
767 1000 )
768 1001 }
769 1002
1003 + // ---- intake through the seam ----
1004 +
1005 + /// The `ArtifactRecord` Bento would have written for a staged bundle.
1006 + async fn record_for(staged: &std::path::Path, version: &str, target: &str) -> String {
1007 + use ops_artifact::{ArtifactRecord, GateRecord, Manifest, Provenance, Scope, Verdict};
1008 + let computed = crate::bundle::digest_dir(staged).await.unwrap();
1009 + let manifest = Manifest::parse(&computed.manifest).unwrap();
1010 + let at = chrono::DateTime::<chrono::Utc>::from_timestamp(1_754_000_000, 0).unwrap();
1011 + ArtifactRecord::new(
1012 + "bento",
1013 + manifest,
1014 + Provenance {
1015 + app: "pom".into(),
1016 + version: version.into(),
1017 + tag: format!("pom-v{version}"),
1018 + git_sha: "a".repeat(40),
1019 + target: target.into(),
1020 + build_host: "astra".into(),
1021 + toolchain: "rustc 1.97.0".into(),
1022 + built_at: at,
1023 + },
1024 + vec![GateRecord::new(
1025 + "prebuild",
1026 + Scope::Artifact,
1027 + Verdict::Passed,
1028 + "prebuild passed in 90s",
1029 + at,
1030 + )],
1031 + )
1032 + .unwrap()
1033 + .to_json()
1034 + }
1035 +
1036 + #[tokio::test]
1037 + async fn an_accepted_artifact_is_published_gated_and_advances_the_tier() {
1038 + // The seam: an artifact Sando did not build reaches the same published,
1039 + // gated, tier-advanced end state a Sando-built one does. No worktree
1040 + // exists anywhere in this test, which is the point — everything from
1041 + // `finalize_local_release` onward stopped caring where the bytes came
1042 + // from.
1043 + let (pool, cfg, topo, _art, run_id, _version, tmp) = stage_fixture(vec![]).await;
1044 + let deploy_lock = Arc::new(tokio::sync::Mutex::new(()));
1045 +
1046 + let staged = cfg.release_root.join("staging").join("intake-1");
1047 + tokio::fs::create_dir_all(&staged).await.unwrap();
1048 + tokio::fs::write(staged.join("makenotwork"), b"bytes built elsewhere")
1049 + .await
1050 + .unwrap();
1051 + let record = record_for(&staged, "1.2.3", "linux/aarch64").await;
1052 +
1053 + intake_and_gate(
1054 + pool.clone(),
1055 + cfg.clone(),
1056 + topo,
1057 + staged.clone(),
1058 + record,
1059 + crate::events::channel(),
1060 + run_id,
1061 + deploy_lock,
1062 + )
1063 + .await
1064 + .expect("a green intake settles the run");
1065 +
1066 + // Published content-addressed, and the staging dir is gone: renamed,
1067 + // not copied.
1068 + let (digest, staged_path, platform): (Option<String>, Option<String>, Option<String>) =
1069 + sqlx::query_as(
1070 + "SELECT bundle_digest, staged_path, platform FROM build_runs WHERE id = ?",
1071 + )
1072 + .bind(run_id.0)
1073 + .fetch_one(&pool)
1074 + .await
1075 + .unwrap();
1076 + let digest = digest.expect("bundle_digest recorded");
1077 + let staged_path = staged_path.expect("staged_path recorded");
1078 + assert_eq!(digest.len(), 64);
1079 + assert!(
1080 + !staged.exists(),
1081 + "staging was renamed into the release root"
1082 + );
1083 + assert_eq!(
1084 + std::path::Path::new(&staged_path),
1085 + tmp.path()
1086 + .join("release-root")
1087 + .join("releases")
1088 + .join(&digest[..16]),
1089 + );
1090 +
1091 + // The platform came off the record's provenance and is on the row. This
Lines truncated
@@ -63,12 +63,31 @@
63 63 #[serde(skip)]
64 64 pub id: AppId,
65 65 pub topology_path: PathBuf,
66 + /// What this product's bundles run on, as `os/arch`, when Sando builds them
67 + /// itself. Left unset for a product whose artifacts arrive from a builder:
68 + /// an intake takes the platform from its record's provenance, which is the
69 + /// only place that answer is trustworthy when two architectures ship under
70 + /// one version.
71 + ///
72 + /// Unset is not a wildcard. A node declaring a platform refuses an artifact
73 + /// that records none, so setting this on a product means setting it on that
74 + /// product's nodes too (see [`crate::deploy::Placement`]).
75 + #[serde(default)]
76 + pub platform: Option<crate::domain::Platform>,
66 77 /// The runtime hostname (`/proc/sys/kernel/hostname`) this daemon is
67 78 /// permitted to build on. `build::run` refuses to compile unless the live
68 79 /// host matches, so a `sandod` misdeployed onto a prod/serving node (e.g.
69 80 /// Hetzner) cannot build there — "never build on prod" becomes an invariant
70 - /// rather than a code-path accident. Required: there is no safe default.
71 - pub build_host: String,
81 + /// rather than a code-path accident. There is no safe default.
82 + ///
83 + /// Unset declares the product **intake-only**: Sando never compiles it, and
84 + /// `build::run` refuses rather than picking a host. That is pom, which is
85 + /// built natively on two architectures by Bento and only ever handed to
86 + /// Sando as finished bytes (wiki [[sando-bento-boundary]]). Naming a build
87 + /// host for a product Sando must not build would be a claim the code would
88 + /// then be free to act on.
89 + #[serde(default)]
90 + pub build_host: Option<String>,
72 91 /// Host-local checkout scratch dir (per-sha worktrees live here).
73 92 pub workdir: PathBuf,
74 93 /// Host-local releases dir. Bundles are staged at `staging/<build_id>/`,
@@ -616,9 +635,10 @@
616 635 #[cfg(test)]
617 636 pub fn for_tests() -> Self {
618 637 Self {
638 + platform: None,
619 639 id: crate::domain::AppId::default(),
620 640 topology_path: PathBuf::from("/tmp/sando-test-topology.toml"),
621 - build_host: "test-host".into(),
641 + build_host: Some("test-host".into()),
622 642 workdir: PathBuf::from("/tmp/sando-test-workdir"),
623 643 release_root: PathBuf::from("/tmp/sando-test-release-root"),
624 644 scratch_db_url: None,
@@ -677,7 +697,7 @@
677 697 let id = AppId::new(crate::domain::DEFAULT_APP);
678 698 let app = &apps[&id];
679 699 assert_eq!(app.id, id, "the app must know its own name");
680 - assert_eq!(app.build_host, "fw13");
700 + assert_eq!(app.build_host.as_deref(), Some("fw13"));
681 701 // Relative paths resolve against the config file, not the daemon's CWD,
682 702 // which is what makes a fixture config usable from a test at all.
683 703 assert_eq!(app.topology_path, dir.path().join("../sando.toml"));
@@ -1104,10 +1124,13 @@
1104 1124 }
1105 1125
1106 1126 #[test]
1107 - fn build_host_is_required() {
1108 - // No safe default: a config without build_host must not parse, so the
1109 - // no-build-on-prod guard can never be silently skipped.
1127 + fn an_absent_build_host_declares_the_product_intake_only() {
1128 + // There is still no default host: absent does not mean "build anywhere",
1129 + // it means Sando does not build this product at all, and `build::run`
1130 + // refuses rather than choosing. The no-build-on-prod guard cannot be
1131 + // skipped by omission, because omission removes the build path.
1110 1132 let without = MINIMAL.replace("build_host = \"fw13\"\n", "");
1111 - assert!(toml::from_str::<AppConfig>(&without).is_err());
1133 + let cfg: AppConfig = toml::from_str(&without).expect("intake-only is a valid product");
1134 + assert_eq!(cfg.build_host, None);
1112 1135 }
1113 1136 }
@@ -26,6 +26,7 @@
26 26 //! is identical to the pre-extraction code — this is a transport extraction,
27 27 //! not a model change.
28 28
29 + use crate::domain::Platform;
29 30 use crate::topology::Node;
30 31 use anyhow::{Context, Result};
31 32 use async_trait::async_trait;
@@ -33,6 +34,104 @@
33 34 use std::path::{Path, PathBuf};
34 35 use tokio::process::Command;
35 36
37 + /// A staged bundle proven to be for the node it is about to be pushed to.
38 + ///
39 + /// This exists because "ship aarch64 bytes to an x86_64 box" was, until pom, a
40 + /// mistake nobody could make: one product, one build host, one architecture, so
41 + /// the pairing of a bundle and a node was correct by having no alternative. pom
42 + /// has two architectures under one version, so the pairing becomes a real
43 + /// choice, and a wrong choice deploys a binary the node cannot exec.
44 + ///
45 + /// The answer is not a check before the call. A check is something a later
46 + /// caller forgets, and the failure it guards is discovered by a production node
47 + /// failing to start. [`Placement::check`] is the *only* way to obtain one of
48 + /// these, and [`deploy_node`] takes one instead of a loose `(node, dir)` pair —
49 + /// so a mismatched deploy is not a bug the code has to avoid, it is a value the
50 + /// code cannot construct.
51 + #[derive(Debug, Clone)]
52 + pub struct Placement<'a> {
53 + node: &'a Node,
54 + bundle: &'a Path,
55 + }
56 +
57 + /// Why a bundle may not be placed on a node.
58 + ///
59 + /// All four cases are refusals, including both "one side said nothing" cases.
60 + /// Silence is not agreement: a node that does not state its platform cannot
61 + /// vouch that it runs a bundle built for a stated one, and a bundle that does
62 + /// not state its platform cannot satisfy a node that requires one. The only
63 + /// admissible pairing besides a match is both sides silent, which is the
64 + /// single-platform world Sando lived in and MNW still lives in.
65 + #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
66 + pub enum PlacementError {
67 + #[error(
68 + "node {node} runs {node_platform} and this bundle was built for {artifact_platform}; \
69 + refusing to deploy a binary the node cannot execute"
70 + )]
71 + Mismatch {
72 + node: String,
73 + node_platform: Platform,
74 + artifact_platform: Platform,
75 + },
76 + #[error(
77 + "node {node} does not declare a platform, and this bundle was built for \
78 + {artifact_platform}. Declare `platform` on the node so the two can be compared"
79 + )]
80 + NodeSilent {
81 + node: String,
82 + artifact_platform: Platform,
83 + },
84 + #[error(
85 + "node {node} requires {node_platform} and this bundle records no platform. \
86 + An artifact whose platform is unknown cannot be shown to satisfy one that is"
87 + )]
88 + ArtifactSilent {
89 + node: String,
90 + node_platform: Platform,
91 + },
92 + }
93 +
94 + impl<'a> Placement<'a> {
95 + /// The one constructor. `artifact` is the platform the bundle records, which
96 + /// for an accepted artifact comes from its `ArtifactRecord` provenance and
97 + /// for a Sando-built one comes from the app config.
98 + pub fn check(
99 + node: &'a Node,
100 + bundle: &'a Path,
101 + artifact: Option<&Platform>,
102 + ) -> Result<Self, PlacementError> {
103 + match (node.platform.as_ref(), artifact) {
104 + (Some(n), Some(a)) if n == a => Ok(Self { node, bundle }),
105 + (Some(n), Some(a)) => Err(PlacementError::Mismatch {
106 + node: node.name.to_string(),
107 + node_platform: n.clone(),
108 + artifact_platform: a.clone(),
109 + }),
110 + (None, Some(a)) => Err(PlacementError::NodeSilent {
111 + node: node.name.to_string(),
112 + artifact_platform: a.clone(),
113 + }),
114 + (Some(n), None) => Err(PlacementError::ArtifactSilent {
115 + node: node.name.to_string(),
116 + node_platform: n.clone(),
117 + }),
118 + // Both silent: the single-platform world. MNW is here, and stays
119 + // here until its nodes declare a platform — at which point its
120 + // builds have to as well, which is the forcing function rather than
121 + // a silently mixed state.
122 + (None, None) => Ok(Self { node, bundle }),
123 + }
124 + }
125 +
126 + pub fn node(&self) -> &'a Node {
127 + self.node
128 + }
129 +
130 + pub fn bundle(&self) -> &'a Path {
131 + self.bundle
132 + }
133 + }
134 +
36 135 /// Keep this many release dirs per node; older ones get gc'd after a
37 136 /// successful deploy. Fixed for now; promote to config if the constant ever
38 137 /// needs to vary by tier.
@@ -148,20 +247,24 @@
148 247 Ok(released)
149 248 }
150 249
151 - /// Deploy `staged_release_dir` (a directory built on the Sando host by
152 - /// `deploy_local`) to `node` using `executor` (its transport from the topology
153 - /// executor map). For `ssh_target=local`, this is just a symlink swap; for
154 - /// remote nodes, we rsync the whole dir over the executor.
250 + /// Deploy a [`Placement`]'s bundle to its node using `executor` (the node's
251 + /// transport from the topology executor map). For `ssh_target=local`, this is
252 + /// just a symlink swap; for remote nodes, we rsync the whole dir over the
253 + /// executor.
254 + ///
255 + /// The bundle and the node arrive together inside the placement, so there is no
256 + /// signature here that accepts a bundle and a node that were never compared.
155 257 ///
156 258 /// `primary_bin` is only used for logging — every file present in the staged
157 259 /// dir gets shipped.
158 260 pub async fn deploy_node(
159 261 executor: &dyn Executor,
160 - node: &Node,
262 + placement: Placement<'_>,
161 263 version: &str,
162 - staged_release_dir: &Path,
163 264 primary_bin: &str,
164 265 ) -> Result<PathBuf> {
266 + let node = placement.node();
267 + let staged_release_dir = placement.bundle();
165 268 // The release dir is named for its content digest (`releases/<digest16>`),
166 269 // not the version. The node mirrors that name so host and node agree on the
167 270 // artifact's identity; the version is only a log label here. Legacy staged
@@ -647,6 +750,103 @@
647 750 use std::sync::{Arc, Mutex as StdMutex};
648 751 use std::time::SystemTime;
649 752
753 + // ---- placement ----
754 + //
755 + // The whole table, because the interesting cases are the two where one side
756 + // said nothing. Treating silence as agreement is how a wrong-architecture
757 + // deploy would get through, and it is the shape a "check it before you call"
758 + // guard tends to end up with.
759 +
760 + fn node_on(platform: Option<&str>) -> Node {
761 + Node {
762 + name: crate::domain::NodeId::new("n1"),
763 + ssh_target: "deploy@n1".into(),
764 + release_root: "/opt/x".into(),
765 + platform: platform.map(|p| Platform::parse(p).unwrap()),
766 + service_name: "x.service".into(),
767 + config_check_env_file: None,
768 + actuate: crate::topology::default_actuate(),
769 + observe: crate::topology::default_observe(),
770 + health_url: None,
771 + companions: Vec::new(),
772 + }
773 + }
774 +
775 + #[test]
776 + fn matching_platforms_are_placeable() {
777 + let node = node_on(Some("linux/aarch64"));
778 + let art = Platform::parse("linux/aarch64").unwrap();
779 + let p = Placement::check(&node, Path::new("/r/abc"), Some(&art)).expect("a match places");
780 + assert_eq!(p.bundle(), Path::new("/r/abc"));
781 + assert_eq!(p.node().name.as_str(), "n1");
782 + }
783 +
784 + #[test]
785 + fn a_different_architecture_is_refused() {
786 + // The failure this type exists for: pom's aarch64 bundle reaching the
787 + // x86_64 box, which execs nothing and takes the watcher down.
788 + let node = node_on(Some("linux/x86_64"));
789 + let art = Platform::parse("linux/aarch64").unwrap();
790 + let err = Placement::check(&node, Path::new("/r/abc"), Some(&art)).unwrap_err();
791 + assert!(
792 + matches!(err, PlacementError::Mismatch { .. }),
793 + "expected a mismatch, got {err}"
794 + );
795 + // The message has to name both, or an operator cannot tell which half
796 + // is wrong.
797 + let msg = err.to_string();
798 + assert!(
799 + msg.contains("linux/x86_64") && msg.contains("linux/aarch64"),
800 + "{msg}"
801 + );
802 + }
803 +
804 + #[test]
805 + fn a_silent_node_refuses_a_stated_artifact() {
806 + // Not "the node probably runs it". A node that never said what it is
807 + // cannot vouch for a bundle that did, and the pairing that looks
808 + // harmless here is exactly the one that ships the wrong half of a
809 + // two-architecture release.
810 + let node = node_on(None);
811 + let art = Platform::parse("linux/aarch64").unwrap();
812 + assert!(matches!(
813 + Placement::check(&node, Path::new("/r/abc"), Some(&art)),
814 + Err(PlacementError::NodeSilent { .. })
815 + ));
816 + }
817 +
818 + #[test]
819 + fn a_stated_node_refuses_a_silent_artifact() {
820 + let node = node_on(Some("linux/aarch64"));
821 + assert!(matches!(
822 + Placement::check(&node, Path::new("/r/abc"), None),
823 + Err(PlacementError::ArtifactSilent { .. })
824 + ));
825 + }
826 +
827 + #[test]
828 + fn both_silent_is_the_single_platform_world_and_still_places() {
829 + // MNW is here and stays here. Its nodes declare nothing and its builds
830 + // record nothing, which is the truth about a product with one build host
831 + // and one architecture. The moment either side starts stating, the other
832 + // has to as well — that is the forcing function, and it is why this cell
833 + // is the only admissible non-match.
834 + let node = node_on(None);
835 + Placement::check(&node, Path::new("/r/abc"), None).expect("the pre-pom world still ships");
836 + }
837 +
838 + #[test]
839 + fn platform_parsing_is_a_shape_not_a_spelling() {
840 + assert_eq!(
841 + Platform::parse("Linux/AArch64").unwrap(),
842 + Platform::parse("linux/aarch64").unwrap(),
843 + "case is not a distinction between two machines"
844 + );
845 + for bad in ["linux", "linux/", "/aarch64", "linux/aarch64/gnu", ""] {
846 + assert!(Platform::parse(bad).is_err(), "{bad:?} should not parse");
847 + }
848 + }
849 +
650 850 // ---- failure stage ----
651 851 //
652 852 // The 2026-08-01 prod deploy failed its pre-swap config check, and the
@@ -1020,6 +1220,7 @@
1020 1220 tokio::fs::write(staged.join("server"), b"x").await.unwrap();
1021 1221
1022 1222 let node = crate::topology::Node {
1223 + platform: None,
1023 1224 name: "unreachable".into(),
1024 1225 ssh_target: "deploy@192.0.2.1".into(),
1025 1226 release_root: "/opt/never".into(),
@@ -1035,7 +1236,8 @@
1035 1236 CapabilitySet::from_tokens(["deploy", "restart"], ["health"]),
1036 1237 );
1037 1238
1038 - let result = deploy_node(&executor, &node, "0.0.1", &staged, "server").await;
1239 + let placement = Placement::check(&node, &staged, None).expect("both sides silent");
1240 + let result = deploy_node(&executor, placement, "0.0.1", "server").await;
1039 1241 let err = result.expect_err("deploy to unreachable host should fail");
1040 1242 let msg = format!("{err:#}");
1041 1243 // Don't pin exact wording, just that the failure is attributed (ssh /
@@ -1060,6 +1262,7 @@
1060 1262 tokio::fs::write(staged.join("server"), b"x").await.unwrap();
1061 1263
1062 1264 let node = crate::topology::Node {
1265 + platform: None,
1063 1266 name: "local-dev".into(),
1064 1267 ssh_target: "local".into(),
1065 1268 release_root: release_root.to_string_lossy().into_owned(),
@@ -1072,9 +1275,14 @@
1072 1275 };
1073 1276 let executor = local_executor();
1074 1277
1075 - let out = deploy_node(&executor, &node, "0.0.1", &staged, "server")
1076 - .await
1077 - .unwrap();
1278 + let out = deploy_node(
1279 + &executor,
1280 + Placement::check(&node, &staged, None).unwrap(),
1281 + "0.0.1",
1282 + "server",
1283 + )
1284 + .await
1285 + .unwrap();
1078 1286 assert_eq!(out, staged);
1079 1287 let target = tokio::fs::read_link(release_root.join("current"))
1080 1288 .await
@@ -1411,6 +1619,7 @@
1411 1619 tokio::fs::create_dir_all(&staged).await.unwrap();
1412 1620
1413 1621 let node = crate::topology::Node {
1622 + platform: None,
1414 1623 name: "local-dev".into(),
1415 1624 ssh_target: "local".into(),
1416 1625 release_root: release_root.to_string_lossy().into_owned(),
@@ -1422,9 +1631,14 @@
1422 1631 companions: Vec::new(),
1423 1632 };
1424 1633 let executor = LocalExec::new(CapabilitySet::from_tokens(["restart"], Vec::<&str>::new()));
1425 - let err = deploy_node(&executor, &node, "0.0.1", &staged, "server")
1426 - .await
1427 - .unwrap_err();
1634 + let err = deploy_node(
1635 + &executor,
1636 + Placement::check(&node, &staged, None).unwrap(),
1637 + "0.0.1",
1638 + "server",
1639 + )
1640 + .await
1641 + .unwrap_err();
1428 1642 assert!(
1429 1643 format!("{err:#}").contains("capability denied"),
1430 1644 "expected capability denial"
@@ -1514,6 +1728,7 @@
1514 1728
1515 1729 fn remote_node(config_check: bool, companions: Vec<NodeCompanion>) -> Node {
1516 1730 Node {
1731 + platform: None,
1517 1732 name: "web-a".into(),
1518 1733 ssh_target: "deploy@web-a".into(),
1519 1734 release_root: "/opt/mnw".into(),
@@ -1553,9 +1768,14 @@
1553 1768
1554 1769 let node = remote_node(true, vec![companion()]);
1555 1770 let exec = FakeExec::new();
1556 - let out = deploy_node(&exec, &node, "0.9.0", &staged, "makenotwork")
1557 - .await
1558 - .expect("deploy_remote should succeed against the fake");
1771 + let out = deploy_node(
1772 + &exec,
1773 + Placement::check(&node, &staged, None).unwrap(),
1774 + "0.9.0",
1775 + "makenotwork",
1776 + )
1777 + .await
1778 + .expect("deploy_remote should succeed against the fake");
1559 1779 assert_eq!(out, PathBuf::from("/opt/mnw/releases/0.9.0"));
1560 1780
1561 1781 let log = exec.log();
@@ -1583,9 +1803,14 @@
1583 1803 let node = remote_node(false, Vec::new());
1584 1804 let mut exec = FakeExec::new();
1585 1805 exec.fail_push_dir = true;
1586 - let err = deploy_node(&exec, &node, "0.9.0", &staged, "makenotwork")
1587 - .await
1588 - .expect_err("rsync failure must fail the deploy");
1806 + let err = deploy_node(
1807 + &exec,
1808 + Placement::check(&node, &staged, None).unwrap(),
1809 + "0.9.0",
1810 + "makenotwork",
1811 + )
1812 + .await
1813 + .expect_err("rsync failure must fail the deploy");
1589 1814 assert!(
1590 1815 format!("{err:#}").contains("rsync"),
1591 1816 "error should attribute the rsync: {err:#}"
@@ -1608,9 +1833,14 @@
1608 1833 let node = remote_node(false, Vec::new());
1609 1834 let mut exec = FakeExec::new();
1610 1835 exec.fail_run_matching = Some("e_machine".into());
1611 - let err = deploy_node(&exec, &node, "0.9.0", &staged, "makenotwork")
1612 - .await
1613 - .expect_err("arch mismatch must fail the deploy");
1836 + let err = deploy_node(
1837 + &exec,
1838 + Placement::check(&node, &staged, None).unwrap(),
1839 + "0.9.0",
1840 + "makenotwork",
1841 + )
1842 + .await
1843 + .expect_err("arch mismatch must fail the deploy");
1614 1844 assert!(
1615 1845 format!("{err:#}").contains("architecture"),
1616 1846 "error should mention the arch check: {err:#}"
@@ -1632,9 +1862,14 @@
1632 1862
1633 1863 let node = remote_node(false, Vec::new());
1634 1864 let exec = FakeExec::new();
1635 - deploy_node(&exec, &node, "0.9.0", &staged, "makenotwork")
1636 - .await
1637 - .unwrap();
1865 + deploy_node(
1866 + &exec,
1867 + Placement::check(&node, &staged, None).unwrap(),
1868 + "0.9.0",
1869 + "makenotwork",
1870 + )
1871 + .await
1872 + .unwrap();
1638 1873 let log = exec.log();
1639 1874 assert!(
1640 1875 !log.iter().any(|c| c.contains("MNW_CHECK_CONFIG=1")),
@@ -1656,9 +1891,14 @@
1656 1891
1657 1892 let node = remote_node(false, vec![companion()]);
1658 1893 let exec = FakeExec::new();
1659 - deploy_node(&exec, &node, "0.9.0", &staged, "makenotwork")
1660 - .await
1661 - .unwrap();
1894 + deploy_node(
1895 + &exec,
1896 + Placement::check(&node, &staged, None).unwrap(),
1897 + "0.9.0",
1898 + "makenotwork",
1899 + )
1900 + .await
1901 + .unwrap();
1662 1902 let log = exec.log();
1663 1903 assert!(
1664 1904 pos(&log, "reload-or-restart") < pos(&log, "install-companion.sh"),
@@ -319,6 +319,86 @@
319 319 }
320 320 }
321 321
322 + // ---------------------------------------------------------------------
323 + // Platform
324 + // ---------------------------------------------------------------------
325 +
326 + /// What a bundle was built for, and what a node can run: `os/arch`.
327 + ///
328 + /// Sando was single-platform for its whole life — one `build_host`, one
329 + /// architecture, one bundle per version — so nothing ever had to say which
330 + /// machine a set of bytes was for. pom breaks that: astra is aarch64 and
331 + /// hetzner is x86_64, so one pom version is two bundles with two digests, and
332 + /// "which of these goes to which box" becomes a question the system has to be
333 + /// able to answer.
334 + ///
335 + /// Parsed rather than stringly so the answer is a comparison of two values and
336 + /// not of two spellings. `ArtifactRecord.provenance.target` already carries this
337 + /// in `linux/aarch64` form; this is the type it parses into.
338 + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
339 + #[serde(try_from = "String", into = "String")]
340 + pub struct Platform {
341 + os: String,
342 + arch: String,
343 + }
344 +
345 + #[derive(Debug, thiserror::Error)]
346 + pub enum PlatformParseError {
347 + #[error("platform `{0}` is not `os/arch` (e.g. `linux/aarch64`)")]
348 + BadShape(String),
349 + }
350 +
351 + impl Platform {
352 + pub fn parse(s: &str) -> Result<Self, PlatformParseError> {
353 + let bad = || PlatformParseError::BadShape(s.to_owned());
354 + let (os, arch) = s.split_once('/').ok_or_else(bad)?;
355 + let part_ok = |p: &str| {
356 + !p.is_empty()
357 + && p.bytes()
358 + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')
359 + };
360 + if !part_ok(os) || !part_ok(arch) {
361 + return Err(bad());
362 + }
363 + Ok(Self {
364 + os: os.to_ascii_lowercase(),
365 + arch: arch.to_ascii_lowercase(),
366 + })
367 + }
368 + pub fn os(&self) -> &str {
369 + &self.os
370 + }
371 + pub fn arch(&self) -> &str {
372 + &self.arch
373 + }
374 + }
375 +
376 + impl fmt::Display for Platform {
377 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
378 + write!(f, "{}/{}", self.os, self.arch)
379 + }
380 + }
381 +
382 + impl FromStr for Platform {
383 + type Err = PlatformParseError;
384 + fn from_str(s: &str) -> Result<Self, Self::Err> {
385 + Self::parse(s)
386 + }
387 + }
388 +
389 + impl TryFrom<String> for Platform {
390 + type Error = PlatformParseError;
391 + fn try_from(s: String) -> Result<Self, Self::Error> {
392 + Self::parse(&s)
393 + }
394 + }
395 +
396 + impl From<Platform> for String {
397 + fn from(p: Platform) -> Self {
398 + p.to_string()
399 + }
400 + }
401 +
322 402 impl sqlx::Type<Sqlite> for GitSha {
323 403 fn type_info() -> <Sqlite as sqlx::Database>::TypeInfo {
324 404 <String as sqlx::Type<Sqlite>>::type_info()
@@ -15,6 +15,7 @@
15 15 use ops_core::remote::LogSink; // brings `LiveLog::write_chunk` (the sink trait) into scope
16 16 use sqlx::SqlitePool;
17 17 use std::collections::HashMap;
18 + use std::path::Path;
18 19 use std::path::PathBuf;
19 20 use std::sync::Arc;
20 21 use tokio::io::AsyncReadExt;
@@ -43,7 +44,18 @@
43 44 pub cfg: Arc<AppConfig>,
44 45 pub tier: TierId,
45 46 pub version: Version,
46 - pub worktree: PathBuf,
47 + /// The checkout this run's artifact was built from, when there is one.
48 + ///
49 + /// `None` for an accepted artifact: it was built elsewhere and Sando has no
50 + /// source tree for it. That is the boundary made visible (wiki
51 + /// [[sando-bento-boundary]]) — artifact-scoped gates belong to the builder,
52 + /// so a gate that reads source is one Sando should refuse to run here rather
53 + /// than resolve against a path that does not exist.
54 + pub worktree: Option<PathBuf>,
55 + /// The published, content-addressed bundle this run is about, when it has
56 + /// been published yet. `migration_dry_run` prefers it over the worktree, so
57 + /// what it proves is inside the digest rather than beside it.
58 + pub bundle: Option<PathBuf>,
47 59 pub events: EventTx,
48 60 /// Nodes the `node_health` post-deploy gate probes. Empty for build-time
49 61 /// gate runs on the host (where `node_health` never appears); filled at
@@ -74,10 +86,49 @@
74 86 /// (`Topology::ensure_test_target_aux_repos_exist`).
75 87 pub fn target_dir(&self, target: &crate::config::TestTarget) -> Option<PathBuf> {
76 88 match target.aux_repo.as_deref() {
77 - None => Some(self.worktree.join(&target.dir)),
89 + None => Some(self.worktree.as_ref()?.join(&target.dir)),
78 90 Some(name) => Some(self.aux_dirs.get(name)?.join(&target.dir)),
79 91 }
80 92 }
93 +
94 + /// The checkout, or a typed refusal for a gate that cannot work without one.
95 + ///
96 + /// Every caller of this is a gate whose evidence is about the *artifact*
97 + /// rather than about the artifact in an environment, which the boundary
98 + /// assigns to the builder. Reaching this arm means a tier asked Sando to
99 + /// re-run a builder's gate against a bundle it was handed, and the honest
100 + /// answer is to say so rather than to pass on having run nothing.
101 + pub fn worktree_for(&self, gate: GateKind) -> std::result::Result<&Path, GateOutcome> {
102 + self.worktree.as_deref().ok_or_else(|| {
103 + GateOutcome::failed(GateFailure::NeedsSource {
104 + gate,
105 + artifact: self.bundle.as_ref().map_or_else(
106 + || "an artifact built elsewhere".into(),
107 + |b| b.display().to_string(),
108 + ),
109 + })
110 + })
111 + }
112 +
113 + /// Where a `migration_check` finds its migrations.
114 + ///
115 + /// The bundle wins when it carries them. That is the point of shipping
116 + /// migrations as a `release_contents` entry: it puts them inside the digest,
117 + /// so the dry run proves something about the bytes that ship rather than
118 + /// about a checkout that happens to sit next to them. The worktree is the
119 + /// fallback for a build whose config has not opted in yet, and for an
120 + /// accepted artifact there is no fallback at all — if the builder did not
121 + /// bundle its migrations, Sando cannot dry-run them and says so.
122 + pub fn migrations_dir(&self, dir: &Path) -> Option<PathBuf> {
123 + if let Some(bundle) = &self.bundle {
124 + let in_bundle = bundle.join(dir);
125 + if in_bundle.is_dir() {
126 + return Some(in_bundle);
127 + }
128 + }
129 + let in_worktree = self.worktree.as_ref()?.join(dir);
130 + in_worktree.is_dir().then_some(in_worktree)
131 + }
81 132 }
82 133
83 134 /// One node the `node_health` gate verifies: its id, the systemd unit to
@@ -680,7 +731,10 @@
680 731 /// feature set is a different cfg, so no artifact sharing with `cargo_test`).
681 732 /// That is the price of the coverage; the filter keeps the *run* to seconds.
682 733 async fn hardening_test(ctx: &GateCtx, run_id: GateRunId) -> Result<GateOutcome> {
683 - let server_dir = ctx.worktree.join("server");
734 + let server_dir = match ctx.worktree_for(GateKind::HardeningTest) {
735 + Ok(w) => w.join("server"),
736 + Err(outcome) => return Ok(outcome),
737 + };
684 738 // No features is the whole point; the scratch DB is needed because the
685 739 // server's sqlx macros type-check against it. Unlike cargo_test, this gate
686 740 // is deliberately not driven by `test_targets`: it targets one specific
@@ -1024,7 +1078,19 @@
1024 1078 )));
1025 1079 }
1026 1080
1027 - let migrations_dir = ctx.worktree.join(&check.dir);
1081 + let Some(migrations_dir) = ctx.migrations_dir(&check.dir) else {
1082 + // Neither the bundle nor a checkout holds them. For an accepted
1083 + // artifact that means the builder did not ship its migrations, and a
1084 + // dry run over nothing would report green having proved nothing.
1085 + let msg = format!(
1086 + "{label}: no migrations at {} in the bundle or a checkout",
1087 + check.dir.display()
1088 + );
1089 + log.line(&msg).await;
1090 + return Ok(CheckResult::Stopped(GateOutcome::failed(
1091 + GateFailure::RestoreFailed { reason: msg },
1092 + )));
1093 + };
1028 1094 log.line("---- run_migrator ----\n").await;
1029 1095 match run_migrator(&db_url, &migrations_dir).await {
1030 1096 Ok(()) => {
@@ -1462,8 +1528,15 @@
1462 1528 /// Unlike the app build scripts, nothing here is best-effort. That asymmetry is
1463 1529 /// the point of the gate.
1464 1530 async fn code_smoke_frontends(ctx: &GateCtx, log: &GateLog) -> Option<GateOutcome> {
1531 + if ctx.cfg.frontend_builds.is_empty() {
1532 + return None;
1533 + }
1534 + let worktree = match ctx.worktree_for(GateKind::CodeSmoke) {
1535 + Ok(w) => w.to_path_buf(),
1536 + Err(outcome) => return Some(outcome),
1537 + };
1465 1538 for fe in &ctx.cfg.frontend_builds {
1466 - let dir = ctx.worktree.join(&fe.dir);
1539 + let dir = worktree.join(&fe.dir);
1467 1540 let label = fe.dir.display().to_string();
1468 1541 log.line(&format!("---- frontend build ({label}) ----\n"))
1469 1542 .await;
@@ -1557,7 +1630,10 @@
1557 1630 /// predates the flag and would fall through to a normal (DB-needing) boot and
1558 1631 /// hang.
1559 1632 async fn code_smoke_docs_check(ctx: &GateCtx, bin: &str, log: &GateLog) -> Option<GateOutcome> {
1560 - let server_dir = ctx.worktree.join("server");
1633 + let server_dir = match ctx.worktree_for(GateKind::CodeSmoke) {
1634 + Ok(w) => w.join("server"),
1635 + Err(outcome) => return Some(outcome),
1636 + };
1561 1637 log.line("---- docs check (MNW_CHECK_DOCS) ----\n").await;
1562 1638 let mut cmd = tokio::process::Command::new(bin);
1563 1639 cmd.env("MNW_CHECK_DOCS", "1")
@@ -1609,7 +1685,10 @@
1609 1685 /// probe. Returns the outcome without a `log_ref` (the caller attaches it after
1610 1686 /// teardown). Never returns `Err` — spawn/child failures map to typed outcomes.
1611 1687 async fn code_smoke_body(ctx: &GateCtx, bin: &str, db_url: &str, log: &GateLog) -> GateOutcome {
1612 - let server_dir = ctx.worktree.join("server");
1688 + let server_dir = match ctx.worktree_for(GateKind::CodeSmoke) {
1689 + Ok(w) => w.join("server"),
1690 + Err(outcome) => return outcome,
1691 + };
1613 1692
1614 1693 // Phase 1: migrate-from-scratch + seed. `--seed-examples` loads config,
1615 1694 // connects, runs migrations against the empty DB, seeds the catalog, exits.
@@ -2456,7 +2535,8 @@
2456 2535 cfg: std::sync::Arc::new(crate::config::AppConfig::for_tests()),
2457 2536 tier: TierId::new("host"),
2458 2537 version: "0.1.0".parse().unwrap(),
2459 - worktree: PathBuf::from(worktree),
2538 + worktree: Some(PathBuf::from(worktree)),
2539 + bundle: None,
2460 2540 events: events::channel(),
2461 2541 nodes: Vec::new(),
2462 2542 build_id: None,
@@ -2533,7 +2613,8 @@
2533 2613 cfg: std::sync::Arc::new(cfg),
2534 2614 tier: TierId::new("host"),
2535 2615 version: "0.1.0".parse().unwrap(),
2536 - worktree: worktree.to_path_buf(),
2616 + worktree: Some(worktree.to_path_buf()),
2617 + bundle: None,
2537 2618 events: events::channel(),
2538 2619 nodes: Vec::new(),
2539 2620 build_id: None,
@@ -2561,7 +2642,8 @@
2561 2642 cfg: std::sync::Arc::new(cfg),
2562 2643 tier: TierId::new("host"),
2563 2644 version: "0.1.0".parse().unwrap(),
2564 - worktree: worktree.to_path_buf(),
2645 + worktree: Some(worktree.to_path_buf()),
2646 + bundle: None,
2565 2647 events: events::channel(),
2566 2648 nodes: Vec::new(),
2567 2649 build_id: None,
@@ -2887,7 +2969,8 @@
2887 2969 cfg: std::sync::Arc::new(cfg),
2888 2970 tier: TierId::new("host"),
2889 2971 version: "0.1.0".parse().unwrap(),
2890 - worktree: tmp.path().to_path_buf(),
2972 + worktree: Some(tmp.path().to_path_buf()),
2973 + bundle: None,
2891 2974 events: events::channel(),
2892 2975 nodes: Vec::new(),
2893 2976 build_id: None,
@@ -2923,7 +3006,8 @@
2923 3006 cfg: std::sync::Arc::new(cfg),
2924 3007 tier: TierId::new("host"),
2925 3008 version: "0.1.0".parse().unwrap(),
2926 - worktree: std::path::PathBuf::from("/tmp/wt"),
3009 + worktree: Some(std::path::PathBuf::from("/tmp/wt")),
3010 + bundle: None,
2927 3011 events: events::channel(),
2928 3012 nodes: Vec::new(),
2929 3013 build_id: None,
@@ -2957,7 +3041,8 @@
2957 3041 cfg: std::sync::Arc::new(crate::config::AppConfig::for_tests()),
2958 3042 tier: TierId::new("host"),
2959 3043 version: "0.1.0".parse().unwrap(),
2960 - worktree: std::path::PathBuf::from("/tmp/wt"),
3044 + worktree: Some(std::path::PathBuf::from("/tmp/wt")),
3045 + bundle: None,
2961 3046 events: events::channel(),
2962 3047 nodes: Vec::new(),
2963 3048 build_id: None,
@@ -3046,7 +3131,8 @@
3046 3131 cfg: std::sync::Arc::new(cfg),
3047 3132 tier: TierId::new("host"),
3048 3133 version: "0.1.0".parse().unwrap(),
3049 - worktree: tmp.path().to_path_buf(),
3134 + worktree: Some(tmp.path().to_path_buf()),
3135 + bundle: None,
3050 3136 events: events::channel(),
3051 3137 nodes: Vec::new(),
3052 3138 build_id: None,
@@ -3122,7 +3208,8 @@
3122 3208 cfg: std::sync::Arc::new(crate::config::AppConfig::for_tests()),
3123 3209 tier: TierId::new("host"),
3124 3210 version: "0.1.0".parse().unwrap(),
3125 - worktree: std::path::PathBuf::from("/tmp/wt"),
3211 + worktree: Some(std::path::PathBuf::from("/tmp/wt")),
3212 + bundle: None,
3126 3213 events: events::channel(),
3127 3214 nodes: Vec::new(),
3128 3215 build_id: None,
@@ -3257,7 +3344,8 @@
3257 3344 cfg,
3258 3345 tier: TierId::new("host"),
3259 3346 version: "0.1.0".parse().unwrap(),
3260 - worktree: std::path::PathBuf::from("/tmp/unused"),
3347 + worktree: Some(std::path::PathBuf::from("/tmp/unused")),
3348 + bundle: None,
3261 3349 events: events::channel(),
3262 3350 nodes: Vec::new(),
3263 3351 build_id: None,
@@ -3312,7 +3400,8 @@
3312 3400 cfg,
3313 3401 tier: TierId::new("b"),
3314 3402 version: "0.1.0".parse().unwrap(),
3315 - worktree: std::path::PathBuf::new(),
3403 + worktree: None,
3404 + bundle: None,
3316 3405 events: events::channel(),
3317 3406 nodes: Vec::new(), // no nodes -> fail closed
3318 3407 build_id: None,
@@ -3678,7 +3767,8 @@
3678 3767 cfg,
3679 3768 tier: TierId::new("host"),
3680 3769 version: "0.1.0".parse().unwrap(),
3681 - worktree: std::path::PathBuf::from("/tmp/unused"),
3770 + worktree: Some(std::path::PathBuf::from("/tmp/unused")),
3771 + bundle: None,
3682 3772 events: events::channel(),
3683 3773 nodes: Vec::new(),
3684 3774 build_id: None,
@@ -54,7 +54,9 @@
54 54 fn validate_loaded(cfg: &config::AppConfig) -> Result<topology::Topology> {
55 55 cfg.validate()?;
56 56 let topo = topology::Topology::load(&cfg.topology_path)?;
57 - topo.ensure_build_host_not_serving(&cfg.build_host)?;
57 + if let Some(h) = cfg.build_host.as_deref() {
58 + topo.ensure_build_host_not_serving(h)?;
59 + }
58 60 topo.ensure_migration_checks_have_backups(&cfg.migration_checks)?;
59 61 topo.ensure_node_companions_are_built(&cfg.companions)?;
60 62 topo.ensure_test_target_aux_repos_exist(&cfg.test_targets)?;
@@ -85,15 +87,25 @@
85 87 let mut apps: state::AppMap = std::collections::BTreeMap::new();
86 88 for (id, cfg) in app_cfgs {
87 89 let topo = Arc::new(topology::Topology::load(&cfg.topology_path)?);
88 - topo.ensure_build_host_not_serving(&cfg.build_host)?;
90 + if let Some(h) = cfg.build_host.as_deref() {
91 + topo.ensure_build_host_not_serving(h)?;
92 + }
89 93 topo.ensure_migration_checks_have_backups(&cfg.migration_checks)?;
90 94 topo.ensure_node_companions_are_built(&cfg.companions)?;
91 95 topo.ensure_test_target_aux_repos_exist(&cfg.test_targets)?;
92 96 tokio::fs::create_dir_all(&cfg.workdir).await?;
93 97 tokio::fs::create_dir_all(&cfg.release_root).await?;
94 - git::ensure_bare_repo(Path::new(&topo.repo.bare_path)).await?;
98 + // An intake-only product has no bare repo on this host to create.
99 + if let Some(repo) = topo.repo.as_ref() {
100 + git::ensure_bare_repo(Path::new(&repo.bare_path)).await?;
101 + }
95 102 let executors = Arc::new(state::build_executors(&topo));
96 - tracing::info!(app = %id, tiers = topo.tiers.len(), bare = %topo.repo.bare_path, "app loaded");
103 + tracing::info!(
104 + app = %id,
105 + tiers = topo.tiers.len(),
106 + bare = topo.repo.as_ref().map_or("(intake-only)", |r| r.bare_path.as_str()),
107 + "app loaded"
108 + );
97 109 apps.insert(
98 110 id,
99 111 Arc::new(state::App {
@@ -310,6 +310,13 @@
310 310 SpawnFailed { message: String },
311 311 /// Gate took longer than the configured ceiling.
312 312 Timeout { gate: GateKind, after_s: u32 },
313 + /// The gate reads a source checkout and this run has none, because the
314 + /// artifact was built elsewhere and handed to Sando (wiki
315 + /// [[sando-bento-boundary]]). Not a failure of the artifact: a failure of
316 + /// the tier's gate list, which is asking Sando to re-prove something about
317 + /// bytes it did not compile. Either the gate belongs to the builder, or the
318 + /// bundle needs to carry what the gate reads.
319 + NeedsSource { gate: GateKind, artifact: String },
313 320 /// Classifier could not match the output to any known variant. The
314 321 /// `log_ref` on the enclosing `GateOutcome` is the diagnostic path.
315 322 Unclassified { legacy_detail: Option<String> },
@@ -385,6 +392,9 @@
385 392 Some(c) => format!("frontend build failed in {dir}: exit {c}"),
386 393 None => format!("frontend build failed in {dir}"),
387 394 },
395 + GateFailure::NeedsSource { gate, artifact } => format!(
396 + "{gate} needs a source checkout; this artifact was built elsewhere ({artifact})"
397 + ),
388 398 GateFailure::SpawnFailed { message } => format!("spawn: {message}"),
389 399 GateFailure::Timeout { gate, after_s } => format!("{gate} timed out after {after_s}s"),
390 400 GateFailure::Unclassified {
@@ -160,6 +160,26 @@
160 160 Ok(())
161 161 }
162 162
163 + /// Record what the bundle runs on.
164 + ///
165 + /// Separate from [`set_identity`] and not best-effort: the digest identifies the
166 + /// bytes, the platform is what makes two bundles of one version distinguishable,
167 + /// and a row that lost it holds an artifact no node declaring a platform will
168 + /// accept. A dropped write here is a bundle that can be placed nowhere, so the
169 + /// caller is told.
170 + pub async fn set_platform(
171 + pool: &SqlitePool,
172 + run_id: RunId,
173 + platform: &crate::domain::Platform,
174 + ) -> Result<()> {
175 + sqlx::query("UPDATE build_runs SET platform = ? WHERE id = ? AND result = 'building'")
176 + .bind(platform.to_string())
177 + .bind(run_id.0)
178 + .execute(pool)
179 + .await?;
180 + Ok(())
181 + }
182 +
163 183 /// Settle the run green. First terminal write wins (guarded on `building`).
164 184 pub async fn mark_passed(pool: &SqlitePool, run_id: RunId) -> Result<()> {
165 185 sqlx::query(
@@ -152,11 +152,11 @@
152 152
153 153 fn topo(tiers: Vec<Tier>) -> Topology {
154 154 Topology {
155 - repo: RepoConfig {
155 + repo: Some(RepoConfig {
156 156 bare_path: "/tmp/x".into(),
157 157 branch: "main".into(),
158 158 upstream: None,
159 - },
159 + }),
160 160 backup: vec![BackupConfig {
161 161 name: "server".into(),
162 162 source: "file:///tmp/b".into(),
@@ -179,6 +179,7 @@
179 179
180 180 fn node(name: &str) -> Node {
181 181 Node {
182 + platform: None,
182 183 name: name.into(),
183 184 ssh_target: format!("deploy@{name}"),
184 185 release_root: "/opt/mnw".into(),
@@ -5,7 +5,16 @@
5 5
6 6 #[derive(Debug, Clone, Serialize, Deserialize)]
7 7 pub struct Topology {
8 - pub repo: RepoConfig,
8 + /// The repo Sando checks out to build this product.
9 + ///
10 + /// `None` for an intake-only product: pom is built by Bento on two
11 + /// machines and handed over as finished bytes, so Sando fetches no source
12 + /// for it and there is no bare repo on this host to name. A topology that
13 + /// declares no repo cannot be `/rebuild`-ed, which is the same statement
14 + /// [`AppConfig::build_host`](crate::config::AppConfig::build_host) makes
15 + /// from the other side.
16 + #[serde(default)]
17 + pub repo: Option<RepoConfig>,
9 18 /// Prod dumps `/backup/fetch` pulls, one per database `migration_dry_run`
10 19 /// has a check for. A list because the repo ships more than one service
11 20 /// with its own database and its own `sqlx::migrate!()` at boot: the server
@@ -130,6 +139,16 @@
130 139 pub name: NodeId,
131 140 pub ssh_target: String,
132 141 pub release_root: String,
142 + /// What this machine runs, as `os/arch` (e.g. `linux/aarch64`).
143 + ///
144 + /// Compared against the bundle's own platform before anything is pushed;
145 + /// see [`crate::deploy::Placement`]. Optional, and a node that declares it
146 + /// can only be given a bundle that declares a matching one — silence on
147 + /// either side is a refusal, not a pass. MNW's nodes declare nothing and
148 + /// keep the single-platform behavior they have always had; pom's declare
149 + /// theirs, because pom is the product where one version is two bundles.
150 + #[serde(default)]
151 + pub platform: Option<crate::domain::Platform>,
133 152 /// systemd unit name to reload-or-restart after the symlink swap.
134 153 /// Defaults to "makenotwork.service" because that's MNW's prod unit.
135 154 #[serde(default = "default_service_name")]
@@ -331,6 +350,18 @@
331 350 &self,
332 351 checks: &[crate::config::MigrationCheck],
333 352 ) -> Result<()> {
353 + // A product no tier dry-runs migrations for owes no dumps. `checks` is
354 + // never empty — the config defaults it to MNW's `server` entry — so
355 + // without this, an intake-only product with no postgres anywhere is
356 + // asked to declare a prod dump for a gate it does not configure.
357 + if !self
358 + .tiers
359 + .iter()
360 + .flat_map(|t| &t.gates)
361 + .any(|g| g.kind() == GateKind::MigrationDryRun)
362 + {
363 + return Ok(());
364 + }
334 365 for c in checks {
335 366 anyhow::ensure!(
336 367 self.backup_named(&c.backup).is_some(),
@@ -428,9 +459,21 @@
428 459 }
429 460
430 461 fn validate(&self) -> Result<()> {
462 + // A dump is only owed by a product that actually dry-runs migrations.
463 + // The unconditional form of this asserted something about every
464 + // product's tiers from a fact about one: pom configures no
465 + // `migration_dry_run` anywhere (it has no postgres schema at all), so
466 + // requiring it to declare a prod dump would be demanding a fixture for
467 + // a gate it never runs.
468 + let dry_runs_migrations = self
469 + .tiers
470 + .iter()
471 + .flat_map(|t| &t.gates)
472 + .any(|g| g.kind() == GateKind::MigrationDryRun);
431 473 anyhow::ensure!(
432 - !self.backup.is_empty(),
433 - "topology declares no [backup]; migration_dry_run would have nothing to restore"
474 + !dry_runs_migrations || !self.backup.is_empty(),
475 + "a tier configures migration_dry_run but the topology declares no [backup]; \
476 + the gate would have nothing to restore"
434 477 );
435 478 for (i, b) in self.backup.iter().enumerate() {
436 479 anyhow::ensure!(
@@ -941,7 +984,10 @@
941 984 [[tier]]
942 985 name = "b"
943 986 provisioned = true
944 - gates = [{{ kind = "node_health" }}]
987 + # migration_dry_run is what makes the backup rules apply at all: a product no
988 + # tier dry-runs migrations for owes no dumps, so a fixture exercising those rules
989 + # has to configure the gate.
990 + gates = [{{ kind = "node_health" }}, {{ kind = "migration_dry_run" }}]
945 991 [[tier.node]]
946 992 name = "prod-1"
947 993 ssh_target = "prod-1"