Skip to main content

xtask/
deploy_render.rs

1//! Guards `.forgejo/scripts/deploy.sh`'s handoff to `ci-image`.
2//!
3//! History, because it explains the shape of these tests. deploy.sh used to render a kaniko Job
4//! itself, from an UNQUOTED `<<EOF` heredoc — so the outer shell expanded the inner script before
5//! kubectl ever saw it. Nothing in the text looked dangerous while you read it, which was exactly
6//! the problem: a backtick in a *comment* became a command, and `$1` became deploy.sh's own
7//! argument. Both mistakes shipped, and the first killed the deploy at the heredoc under `set -u`
8//! before any Job existed — which is why there were no logs to read.
9//!
10//! The Job is now rendered by `ci-image` (do-k8s apps/ci/runner-image/ci-image-build.sh), which
11//! carries its own manifest-rendering tests. What is left here is the half this repo owns: the
12//! prepare script, and whether it reaches ci-image intact. It travels as a QUOTED heredoc into an
13//! env value, so the outer shell no longer expands anything — but that is a property worth
14//! asserting rather than assuming, since unquoting the heredoc would silently restore the old
15//! hazard.
16//!
17//! So this runs deploy.sh with a `ci-image` shim on PATH and checks what it was actually handed.
18
19#[cfg(test)]
20mod tests {
21    use std::fs;
22    use std::os::unix::fs::PermissionsExt;
23    use std::path::{Path, PathBuf};
24    use std::process::Command;
25
26    fn repo_root() -> PathBuf {
27        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
28            .parent()
29            .expect("xtask has a parent")
30            .to_path_buf()
31    }
32
33    fn write_shim(dir: &Path, name: &str, body: &str) {
34        let path = dir.join(name);
35        fs::write(&path, body).expect("write shim");
36        let mut perms = fs::metadata(&path).expect("stat shim").permissions();
37        perms.set_mode(0o755);
38        fs::set_permissions(&path, perms).expect("chmod shim");
39    }
40
41    /// What deploy.sh handed to ci-image: the prepare script, and its environment.
42    struct Handoff {
43        prep: String,
44        env: String,
45    }
46
47    /// Runs deploy.sh with shims and returns the ci-image invocation it produced.
48    ///
49    /// Each call gets its own directory: cargo runs a crate's tests in threads, and a shared one
50    /// had every test deleting the others' shims mid-run — which looked exactly like the failure
51    /// it is meant to detect.
52    fn run_deploy() -> Handoff {
53        use std::sync::atomic::{AtomicUsize, Ordering};
54        static SEQ: AtomicUsize = AtomicUsize::new(0);
55        let unique = format!(
56            "{}-{}",
57            std::process::id(),
58            SEQ.fetch_add(1, Ordering::Relaxed)
59        );
60        let root = repo_root();
61        let script = root.join(".forgejo/scripts/deploy.sh");
62        assert!(
63            script.exists(),
64            "deploy.sh not found at {}",
65            script.display()
66        );
67
68        let tmp = std::env::temp_dir().join(format!("nomisync-deploy-render-{unique}"));
69        let _ = fs::remove_dir_all(&tmp);
70        fs::create_dir_all(&tmp).expect("temp dir");
71        let prep_file = tmp.join("prep.sh");
72        let env_file = tmp.join("env.txt");
73
74        // The ci-image shim records what it was given and returns a well-formed digest, so
75        // deploy.sh proceeds exactly as it would in production.
76        write_shim(
77            &tmp,
78            "ci-image",
79            &format!(
80                "#!/usr/bin/env bash\nprintf '%s' \"$PREP_SCRIPT\" > {}\n\
81                 {{ echo \"PROJECT=$PROJECT\"; echo \"TARGET=$TARGET\"; echo \"IMAGE=$IMAGE\"; \
82                 echo \"PREP_SECRETS=$PREP_SECRETS\"; echo \"PREP_ENV=$PREP_ENV\"; }} > {}\n\
83                 echo 'sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'\n",
84                prep_file.display(),
85                env_file.display()
86            ),
87        );
88        // deploy.sh checks it is still master tip; answer with the sha it is given.
89        write_shim(
90            &tmp,
91            "git",
92            "#!/usr/bin/env bash\ncase \"$1\" in\n  ls-remote) echo \"$SHA refs/heads/master\";;\nesac\nexit 0\n",
93        );
94        // The GitOps bump that follows needs a cluster; it is not what this tests.
95        write_shim(&tmp, "kubectl", "#!/usr/bin/env bash\nexit 0\n");
96
97        let path = format!(
98            "{}:{}",
99            tmp.display(),
100            std::env::var("PATH").unwrap_or_default()
101        );
102        let _ = Command::new("timeout")
103            .args(["25", "bash", &script.to_string_lossy()])
104            .env("PATH", path)
105            .env("REPO", "rayslava/nomisync")
106            .env("SHA", "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
107            .env("GIT_TOKEN", "token")
108            .env("GITHUB_RUN_NUMBER", "1")
109            .env("GITHUB_RUN_ID", "1")
110            .current_dir(&root)
111            .output()
112            .expect("run deploy.sh");
113
114        let prep = fs::read_to_string(&prep_file).unwrap_or_else(|_| {
115            panic!(
116                "deploy.sh never called ci-image — it died before the handoff. \
117                 That is what an unterminated heredoc or a bad expansion does."
118            )
119        });
120        let env = fs::read_to_string(&env_file).expect("ci-image env capture");
121        let _ = fs::remove_dir_all(&tmp);
122        Handoff { prep, env }
123    }
124
125    #[test]
126    fn deploy_reaches_ci_image_with_a_prepare_script() {
127        let _processes = crate::test_support::processes();
128        let h = run_deploy();
129        assert!(
130            h.prep.contains("nomisync-manifest.json"),
131            "the prepare script is missing its manifest fetch, so the heredoc was truncated:\n{}",
132            h.prep
133        );
134        assert!(
135            h.env.contains("PROJECT=nomisync")
136                && h.env.contains("IMAGE=registry.do.rayslava.com/nomisync:"),
137            "ci-image was called with the wrong identity:\n{}",
138            h.env
139        );
140    }
141
142    #[test]
143    fn the_quoted_heredoc_does_not_eat_inner_dollars() {
144        // `print(m$1)` belongs to the CONTAINER's shell. Under the old unquoted heredoc, deploy.sh
145        // expanded it — to nothing, or fatally under set -u. A quoted heredoc must pass it through
146        // verbatim; if someone unquotes it, this fails.
147        let _processes = crate::test_support::processes();
148        let h = run_deploy();
149        assert!(
150            h.prep.contains("print(m$1)"),
151            "$1 was expanded away before ci-image saw it — is the PREP heredoc still quoted?\n{}",
152            h.prep
153        );
154    }
155
156    #[test]
157    fn the_prepare_heredoc_is_quoted() {
158        // Checked in the SOURCE: an unquoted heredoc EXECUTES a backtick and substitutes its
159        // output, so a hazard there can vanish before any captured-output check could see it.
160        // Quoting is what makes the whole body inert, comments included.
161        let source = fs::read_to_string(repo_root().join(".forgejo/scripts/deploy.sh"))
162            .expect("read deploy.sh");
163        assert!(
164            source.contains("PREP_SCRIPT=\"$(cat <<'PREP'"),
165            "the prepare heredoc is no longer quoted; the outer shell will expand and execute its body"
166        );
167    }
168
169    #[test]
170    fn the_prepare_script_is_valid_bash() {
171        let _processes = crate::test_support::processes();
172        let h = run_deploy();
173        let dir = std::env::temp_dir().join(format!(
174            "nomisync-prepare-{}-{:?}",
175            std::process::id(),
176            std::thread::current().id()
177        ));
178        fs::create_dir_all(&dir).expect("temp dir");
179        let path = dir.join("prepare.sh");
180        fs::write(&path, &h.prep).expect("write prepare");
181
182        let out = Command::new("bash")
183            .args(["-n", &path.to_string_lossy()])
184            .output()
185            .expect("bash -n");
186        let _ = fs::remove_dir_all(&dir);
187
188        assert!(
189            out.status.success(),
190            "the prepare script is not valid bash:\n{}",
191            String::from_utf8_lossy(&out.stderr)
192        );
193    }
194
195    #[test]
196    fn the_prepare_script_keeps_its_provenance_checks() {
197        // These are the reason the prepare script exists. It fetches by content-addressed key and
198        // re-verifies, and it refuses a manifest belonging to another commit or run. Losing any of
199        // them while moving the script around would deploy unverified bytes and still go green.
200        let _processes = crate::test_support::processes();
201        let h = run_deploy();
202        for needle in [
203            "manifest commit",
204            "manifest run",
205            "hash mismatch against the manifest",
206            "sha256sum -c -",
207        ] {
208            assert!(
209                h.prep.contains(needle),
210                "the prepare script lost its check for {needle:?}:\n{}",
211                h.prep
212            );
213        }
214    }
215
216    #[test]
217    fn the_run_id_reaches_the_prepare_container() {
218        // The manifest cross-check compares against GITHUB_RUN_ID. ci-image does not forward it on
219        // its own, so it must be passed via PREP_ENV — absent, the check would compare against an
220        // empty string and pass vacuously.
221        let _processes = crate::test_support::processes();
222        let h = run_deploy();
223        assert!(
224            h.env.contains("PREP_ENV=GITHUB_RUN_ID="),
225            "GITHUB_RUN_ID is not being passed to the prepare container:\n{}",
226            h.env
227        );
228    }
229
230    #[test]
231    fn both_deployments_are_pinned_by_digest() {
232        // A tag can be re-pointed at other bytes. ci-image returns the digest this run pushed, and
233        // both Deployments share one image, so both references must carry it.
234        let source = fs::read_to_string(repo_root().join(".forgejo/scripts/deploy.sh"))
235            .expect("read deploy.sh");
236        assert!(
237            source.contains("${SHA}@${DIGEST}"),
238            "the GitOps bump writes a bare tag; it should pin the digest ci-image returned"
239        );
240        assert!(
241            source.contains("MANIFESTS=\"$WEB_MANIFEST $SSHD_MANIFEST\""),
242            "both Deployments must start in the same edit set, or web and sshd can split"
243        );
244        assert!(
245            source.contains("sed -i -E") && source.contains("$MANIFESTS"),
246            "the bump must rewrite that whole set in one sed"
247        );
248    }
249
250    #[test]
251    fn the_fleet_migration_job_is_bumped_with_the_deployments() {
252        // The Job runs the migrations compiled into THIS image. Left on a stale
253        // tag it would migrate the fleet with a different release's migration
254        // set — the schema drift the migrator exists to prevent, caused by the
255        // migrator itself.
256        let source = fs::read_to_string(repo_root().join(".forgejo/scripts/deploy.sh"))
257            .expect("read deploy.sh");
258        assert!(
259            source.contains("JOB_MANIFEST="),
260            "the fleet migration Job is not part of the GitOps bump"
261        );
262        assert!(
263            source.contains("[ -f \"$JOB_MANIFEST\" ] && MANIFESTS=\"$MANIFESTS $JOB_MANIFEST\""),
264            "the Job must join the edit set when its manifest exists, and be skipped \
265             — not fatal — while it does not, exactly as the Deployments bootstrap"
266        );
267    }
268
269    #[test]
270    fn the_release_artifact_carries_the_fleet_migrator() {
271        // The Dockerfile COPYs it, so an image build fails outright if packaging
272        // drops it — after a successful push, which is the expensive place to
273        // find out.
274        let source = fs::read_to_string(repo_root().join(".forgejo/scripts/deploy.sh"))
275            .expect("read deploy.sh");
276        assert!(
277            source.contains("release-fleet-migrate"),
278            "deploy.sh does not assert the migrator binary survived the tarball"
279        );
280
281        let dockerfile =
282            fs::read_to_string(repo_root().join("Dockerfile")).expect("read Dockerfile");
283        assert!(
284            dockerfile.contains("COPY release-fleet-migrate"),
285            "the image does not carry the migrator the Job runs"
286        );
287        assert!(
288            dockerfile.contains("ENTRYPOINT [\"/web\"]"),
289            "the migrator must be a Job command, never the container entrypoint"
290        );
291    }
292}