1#[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 struct Handoff {
43 prep: String,
44 env: String,
45 }
46
47 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 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 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 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 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 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 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 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 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 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 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}