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)]
20
mod 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
10
    fn repo_root() -> PathBuf {
27
10
        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
28
10
            .parent()
29
10
            .expect("xtask has a parent")
30
10
            .to_path_buf()
31
10
    }
32

            
33
15
    fn write_shim(dir: &Path, name: &str, body: &str) {
34
15
        let path = dir.join(name);
35
15
        fs::write(&path, body).expect("write shim");
36
15
        let mut perms = fs::metadata(&path).expect("stat shim").permissions();
37
15
        perms.set_mode(0o755);
38
15
        fs::set_permissions(&path, perms).expect("chmod shim");
39
15
    }
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
5
    fn run_deploy() -> Handoff {
53
        use std::sync::atomic::{AtomicUsize, Ordering};
54
        static SEQ: AtomicUsize = AtomicUsize::new(0);
55
5
        let unique = format!(
56
            "{}-{}",
57
5
            std::process::id(),
58
5
            SEQ.fetch_add(1, Ordering::Relaxed)
59
        );
60
5
        let root = repo_root();
61
5
        let script = root.join(".forgejo/scripts/deploy.sh");
62
5
        assert!(
63
5
            script.exists(),
64
            "deploy.sh not found at {}",
65
            script.display()
66
        );
67

            
68
5
        let tmp = std::env::temp_dir().join(format!("nomisync-deploy-render-{unique}"));
69
5
        let _ = fs::remove_dir_all(&tmp);
70
5
        fs::create_dir_all(&tmp).expect("temp dir");
71
5
        let prep_file = tmp.join("prep.sh");
72
5
        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
5
        write_shim(
77
5
            &tmp,
78
5
            "ci-image",
79
5
            &format!(
80
5
                "#!/usr/bin/env bash\nprintf '%s' \"$PREP_SCRIPT\" > {}\n\
81
5
                 {{ echo \"PROJECT=$PROJECT\"; echo \"TARGET=$TARGET\"; echo \"IMAGE=$IMAGE\"; \
82
5
                 echo \"PREP_SECRETS=$PREP_SECRETS\"; echo \"PREP_ENV=$PREP_ENV\"; }} > {}\n\
83
5
                 echo 'sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'\n",
84
5
                prep_file.display(),
85
5
                env_file.display()
86
5
            ),
87
        );
88
        // deploy.sh checks it is still master tip; answer with the sha it is given.
89
5
        write_shim(
90
5
            &tmp,
91
5
            "git",
92
5
            "#!/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
5
        write_shim(&tmp, "kubectl", "#!/usr/bin/env bash\nexit 0\n");
96

            
97
5
        let path = format!(
98
            "{}:{}",
99
5
            tmp.display(),
100
5
            std::env::var("PATH").unwrap_or_default()
101
        );
102
5
        let _ = Command::new("timeout")
103
5
            .args(["25", "bash", &script.to_string_lossy()])
104
5
            .env("PATH", path)
105
5
            .env("REPO", "rayslava/nomisync")
106
5
            .env("SHA", "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
107
5
            .env("GIT_TOKEN", "token")
108
5
            .env("GITHUB_RUN_NUMBER", "1")
109
5
            .env("GITHUB_RUN_ID", "1")
110
5
            .current_dir(&root)
111
5
            .output()
112
5
            .expect("run deploy.sh");
113

            
114
5
        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
5
        let env = fs::read_to_string(&env_file).expect("ci-image env capture");
121
5
        let _ = fs::remove_dir_all(&tmp);
122
5
        Handoff { prep, env }
123
5
    }
124

            
125
    #[test]
126
1
    fn deploy_reaches_ci_image_with_a_prepare_script() {
127
1
        let _processes = crate::test_support::processes();
128
1
        let h = run_deploy();
129
1
        assert!(
130
1
            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
1
        assert!(
135
1
            h.env.contains("PROJECT=nomisync")
136
1
                && h.env.contains("IMAGE=registry.do.rayslava.com/nomisync:"),
137
            "ci-image was called with the wrong identity:\n{}",
138
            h.env
139
        );
140
1
    }
141

            
142
    #[test]
143
1
    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
1
        let _processes = crate::test_support::processes();
148
1
        let h = run_deploy();
149
1
        assert!(
150
1
            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
1
    }
155

            
156
    #[test]
157
1
    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
1
        let source = fs::read_to_string(repo_root().join(".forgejo/scripts/deploy.sh"))
162
1
            .expect("read deploy.sh");
163
1
        assert!(
164
1
            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
1
    }
168

            
169
    #[test]
170
1
    fn the_prepare_script_is_valid_bash() {
171
1
        let _processes = crate::test_support::processes();
172
1
        let h = run_deploy();
173
1
        let dir = std::env::temp_dir().join(format!(
174
1
            "nomisync-prepare-{}-{:?}",
175
1
            std::process::id(),
176
1
            std::thread::current().id()
177
1
        ));
178
1
        fs::create_dir_all(&dir).expect("temp dir");
179
1
        let path = dir.join("prepare.sh");
180
1
        fs::write(&path, &h.prep).expect("write prepare");
181

            
182
1
        let out = Command::new("bash")
183
1
            .args(["-n", &path.to_string_lossy()])
184
1
            .output()
185
1
            .expect("bash -n");
186
1
        let _ = fs::remove_dir_all(&dir);
187

            
188
1
        assert!(
189
1
            out.status.success(),
190
            "the prepare script is not valid bash:\n{}",
191
            String::from_utf8_lossy(&out.stderr)
192
        );
193
1
    }
194

            
195
    #[test]
196
1
    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
1
        let _processes = crate::test_support::processes();
201
1
        let h = run_deploy();
202
4
        for needle in [
203
1
            "manifest commit",
204
1
            "manifest run",
205
1
            "hash mismatch against the manifest",
206
1
            "sha256sum -c -",
207
1
        ] {
208
4
            assert!(
209
4
                h.prep.contains(needle),
210
                "the prepare script lost its check for {needle:?}:\n{}",
211
                h.prep
212
            );
213
        }
214
1
    }
215

            
216
    #[test]
217
1
    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
1
        let _processes = crate::test_support::processes();
222
1
        let h = run_deploy();
223
1
        assert!(
224
1
            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
1
    }
229

            
230
    #[test]
231
1
    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
1
        let source = fs::read_to_string(repo_root().join(".forgejo/scripts/deploy.sh"))
235
1
            .expect("read deploy.sh");
236
1
        assert!(
237
1
            source.contains("${SHA}@${DIGEST}"),
238
            "the GitOps bump writes a bare tag; it should pin the digest ci-image returned"
239
        );
240
1
        assert!(
241
1
            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
1
        assert!(
245
1
            source.contains("sed -i -E") && source.contains("$MANIFESTS"),
246
            "the bump must rewrite that whole set in one sed"
247
        );
248
1
    }
249

            
250
    #[test]
251
1
    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
1
        let source = fs::read_to_string(repo_root().join(".forgejo/scripts/deploy.sh"))
257
1
            .expect("read deploy.sh");
258
1
        assert!(
259
1
            source.contains("JOB_MANIFEST="),
260
            "the fleet migration Job is not part of the GitOps bump"
261
        );
262
1
        assert!(
263
1
            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
1
    }
268

            
269
    #[test]
270
1
    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
1
        let source = fs::read_to_string(repo_root().join(".forgejo/scripts/deploy.sh"))
275
1
            .expect("read deploy.sh");
276
1
        assert!(
277
1
            source.contains("release-fleet-migrate"),
278
            "deploy.sh does not assert the migrator binary survived the tarball"
279
        );
280

            
281
1
        let dockerfile =
282
1
            fs::read_to_string(repo_root().join("Dockerfile")).expect("read Dockerfile");
283
1
        assert!(
284
1
            dockerfile.contains("COPY release-fleet-migrate"),
285
            "the image does not carry the migrator the Job runs"
286
        );
287
1
        assert!(
288
1
            dockerfile.contains("ENTRYPOINT [\"/web\"]"),
289
            "the migrator must be a Job command, never the container entrypoint"
290
        );
291
1
    }
292
}