max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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" |