Skip to main content

max / makenotwork

26.6 KB · 773 lines History Blame Raw
1 //! Tests for [`super`].
2
3 use super::{
4 BuildArtifact, accept_intake, check_build_host, checkout_aux_repos, gate_intake,
5 runtime_hostname, stage_and_gate, tail,
6 };
7 use crate::config::{AppConfig, TestTarget};
8 use crate::domain::{GitSha, RunId, Version};
9 use crate::topology::{AuxRepo, BackupConfig, CanaryPolicy, Gate, RepoConfig, Tier, Topology};
10 use sqlx::SqlitePool;
11 use sqlx::sqlite::SqlitePoolOptions;
12 use std::collections::BTreeMap;
13 use std::path::PathBuf;
14 use std::sync::Arc;
15
16 /// Post-build pipeline fixture: an in-memory store with the `host` tier
17 /// seeded, a synthetic worktree holding a fake primary binary, and a
18 /// build_runs row in flight. Returns everything `stage_and_gate` needs plus
19 /// the tempdir root (drop it to clean up) and the run/version it seeded.
20 ///
21 /// `gates` is the host tier's gate list: `[]` is the green path;
22 /// `[Gate::ManualConfirm]` is a deterministic red — with no prior operator
23 /// confirmation row that gate blocks, and it shells out to nothing.
24 async fn stage_fixture(
25 gates: Vec<Gate>,
26 ) -> (
27 SqlitePool,
28 Arc<AppConfig>,
29 Arc<Topology>,
30 BuildArtifact,
31 RunId,
32 Version,
33 tempfile::TempDir,
34 ) {
35 let tmp = tempfile::tempdir().unwrap();
36 let release_root = tmp.path().join("release-root");
37 let worktree = tmp.path().join("worktree");
38 let bin_dir = worktree.join("target").join("release");
39 tokio::fs::create_dir_all(&bin_dir).await.unwrap();
40 let bin_path = bin_dir.join("makenotwork");
41 tokio::fs::write(&bin_path, b"#!/bin/false\nfake sando artifact\n")
42 .await
43 .unwrap();
44
45 let pool = SqlitePoolOptions::new()
46 .max_connections(1)
47 .connect("sqlite::memory:")
48 .await
49 .unwrap();
50 sqlx::migrate!("./migrations").run(&pool).await.unwrap();
51
52 // gate_runs and tier_state FK into `tiers`; the pipeline only touches host.
53 sqlx::query(
54 "INSERT INTO tiers (name, ord, provisioned, canary) VALUES ('host', 0, 1, 'sequential')",
55 )
56 .execute(&pool)
57 .await
58 .unwrap();
59 sqlx::query("INSERT INTO tier_state (tier) VALUES ('host')")
60 .execute(&pool)
61 .await
62 .unwrap();
63
64 let version = Version::parse("1.2.3").unwrap();
65 let git_sha = GitSha::parse("abc1234").unwrap();
66 // gate_runs.version and the `SET artifact_path` UPDATE both need the row.
67 sqlx::query(
68 "INSERT INTO versions (version, git_sha, built_at, artifact_path)
69 VALUES (?, ?, datetime('now'), '')",
70 )
71 .bind(version.to_string())
72 .bind(git_sha.to_string())
73 .execute(&pool)
74 .await
75 .unwrap();
76
77 let run_id = crate::runs::create(
78 &pool,
79 &crate::domain::AppId::default(),
80 &git_sha.to_string(),
81 )
82 .await
83 .unwrap();
84
85 let cfg = AppConfig {
86 page_smoke_cmd: None,
87 platform: None,
88 code_smoke_env: BTreeMap::default(),
89 id: crate::domain::AppId::default(),
90 topology_path: PathBuf::from("/tmp/test-sando.toml"),
91 build_host: Some("test-host".into()),
92 workdir: tmp.path().to_path_buf(),
93 release_root: release_root.clone(),
94 scratch_db_url: None,
95 scratch_owner_role: "makenotwork".into(),
96 boot_smoke_port: 18181,
97 code_smoke_port: 18182,
98 bin_names: vec!["makenotwork".into()],
99 logs_root: tmp.path().join("logs"),
100 release_contents: vec![],
101 cargo_target_dir: None,
102 gate_timeout_secs: 2400,
103 companions: Vec::new(),
104 test_targets: vec![TestTarget {
105 dir: PathBuf::from("server"),
106 aux_repo: None,
107 features: vec!["fast-tests".into()],
108 all_features: false,
109 scratch_db: true,
110 }],
111 migration_checks: vec![],
112 frontend_builds: vec![],
113 backup_max_age_hours: 48,
114 };
115
116 let topo = Topology {
117 repo: Some(RepoConfig {
118 bare_path: "/tmp/test.git".into(),
119 branch: "main".into(),
120 upstream: None,
121 }),
122 backup: vec![BackupConfig {
123 name: "server".into(),
124 source: "file:///tmp/test-backup.sql".into(),
125 local_path: "/tmp/local-backup.sql".into(),
126 }],
127 tiers: vec![Tier {
128 public_url: None,
129 name: "host".into(),
130 provisioned: true,
131 gates,
132 canary: CanaryPolicy::Sequential,
133 nodes: Vec::new(),
134 }],
135 aux_repos: Vec::new(),
136 };
137
138 let art = BuildArtifact {
139 version: version.clone(),
140 git_sha,
141 worktree,
142 binary_paths: vec![bin_path],
143 companion_paths: Vec::new(),
144 };
145
146 (
147 pool,
148 Arc::new(cfg),
149 Arc::new(topo),
150 art,
151 run_id,
152 version,
153 tmp,
154 )
155 }
156
157 // ---- intake through the seam ----
158
159 /// The `ArtifactRecord` Bento would have written for a staged bundle.
160 async fn record_for(staged: &std::path::Path, version: &str, target: &str) -> String {
161 use ops_artifact::{ArtifactRecord, GateRecord, Manifest, Provenance, Scope, Verdict};
162 let computed = crate::bundle::digest_dir(staged).await.unwrap();
163 let manifest = Manifest::parse(&computed.manifest).unwrap();
164 let at = chrono::DateTime::<chrono::Utc>::from_timestamp(1_754_000_000, 0).unwrap();
165 ArtifactRecord::new(
166 "bento",
167 manifest,
168 Provenance {
169 app: "pom".into(),
170 version: version.into(),
171 tag: format!("pom-v{version}"),
172 git_sha: "a".repeat(40),
173 target: target.into(),
174 build_host: "astra".into(),
175 toolchain: "rustc 1.97.0".into(),
176 built_at: at,
177 },
178 vec![GateRecord::new(
179 "prebuild",
180 Scope::Artifact,
181 Verdict::Passed,
182 "prebuild passed in 90s",
183 at,
184 )],
185 )
186 .unwrap()
187 .to_json()
188 }
189
190 #[tokio::test]
191 async fn an_accepted_artifact_is_published_gated_and_advances_the_tier() {
192 // The seam: an artifact Sando did not build reaches the same published,
193 // gated, tier-advanced end state a Sando-built one does. No worktree
194 // exists anywhere in this test, which is the point — everything from
195 // `finalize_local_release` onward stopped caring where the bytes came
196 // from.
197 let (pool, cfg, topo, _art, run_id, _version, tmp) = stage_fixture(vec![]).await;
198 let deploy_lock = Arc::new(tokio::sync::Mutex::new(()));
199
200 let staged = cfg.release_root.join("staging").join("intake-1");
201 tokio::fs::create_dir_all(&staged).await.unwrap();
202 tokio::fs::write(staged.join("makenotwork"), b"bytes built elsewhere")
203 .await
204 .unwrap();
205 let record = record_for(&staged, "1.2.3", "linux/aarch64").await;
206
207 // Two calls now, on purpose: the route answers its caller on the first
208 // and spawns the second. Acceptance is what the producer waits for.
209 let published = accept_intake(&pool, &cfg, &staged, &record, run_id)
210 .await
211 .expect("the bytes are believed");
212 gate_intake(
213 pool.clone(),
214 cfg.clone(),
215 topo,
216 published,
217 crate::events::channel(),
218 run_id,
219 deploy_lock,
220 )
221 .await
222 .expect("a green intake settles the run");
223
224 // Published content-addressed, and the staging dir is gone: renamed,
225 // not copied.
226 let (digest, staged_path, platform): (Option<String>, Option<String>, Option<String>) =
227 sqlx::query_as("SELECT bundle_digest, staged_path, platform FROM build_runs WHERE id = ?")
228 .bind(run_id.0)
229 .fetch_one(&pool)
230 .await
231 .unwrap();
232 let digest = digest.expect("bundle_digest recorded");
233 let staged_path = staged_path.expect("staged_path recorded");
234 assert_eq!(digest.len(), 64);
235 assert!(
236 !staged.exists(),
237 "staging was renamed into the release root"
238 );
239 assert_eq!(
240 std::path::Path::new(&staged_path),
241 tmp.path()
242 .join("release-root")
243 .join("releases")
244 .join(&digest[..16]),
245 );
246
247 // The platform came off the record's provenance and is on the row. This
248 // is what makes two bundles of one version tellable apart later.
249 assert_eq!(platform.as_deref(), Some("linux/aarch64"));
250
251 // Green gates advanced the host tier, exactly as a build would have.
252 let (result, _summary) = run_result(&pool, run_id).await;
253 assert_eq!(result, "passed");
254 let (current, _prev) = tier_versions(&pool, "host").await;
255 assert_eq!(current.as_deref(), Some("1.2.3"));
256 }
257
258 #[tokio::test]
259 async fn an_intake_whose_bytes_drifted_never_reaches_the_gates() {
260 // Identity is decided before anything else happens to the bundle, so a
261 // record vouching for one set of bytes arriving with another fails the
262 // run rather than gating and shipping.
263 let (pool, cfg, topo, _art, run_id, _version, _tmp) = stage_fixture(vec![]).await;
264 let deploy_lock = Arc::new(tokio::sync::Mutex::new(()));
265
266 let staged = cfg.release_root.join("staging").join("intake-1");
267 tokio::fs::create_dir_all(&staged).await.unwrap();
268 tokio::fs::write(staged.join("makenotwork"), b"bytes built elsewhere")
269 .await
270 .unwrap();
271 let record = record_for(&staged, "1.2.3", "linux/aarch64").await;
272 tokio::fs::write(staged.join("makenotwork"), b"other bytes entirely")
273 .await
274 .unwrap();
275
276 // Refused by ACCEPTANCE, not by gating — which is what lets the route
277 // answer the producer with the refusal instead of a `202`-shaped lie.
278 let _ = (&topo, &deploy_lock);
279 let err = accept_intake(&pool, &cfg, &staged, &record, run_id)
280 .await
281 .expect_err("a drifted bundle is refused");
282 assert!(err.to_string().contains("makenotwork"), "{err}");
283
284 // Nothing advanced, and the bytes were left where they were.
285 let (current, _prev) = tier_versions(&pool, "host").await;
286 assert_eq!(current, None);
287 assert!(staged.exists(), "a refused intake leaves the bytes alone");
288 }
289
290 #[tokio::test]
291 async fn a_gate_that_reads_source_refuses_against_an_accepted_artifact() {
292 // The boundary showing up at runtime. `code_smoke` is artifact-scoped —
293 // it compiles frontends and boots the binary against a scratch DB — and
294 // an accepted artifact has no checkout for it to read. It has to say so
295 // rather than pass on having run nothing, which is what an unwrapped
296 // `worktree.join(..)` against an empty path would have done.
297 let (pool, cfg, _topo, _art, run_id, version, _tmp) = stage_fixture(vec![]).await;
298 let ctx = crate::gates::GateCtx {
299 pool,
300 cfg,
301 tier: crate::domain::TierId::new("host"),
302 version,
303 worktree: None,
304 bundle: Some(PathBuf::from("/r/abc")),
305 events: crate::events::channel(),
306 nodes: Vec::new(),
307 build_id: Some(run_id.0),
308 public_url: None,
309 aux_dirs: std::collections::HashMap::default(),
310 };
311 let outcome = ctx
312 .worktree_for(crate::domain::GateKind::CodeSmoke)
313 .expect_err("no worktree means no source-reading gate");
314 assert!(!outcome.is_passed());
315 let crate::outcome::GateStatus::Failed { failure } = &outcome.status else {
316 panic!("expected a failure, got {:?}", outcome.status)
317 };
318 assert!(
319 matches!(failure, crate::outcome::GateFailure::NeedsSource { .. }),
320 "{failure:?}"
321 );
322 assert!(
323 failure.summary().contains("built elsewhere"),
324 "{}",
325 failure.summary()
326 );
327 }
328
329 #[tokio::test]
330 async fn migrations_come_from_the_bundle_before_the_worktree() {
331 // What the gate proves has to be what ships. Migrations staged into the
332 // bundle are inside its digest; the same files sitting in a checkout are
333 // not, and a checkout can be edited between the dry run and the deploy.
334 // So when both hold a copy, the bundle wins.
335 let (pool, cfg, _topo, _art, run_id, version, tmp) = stage_fixture(vec![]).await;
336 let bundle = tmp.path().join("bundle");
337 let worktree = tmp.path().join("wt");
338 for root in [&bundle, &worktree] {
339 tokio::fs::create_dir_all(root.join("server/migrations"))
340 .await
341 .unwrap();
342 }
343 let ctx = crate::gates::GateCtx {
344 pool,
345 cfg,
346 tier: crate::domain::TierId::new("host"),
347 version,
348 worktree: Some(worktree.clone()),
349 bundle: Some(bundle.clone()),
350 events: crate::events::channel(),
351 nodes: Vec::new(),
352 build_id: Some(run_id.0),
353 public_url: None,
354 aux_dirs: std::collections::HashMap::default(),
355 };
356 assert_eq!(
357 ctx.migrations_dir(std::path::Path::new("server/migrations")),
358 Some(bundle.join("server/migrations")),
359 );
360
361 // The worktree is the fallback for a build whose config has not opted
362 // into bundling them yet, which is every MNW build before this lands.
363 let ctx = crate::gates::GateCtx {
364 bundle: Some(tmp.path().join("empty-bundle")),
365 ..ctx
366 };
367 assert_eq!(
368 ctx.migrations_dir(std::path::Path::new("server/migrations")),
369 Some(worktree.join("server/migrations")),
370 );
371
372 // And neither is not silently green: an accepted artifact whose builder
373 // did not bundle its migrations has nothing to dry-run, and the gate
374 // has to be told so rather than restore a dump and report success.
375 let ctx = crate::gates::GateCtx {
376 worktree: None,
377 ..ctx
378 };
379 assert_eq!(
380 ctx.migrations_dir(std::path::Path::new("server/migrations")),
381 None
382 );
383 }
384
385 // ---- checkout_aux_repos ----
386
387 async fn git_in(dir: &std::path::Path, args: &[&str]) {
388 let out = tokio::process::Command::new("git")
389 .args(["-c", "user.email=t@t", "-c", "user.name=t"])
390 .current_dir(dir)
391 .args(args)
392 .output()
393 .await
394 .unwrap();
395 assert!(
396 out.status.success(),
397 "git {args:?}: {}",
398 String::from_utf8_lossy(&out.stderr)
399 );
400 }
401
402 /// A minimal `Config` whose only field this test path reads is `workdir`.
403 fn cfg_with_workdir(workdir: PathBuf) -> AppConfig {
404 AppConfig {
405 page_smoke_cmd: None,
406 platform: None,
407 code_smoke_env: BTreeMap::default(),
408 id: crate::domain::AppId::default(),
409 topology_path: PathBuf::from("/tmp/test-sando.toml"),
410 build_host: Some("test-host".into()),
411 workdir,
412 release_root: PathBuf::from("/tmp/rr"),
413 scratch_db_url: None,
414 scratch_owner_role: "makenotwork".into(),
415 boot_smoke_port: 18181,
416 code_smoke_port: 18182,
417 bin_names: vec!["makenotwork".into()],
418 logs_root: PathBuf::from("/tmp/logs"),
419 release_contents: vec![],
420 cargo_target_dir: None,
421 gate_timeout_secs: 2400,
422 companions: Vec::new(),
423 test_targets: vec![],
424 migration_checks: vec![],
425 frontend_builds: vec![],
426 backup_max_age_hours: 48,
427 }
428 }
429
430 fn topo_with_aux(aux_repos: Vec<AuxRepo>) -> Topology {
431 Topology {
432 repo: Some(RepoConfig {
433 bare_path: "/tmp/x.git".into(),
434 branch: "main".into(),
435 upstream: None,
436 }),
437 backup: vec![BackupConfig {
438 name: "server".into(),
439 source: "s".into(),
440 local_path: "/tmp/d".into(),
441 }],
442 tiers: vec![],
443 aux_repos,
444 }
445 }
446
447 #[tokio::test]
448 async fn a_gate_looks_where_the_aux_checkout_actually_landed() {
449 // The two halves of the aux-repo test_target path: checkout_aux_repos
450 // writes the tree, and GateCtx::target_dir reads it. Nothing but this
451 // stops one from being changed without the other, and the failure would
452 // be a warn-and-skip — a green gate that ran one crate fewer.
453 let tmp = tempfile::tempdir().unwrap();
454 let src = tmp.path().join("docengine-src");
455 tokio::fs::create_dir_all(&src).await.unwrap();
456 git_in(&src, &["init", "-q", "-b", "main"]).await;
457 tokio::fs::write(src.join("Cargo.toml"), b"[package]\nname = \"docengine\"\n")
458 .await
459 .unwrap();
460 git_in(&src, &["add", "."]).await;
461 git_in(&src, &["commit", "-q", "-m", "one"]).await;
462
463 let workdir = tmp.path().join("work");
464 tokio::fs::create_dir_all(&workdir).await.unwrap();
465 let cfg = cfg_with_workdir(workdir.clone());
466 let topo = topo_with_aux(vec![AuxRepo {
467 name: "docengine".into(),
468 bare_path: tmp
469 .path()
470 .join("docengine.git")
471 .to_string_lossy()
472 .into_owned(),
473 upstream: src.to_string_lossy().into_owned(),
474 branch: "main".into(),
475 // Nested, as the real one is: it must not be mistaken for a path
476 // under the per-sha worktree.
477 checkout_dir: "Libraries/docengine".into(),
478 }]);
479 checkout_aux_repos(&cfg, &topo).await.unwrap();
480
481 let ctx = crate::gates::GateCtx {
482 pool: sqlx::SqlitePool::connect_lazy("sqlite::memory:").unwrap(),
483 cfg: Arc::new(cfg.clone()),
484 tier: crate::domain::TierId::new("host"),
485 version: "0.1.0".parse().unwrap(),
486 worktree: Some(workdir.join("abc123")),
487 bundle: None,
488 events: crate::events::channel(),
489 nodes: Vec::new(),
490 build_id: None,
491 public_url: None,
492 aux_dirs: super::aux_checkout_dirs(&cfg, &topo),
493 };
494 let target = crate::config::TestTarget {
495 dir: PathBuf::new(),
496 aux_repo: Some("docengine".into()),
497 features: Vec::new(),
498 all_features: true,
499 scratch_db: false,
500 };
501 let resolved = ctx.target_dir(&target).expect("aux repo is checked out");
502 assert!(
503 resolved.join("Cargo.toml").is_file(),
504 "gate would skip the aux target as absent; resolved {}",
505 resolved.display(),
506 );
507 assert!(
508 !resolved.starts_with(ctx.worktree.as_ref().unwrap()),
509 "an aux checkout is a sibling of the worktree, not under it",
510 );
511 }
512
513 #[tokio::test]
514 async fn checkout_aux_repos_places_repo_beside_worktree_and_refreshes_to_branch_head() {
515 let tmp = tempfile::tempdir().unwrap();
516
517 // An "upstream" source repo with a marker file on main.
518 let src = tmp.path().join("synckit-src");
519 tokio::fs::create_dir_all(&src).await.unwrap();
520 git_in(&src, &["init", "-q", "-b", "main"]).await;
521 tokio::fs::write(src.join("VERSION"), b"v1").await.unwrap();
522 git_in(&src, &["add", "."]).await;
523 git_in(&src, &["commit", "-q", "-m", "one"]).await;
524
525 let workdir = tmp.path().join("work");
526 tokio::fs::create_dir_all(&workdir).await.unwrap();
527 let cfg = cfg_with_workdir(workdir.clone());
528 let topo = topo_with_aux(vec![AuxRepo {
529 name: "synckit".into(),
530 bare_path: tmp
531 .path()
532 .join("synckit.git")
533 .to_string_lossy()
534 .into_owned(),
535 upstream: src.to_string_lossy().into_owned(),
536 branch: "main".into(),
537 checkout_dir: "synckit".into(),
538 }]);
539
540 // First build: the aux repo lands at workdir/synckit at v1.
541 checkout_aux_repos(&cfg, &topo).await.unwrap();
542 let dest = workdir.join("synckit");
543 assert_eq!(
544 tokio::fs::read(dest.join("VERSION")).await.unwrap(),
545 b"v1",
546 "aux repo checked out beside the worktree",
547 );
548
549 // Upstream advances; a later build refreshes the shared checkout to HEAD.
550 tokio::fs::write(src.join("VERSION"), b"v2").await.unwrap();
551 git_in(&src, &["add", "."]).await;
552 git_in(&src, &["commit", "-q", "-m", "two"]).await;
553 checkout_aux_repos(&cfg, &topo).await.unwrap();
554 assert_eq!(
555 tokio::fs::read(dest.join("VERSION")).await.unwrap(),
556 b"v2",
557 "aux checkout refreshed to the new branch HEAD",
558 );
559
560 // The aux bare carries no build-trigger hook.
561 assert!(
562 !tmp.path().join("synckit.git/hooks/post-receive").exists(),
563 "aux bare must be hookless",
564 );
565 }
566
567 #[tokio::test]
568 async fn checkout_aux_repos_is_a_noop_without_aux_repos() {
569 let tmp = tempfile::tempdir().unwrap();
570 let cfg = cfg_with_workdir(tmp.path().to_path_buf());
571 checkout_aux_repos(&cfg, &topo_with_aux(vec![]))
572 .await
573 .unwrap();
574 }
575
576 #[tokio::test]
577 async fn checkout_aux_repos_fails_on_an_unresolvable_branch() {
578 let tmp = tempfile::tempdir().unwrap();
579 let src = tmp.path().join("src");
580 tokio::fs::create_dir_all(&src).await.unwrap();
581 git_in(&src, &["init", "-q", "-b", "main"]).await;
582 tokio::fs::write(src.join("f"), b"x").await.unwrap();
583 git_in(&src, &["add", "."]).await;
584 git_in(&src, &["commit", "-q", "-m", "c"]).await;
585
586 let cfg = cfg_with_workdir(tmp.path().join("work"));
587 let topo = topo_with_aux(vec![AuxRepo {
588 name: "synckit".into(),
589 bare_path: tmp.path().join("s.git").to_string_lossy().into_owned(),
590 upstream: src.to_string_lossy().into_owned(),
591 branch: "nonexistent".into(),
592 checkout_dir: "synckit".into(),
593 }]);
594 let err = checkout_aux_repos(&cfg, &topo).await.unwrap_err();
595 assert!(
596 format!("{err:#}").contains("synckit"),
597 "error names the aux repo: {err:#}",
598 );
599 }
600
601 async fn tier_versions(pool: &SqlitePool, tier: &str) -> (Option<String>, Option<String>) {
602 sqlx::query_as("SELECT current_version, previous_version FROM tier_state WHERE tier = ?")
603 .bind(tier)
604 .fetch_one(pool)
605 .await
606 .unwrap()
607 }
608
609 async fn run_result(pool: &SqlitePool, run_id: RunId) -> (String, Option<String>) {
610 sqlx::query_as("SELECT result, failure_summary FROM build_runs WHERE id = ?")
611 .bind(run_id.0)
612 .fetch_one(pool)
613 .await
614 .unwrap()
615 }
616
617 #[tokio::test]
618 async fn stage_and_gate_stages_advances_and_flips_the_symlink_when_gates_are_green() {
619 let (pool, cfg, topo, art, run_id, version, tmp) = stage_fixture(vec![]).await;
620 let deploy_lock = Arc::new(tokio::sync::Mutex::new(()));
621
622 stage_and_gate(
623 pool.clone(),
624 cfg.clone(),
625 topo,
626 art,
627 crate::events::channel(),
628 run_id,
629 deploy_lock,
630 )
631 .await
632 .expect("green host pipeline returns Ok");
633
634 // Tier advanced to the built version (previous was NULL -> stays NULL).
635 let (current, previous) = tier_versions(&pool, "host").await;
636 assert_eq!(current.as_deref(), Some(version.to_string().as_str()));
637 assert_eq!(previous, None);
638
639 // Run settled green.
640 let (result, summary) = run_result(&pool, run_id).await;
641 assert_eq!(result, "passed");
642 assert_eq!(summary, None);
643
644 // Identity: the build row carries the bundle digest (64 hex) and the
645 // content-addressed dir it was published to (releases/<digest16>).
646 let (digest, staged_path): (Option<String>, Option<String>) =
647 sqlx::query_as("SELECT bundle_digest, staged_path FROM build_runs WHERE id = ?")
648 .bind(run_id.0)
649 .fetch_one(&pool)
650 .await
651 .unwrap();
652 let digest = digest.expect("bundle_digest recorded");
653 let staged_path = staged_path.expect("staged_path recorded");
654 assert_eq!(digest.len(), 64);
655 let releases = tmp.path().join("release-root").join("releases");
656 assert_eq!(
657 std::path::Path::new(&staged_path),
658 releases.join(&digest[..16]),
659 "bundle is published content-addressed at releases/<digest16>"
660 );
661
662 // versions.artifact_path points at the primary binary inside that dir,
663 // and it exists on disk.
664 let staged_bin: String =
665 sqlx::query_scalar("SELECT artifact_path FROM versions WHERE version = ?")
666 .bind(version.to_string())
667 .fetch_one(&pool)
668 .await
669 .unwrap();
670 let expected_bin = releases.join(&digest[..16]).join("makenotwork");
671 assert_eq!(staged_bin, expected_bin.to_string_lossy());
672 assert!(
673 expected_bin.exists(),
674 "staged binary missing at {expected_bin:?}"
675 );
676
677 // The bundle carries its MANIFEST (for node-side verification), and the
678 // recorded digest recomputes over the published dir (MANIFEST excluded).
679 assert!(
680 releases.join(&digest[..16]).join("MANIFEST").exists(),
681 "MANIFEST written into the bundle"
682 );
683 let recomputed = crate::bundle::digest_dir(std::path::Path::new(&staged_path))
684 .await
685 .unwrap();
686 assert_eq!(
687 digest, recomputed.full,
688 "recorded digest matches the bundle"
689 );
690
691 // The `current` symlink flipped to the content-addressed release.
692 let link = tmp.path().join("release-root").join("current");
693 let target = std::fs::read_link(&link).expect("current is a symlink");
694 assert_eq!(target, PathBuf::from(format!("releases/{}", &digest[..16])));
695 }
696
697 #[tokio::test]
698 async fn stage_and_gate_marks_the_run_failed_and_does_not_advance_when_a_gate_is_red() {
699 // ManualConfirm with no prior confirmation row blocks deterministically.
700 let (pool, cfg, topo, art, run_id, _version, _tmp) =
701 stage_fixture(vec![Gate::ManualConfirm]).await;
702 let deploy_lock = Arc::new(tokio::sync::Mutex::new(()));
703
704 // A red gate is a pipeline outcome, not an error: the fn records the
705 // failure and returns Ok so the spawned task settles the run cleanly.
706 stage_and_gate(
707 pool.clone(),
708 cfg,
709 topo,
710 art,
711 crate::events::channel(),
712 run_id,
713 deploy_lock,
714 )
715 .await
716 .expect("a red gate settles the run, it does not error out");
717
718 // Tier did NOT advance — still the seeded NULL/NULL.
719 let (current, previous) = tier_versions(&pool, "host").await;
720 assert_eq!(current, None);
721 assert_eq!(previous, None);
722
723 // Run settled red with a non-empty summary.
724 let (result, summary) = run_result(&pool, run_id).await;
725 assert_eq!(result, "failed");
726 assert!(
727 summary.as_deref().is_some_and(|s| !s.is_empty()),
728 "failed run must carry a summary, got {summary:?}"
729 );
730 }
731
732 #[test]
733 fn check_build_host_accepts_matching_host() {
734 assert!(check_build_host("fw13", "fw13").is_ok());
735 }
736
737 #[test]
738 fn check_build_host_refuses_mismatched_host() {
739 // A daemon misdeployed onto prod (e.g. a Hetzner host) must refuse.
740 let err = check_build_host("alpha-west-1", "fw13")
741 .unwrap_err()
742 .to_string();
743 assert!(err.contains("refusing to build"), "{err}");
744 assert!(
745 err.contains("alpha-west-1") && err.contains("fw13"),
746 "{err}"
747 );
748 }
749
750 #[test]
751 fn runtime_hostname_reads_a_nonempty_trimmed_name() {
752 let h = runtime_hostname().expect("hostname readable on Linux");
753 assert!(!h.is_empty());
754 assert_eq!(h, h.trim(), "must be trimmed");
755 }
756
757 #[test]
758 fn tail_does_not_panic_on_multibyte_boundary() {
759 // Each '€' is 3 bytes; a byte cap landing mid-codepoint must not panic.
760 let s = "".repeat(10); // 30 bytes
761 for max in 1..=30 {
762 let out = tail(s.as_bytes(), max);
763 assert!(out.len() <= max, "max={max} got {} bytes", out.len());
764 // Result is always valid UTF-8 made only of whole '€'s.
765 assert!(out.chars().all(|c| c == ''), "max={max}: {out:?}");
766 }
767 }
768
769 #[test]
770 fn tail_returns_whole_input_when_under_cap() {
771 assert_eq!(tail(b"hello", 100), "hello");
772 }
773