Skip to main content

max / alloy

Stop the build context from dropping the schemas the image copies `.containerignore` has excluded /schemas since 2026-07-19, when nothing read it. On 2026-08-01 the Containerfile grew `COPY schemas /usr/share/alloy/schemas` to fix installed machines whose settings tab opened on "no schemas found". The two cancelled: the COPY matched nothing, podman failed the build on the spot, and the image has not been buildable by either profile since. Nothing caught it because no image was built in that window, and a source checkout cannot see it — cargo run finds the repo's own copies through the search path's CARGO_MANIFEST_DIR fallback. tests/build_context.rs is the guard, in the same spirit as profile_split.rs: it reads every COPY that takes from the build context and fails if .containerignore drops it, plus the other direction, a COPY naming a path that is not in the repo. Checked against a negative control — with /schemas put back, the test fails naming the COPY.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-03 23:03 UTC
Signed with PGP, not checked
Commit: 13d4628f399651eb60f437e545a0e312a804c794
Parent: e02475d
2 files changed, +154 insertions, -5 deletions
M .containerignore +13 -5
@@ -6,10 +6,19 @@
6 6 # every run.
7 7 #
8 8 # Keep this in step with the COPY lines in the Containerfiles: the image
9 - # build reads Cargo.toml, Cargo.lock, crates/, templates/, etc/ and usr/,
10 - # and build/Containerfile.iso additionally reads build/make-iso.sh. etc/ is
11 - # read twice, once into the runtime image and once into the rust-build stage,
12 - # where etc/skel is checked against the rendered skeleton for overlap.
9 + # build reads Cargo.toml, Cargo.lock, crates/, templates/, etc/, usr/ and
10 + # schemas/, and build/Containerfile.iso additionally reads build/make-iso.sh.
11 + # etc/ is read twice, once into the runtime image and once into the rust-build
12 + # stage, where etc/skel is checked against the rendered skeleton for overlap.
13 + #
14 + # "Keep this in step" is not advice that was followed. `/schemas` sat in the
15 + # exclusions below from 2026-07-19, and on 2026-08-01 the Containerfile grew
16 + # `COPY schemas /usr/share/alloy/schemas` to fix the settings tab opening on
17 + # "no schemas found". The two cancelled: the COPY matched nothing and podman
18 + # failed the build outright, so from that commit until 2026-08-03 the image
19 + # could not be built at all, by either profile. Nothing caught it because no
20 + # image was built in that window. crates/alloy/tests/build_context.rs is what
21 + # catches it now.
13 22
14 23 # Cargo artifacts. The rust-build stage compiles from scratch inside the
15 24 # image on purpose — host artifacts are built against a different libc and
@@ -35,5 +44,4 @@
35 44 !/build/make-iso.sh
36 45 /builds.disabled
37 46 /docs
38 - /schemas
39 47 /tools
@@ -1,0 +1,141 @@
1 + //! Every `COPY` reads something the build context actually carries.
2 + //!
3 + //! `.containerignore` is an optimization: podman tars the whole directory
4 + //! before reading the first instruction, so excluding `output/` saves shipping
5 + //! a multi-gigabyte ISO to every build. The file's own header says to keep it
6 + //! in step with the `COPY` lines, and that instruction is exactly the kind
7 + //! nobody follows, because nothing enforced it.
8 + //!
9 + //! It went wrong once and cost two days. `/schemas` was excluded on
10 + //! 2026-07-19, when nothing read it. On 2026-08-01 the Containerfile grew
11 + //! `COPY schemas /usr/share/alloy/schemas`, fixing an installed machine whose
12 + //! settings tab opened on "no schemas found". The exclusion was still there,
13 + //! so the COPY matched nothing and podman failed the build outright — every
14 + //! build, both profiles, from that commit until 2026-08-03. Nobody noticed
15 + //! because no image was built in the window, and a source checkout cannot see
16 + //! it: `cargo run` finds the repo's own copies through the search path's
17 + //! `CARGO_MANIFEST_DIR` fallback.
18 + //!
19 + //! A text check, in the same spirit as `profile_split.rs` and
20 + //! `polkit_rules.rs`: it runs on every `cargo test` and catches the edit that
21 + //! a build would only catch an hour in.
22 +
23 + use std::path::PathBuf;
24 +
25 + fn repo() -> PathBuf {
26 + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
27 + }
28 +
29 + fn read(relative: &str) -> String {
30 + let path = repo().join(relative);
31 + std::fs::read_to_string(&path)
32 + .unwrap_or_else(|err| panic!("cannot read {}: {err}", path.display()))
33 + }
34 +
35 + /// The context-relative source of every `COPY` that reads the build context.
36 + ///
37 + /// `COPY --from=<stage>` reads an earlier stage rather than the context and is
38 + /// not this file's business: whether `/staged-skel` exists is the build's own
39 + /// question, and `.containerignore` has no opinion on it.
40 + fn context_copies(containerfile: &str) -> Vec<String> {
41 + containerfile
42 + .lines()
43 + .map(str::trim)
44 + .filter(|line| line.starts_with("COPY "))
45 + .filter(|line| !line.contains("--from="))
46 + .filter_map(|line| {
47 + // `COPY <src>... <dst>`: everything but the last word is a source,
48 + // and every source shipped today is a single one.
49 + let words: Vec<&str> = line.split_whitespace().skip(1).collect();
50 + words
51 + .split_last()
52 + .map(|(_, sources)| sources)?
53 + .first()
54 + .copied()
55 + })
56 + .map(|source| source.trim_start_matches("./").to_string())
57 + .collect()
58 + }
59 +
60 + /// Whether `.containerignore` drops `source` from the context.
61 + ///
62 + /// Only the shape the file actually uses is modelled: whole top-level entries,
63 + /// with `!` negating an earlier exclusion (`/build` then `!/build/make-iso.sh`).
64 + /// A glob would need real pattern matching, and inventing that here would mean
65 + /// this test disagreeing with podman about what a pattern means, which is
66 + /// worse than not covering a shape the file does not use.
67 + fn excluded_by(ignore: &str, source: &str) -> bool {
68 + let mut excluded = false;
69 + for rule in ignore
70 + .lines()
71 + .map(str::trim)
72 + .filter(|line| !line.is_empty() && !line.starts_with('#'))
73 + {
74 + assert!(
75 + !rule.contains('*'),
76 + "`{rule}` is a glob, and this test does not model globs. \
77 + Teach it the pattern or keep .containerignore literal."
78 + );
79 + let (negated, pattern) = match rule.strip_prefix('!') {
80 + Some(rest) => (true, rest),
81 + None => (false, rule),
82 + };
83 + let pattern = pattern.trim_start_matches('/');
84 + // A directory rule covers everything under it, which is what makes
85 + // `/build` plus `!/build/make-iso.sh` work in that order.
86 + if source == pattern || source.starts_with(&format!("{pattern}/")) {
87 + excluded = !negated;
88 + }
89 + }
90 + excluded
91 + }
92 +
93 + #[test]
94 + fn no_copy_reads_a_path_the_context_excludes() {
95 + let ignore = read(".containerignore");
96 + for file in ["Containerfile", "build/Containerfile.iso"] {
97 + for source in context_copies(&read(file)) {
98 + assert!(
99 + !excluded_by(&ignore, &source),
100 + "{file} copies `{source}`, which .containerignore drops from the \
101 + build context. podman fails the build on the COPY, an hour in."
102 + );
103 + }
104 + }
105 + }
106 +
107 + /// The other direction of the same mistake: a `COPY` naming a path that is not
108 + /// in the repo at all. Cheap to check while the sources are already in hand,
109 + /// and it fails the same way at the same cost.
110 + #[test]
111 + fn every_copy_names_something_that_exists() {
112 + for file in ["Containerfile", "build/Containerfile.iso"] {
113 + for source in context_copies(&read(file)) {
114 + let path = repo().join(&source);
115 + assert!(
116 + path.exists(),
117 + "{file} copies `{source}`, which is not in the repo"
118 + );
119 + }
120 + }
121 + }
122 +
123 + // The check has to be able to fail, or it is decoration. Both halves of the
124 + // rule are exercised against the shape that actually caused the outage.
125 + #[test]
126 + fn the_exclusion_check_can_fail() {
127 + let ignore = "/output\n/schemas\n";
128 + assert!(excluded_by(ignore, "schemas"));
129 + assert!(excluded_by(ignore, "schemas/shop.toml.schema"));
130 + assert!(!excluded_by(ignore, "templates"));
131 + // The negation only counts after the exclusion, which is the order
132 + // .containerignore's own comment warns about.
133 + assert!(!excluded_by(
134 + "/build\n!/build/make-iso.sh\n",
135 + "build/make-iso.sh"
136 + ));
137 + assert!(excluded_by(
138 + "!/build/make-iso.sh\n/build\n",
139 + "build/make-iso.sh"
140 + ));
141 + }