| 14 |
14 |
|
//! `<release_root>/current/<bin_name>` so reload-or-restart picks up the new
|
| 15 |
15 |
|
//! binary without ever pointing at a missing path.
|
| 16 |
16 |
|
//!
|
| 17 |
|
- |
//! For nodes with `ssh_target` set to anything other than `"local"`, deploy
|
| 18 |
|
- |
//! goes via rsync + ssh; the bootstrap (creating release_root, installing the
|
| 19 |
|
- |
//! service unit, granting sudo for systemctl) is out of scope here — it
|
| 20 |
|
- |
//! happens once per node, not per deploy.
|
|
17 |
+ |
//! The host-side transport — `ssh` for shell steps, `rsync` for the release
|
|
18 |
+ |
//! dir — comes from the shared [`ops_exec::Executor`] (a `LocalExec` for
|
|
19 |
+ |
//! `ssh_target = "local"`, an `SshExec` otherwise), built once per node in
|
|
20 |
+ |
//! [`crate::state`]. This module owns the *deploy choreography* (mkdir, push,
|
|
21 |
+ |
//! atomic swap, restart, gc); the transport is the crate's. SSH push behavior
|
|
22 |
+ |
//! is identical to the pre-extraction code — this is a transport extraction,
|
|
23 |
+ |
//! not a model change.
|
| 21 |
24 |
|
|
| 22 |
25 |
|
use crate::topology::Node;
|
| 23 |
26 |
|
use anyhow::{Context, Result};
|
|
27 |
+ |
use async_trait::async_trait;
|
|
28 |
+ |
use ops_exec::{Action, Executor, LogSink, RunOutput, Step, SyncOpts, sh_quote};
|
| 24 |
29 |
|
use std::path::{Path, PathBuf};
|
| 25 |
30 |
|
use tokio::process::Command;
|
| 26 |
31 |
|
|
| 27 |
|
- |
/// SSH options used everywhere we shell out to ssh — fail fast, no prompts.
|
| 28 |
|
- |
const SSH_FLAGS: &[&str] = &[
|
| 29 |
|
- |
"-o", "BatchMode=yes",
|
| 30 |
|
- |
"-o", "ConnectTimeout=10",
|
| 31 |
|
- |
"-o", "StrictHostKeyChecking=accept-new",
|
| 32 |
|
- |
];
|
| 33 |
|
- |
|
| 34 |
32 |
|
/// Keep this many release dirs per node; older ones get gc'd after a
|
| 35 |
33 |
|
/// successful deploy. Fixed for now; promote to config if the constant ever
|
| 36 |
34 |
|
/// needs to vary by tier.
|
| 37 |
35 |
|
const RELEASES_TO_KEEP: usize = 5;
|
| 38 |
36 |
|
|
|
37 |
+ |
/// A sink that drops streamed bytes. Deploy steps don't have a live-log handle
|
|
38 |
+ |
/// (gates do), so output is discarded as it streams; [`RunOutput`] still
|
|
39 |
+ |
/// captures the full stdout/stderr for error reporting, preserving the
|
|
40 |
+ |
/// pre-extraction behavior of surfacing `stderr` in failure messages.
|
|
41 |
+ |
struct DiscardSink;
|
|
42 |
+ |
|
|
43 |
+ |
#[async_trait]
|
|
44 |
+ |
impl LogSink for DiscardSink {
|
|
45 |
+ |
async fn write_chunk(&mut self, _bytes: &[u8]) {}
|
|
46 |
+ |
}
|
|
47 |
+ |
|
|
48 |
+ |
/// Run a shell step through `executor`, treating a non-zero exit as an error
|
|
49 |
+ |
/// whose message carries the captured stderr — exactly as the old bespoke
|
|
50 |
+ |
/// `ssh()` helper did (`ssh <target> failed: <stderr>`).
|
|
51 |
+ |
async fn run_checked(executor: &dyn Executor, script: &str, what: &str) -> Result<RunOutput> {
|
|
52 |
+ |
let step = Step::shell(Action::Deploy, script);
|
|
53 |
+ |
let mut sink = DiscardSink;
|
|
54 |
+ |
let out = executor
|
|
55 |
+ |
.run_streaming(&step, &mut sink)
|
|
56 |
+ |
.await
|
|
57 |
+ |
.with_context(|| format!("{what}: spawning command"))?;
|
|
58 |
+ |
anyhow::ensure!(
|
|
59 |
+ |
out.status.success(),
|
|
60 |
+ |
"{what} failed (exit {}): {}",
|
|
61 |
+ |
out.status.code().map(|c| c.to_string()).unwrap_or_else(|| "signal".into()),
|
|
62 |
+ |
String::from_utf8_lossy(&out.stderr),
|
|
63 |
+ |
);
|
|
64 |
+ |
Ok(out)
|
|
65 |
+ |
}
|
|
66 |
+ |
|
| 39 |
67 |
|
pub async fn deploy_local(
|
| 40 |
68 |
|
release_root: &Path,
|
| 41 |
69 |
|
version: &crate::domain::Version,
|
| 72 |
100 |
|
}
|
| 73 |
101 |
|
|
| 74 |
102 |
|
/// Deploy `staged_release_dir` (a directory built on the Sando host by
|
| 75 |
|
- |
/// `deploy_local`) to `node`. For `ssh_target=local`, this is just symlink
|
| 76 |
|
- |
/// swap + restart; for remote nodes, we rsync the whole dir.
|
|
103 |
+ |
/// `deploy_local`) to `node` using `executor` (its transport from the topology
|
|
104 |
+ |
/// executor map). For `ssh_target=local`, this is just a symlink swap; for
|
|
105 |
+ |
/// remote nodes, we rsync the whole dir over the executor.
|
| 77 |
106 |
|
///
|
| 78 |
107 |
|
/// `primary_bin` is only used for logging — every file present in the staged
|
| 79 |
108 |
|
/// dir gets shipped.
|
| 80 |
109 |
|
pub async fn deploy_node(
|
|
110 |
+ |
executor: &dyn Executor,
|
| 81 |
111 |
|
node: &Node,
|
| 82 |
112 |
|
version: &str,
|
| 83 |
113 |
|
staged_release_dir: &Path,
|
| 86 |
116 |
|
if node.ssh_target == "local" || node.ssh_target.is_empty() {
|
| 87 |
117 |
|
// Local deploy already happened when we staged on the Sando host.
|
| 88 |
118 |
|
// Just re-point `current` at the staged dir.
|
| 89 |
|
- |
return reset_local_current(Path::new(&node.release_root), version).await;
|
|
119 |
+ |
return reset_local_current(executor, Path::new(&node.release_root), version).await;
|
| 90 |
120 |
|
}
|
| 91 |
|
- |
deploy_remote(node, version, staged_release_dir, primary_bin).await
|
|
121 |
+ |
deploy_remote(executor, node, version, staged_release_dir, primary_bin).await
|
| 92 |
122 |
|
}
|
| 93 |
123 |
|
|
| 94 |
|
- |
async fn reset_local_current(release_root: &Path, version: &str) -> Result<PathBuf> {
|
|
124 |
+ |
async fn reset_local_current(
|
|
125 |
+ |
executor: &dyn Executor,
|
|
126 |
+ |
release_root: &Path,
|
|
127 |
+ |
version: &str,
|
|
128 |
+ |
) -> Result<PathBuf> {
|
| 95 |
129 |
|
let current = release_root.join("current");
|
| 96 |
130 |
|
let target = format!("releases/{version}");
|
| 97 |
|
- |
let out = Command::new("ln")
|
| 98 |
|
- |
.args(["-sfn", &target])
|
| 99 |
|
- |
.arg(¤t)
|
| 100 |
|
- |
.output()
|
| 101 |
|
- |
.await?;
|
| 102 |
|
- |
anyhow::ensure!(
|
| 103 |
|
- |
out.status.success(),
|
| 104 |
|
- |
"symlink swap failed: {}",
|
| 105 |
|
- |
String::from_utf8_lossy(&out.stderr),
|
| 106 |
|
- |
);
|
|
131 |
+ |
run_checked(
|
|
132 |
+ |
executor,
|
|
133 |
+ |
&format!("ln -sfn {} {}", sh_quote(&target), sh_quote(¤t.to_string_lossy())),
|
|
134 |
+ |
"local symlink swap",
|
|
135 |
+ |
)
|
|
136 |
+ |
.await?;
|
| 107 |
137 |
|
Ok(release_root.join("releases").join(version))
|
| 108 |
138 |
|
}
|
| 109 |
139 |
|
|
| 110 |
140 |
|
async fn deploy_remote(
|
|
141 |
+ |
executor: &dyn Executor,
|
| 111 |
142 |
|
node: &Node,
|
| 112 |
143 |
|
version: &str,
|
| 113 |
144 |
|
staged_release_dir: &Path,
|
| 114 |
145 |
|
primary_bin: &str,
|
| 115 |
146 |
|
) -> Result<PathBuf> {
|
| 116 |
147 |
|
let release_root = &node.release_root;
|
| 117 |
|
- |
let ssh_target = &node.ssh_target;
|
| 118 |
148 |
|
let service = &node.service_name;
|
| 119 |
149 |
|
let release_dir = format!("{release_root}/releases/{version}");
|
| 120 |
150 |
|
|
| 121 |
151 |
|
tracing::info!(node = %node.name, version, "deploy: mkdir release dir");
|
| 122 |
|
- |
ssh(ssh_target, &format!("set -e; mkdir -p {q}", q = sh_quote(&release_dir)))
|
| 123 |
|
- |
.await
|
| 124 |
|
- |
.context("creating remote release dir")?;
|
|
152 |
+ |
run_checked(
|
|
153 |
+ |
executor,
|
|
154 |
+ |
&format!("set -e; mkdir -p {q}", q = sh_quote(&release_dir)),
|
|
155 |
+ |
"creating remote release dir",
|
|
156 |
+ |
)
|
|
157 |
+ |
.await?;
|
| 125 |
158 |
|
|
| 126 |
159 |
|
tracing::info!(node = %node.name, version, primary = %primary_bin, "deploy: rsync release dir");
|
| 127 |
160 |
|
// Rsync the whole staged dir (binaries + every release_contents entry).
|
| 128 |
|
- |
// Trailing slash on source = contents of dir, not the dir itself.
|
| 129 |
|
- |
//
|
| 130 |
|
- |
// --delete: removed assets across versions don't accumulate on the
|
| 131 |
|
- |
// target. Bundle stays self-contained per version.
|
| 132 |
|
- |
// --chmod: `F+X` preserves execute bit per-file (binaries land 0755,
|
| 133 |
|
- |
// data files 0644) instead of the old blanket-0755 that was wrong for
|
| 134 |
|
- |
// static assets + docs.
|
| 135 |
|
- |
let rsync_src = format!("{}/", staged_release_dir.display());
|
| 136 |
|
- |
let rsync_dest = format!("{ssh_target}:{release_dir}/");
|
| 137 |
|
- |
let mut rsync = Command::new("rsync");
|
| 138 |
|
- |
rsync
|
| 139 |
|
- |
.arg("-az")
|
| 140 |
|
- |
.arg("--partial")
|
| 141 |
|
- |
.arg("--delete")
|
| 142 |
|
- |
.arg("--chmod=Du=rwx,Dgo=rx,Fu=rw,Fgo=r,F+X")
|
| 143 |
|
- |
.arg("-e")
|
| 144 |
|
- |
.arg(format!(
|
| 145 |
|
- |
"ssh {}",
|
| 146 |
|
- |
SSH_FLAGS.iter().map(|s| s.to_string()).collect::<Vec<_>>().join(" ")
|
| 147 |
|
- |
))
|
| 148 |
|
- |
.arg(&rsync_src)
|
| 149 |
|
- |
.arg(&rsync_dest);
|
| 150 |
|
- |
let out = rsync.output().await.context("spawning rsync")?;
|
| 151 |
|
- |
anyhow::ensure!(
|
| 152 |
|
- |
out.status.success(),
|
| 153 |
|
- |
"rsync failed (current symlink left intact): {}",
|
| 154 |
|
- |
String::from_utf8_lossy(&out.stderr),
|
| 155 |
|
- |
);
|
|
161 |
+ |
// `SyncOpts::release_mirror()` is the exact pre-extraction rsync flag set:
|
|
162 |
+ |
// -az --partial --delete --chmod=Du=rwx,Dgo=rx,Fu=rw,Fgo=r,F+X.
|
|
163 |
+ |
// --delete: removed assets across versions don't accumulate on the
|
|
164 |
+ |
// target; the bundle stays self-contained per version.
|
|
165 |
+ |
// --chmod: F+X preserves the execute bit per-file (binaries land 0755,
|
|
166 |
+ |
// data files 0644) instead of a blanket 0755.
|
|
167 |
+ |
executor
|
|
168 |
+ |
.push(staged_release_dir, Path::new(&release_dir), &SyncOpts::release_mirror())
|
|
169 |
+ |
.await
|
|
170 |
+ |
.context("rsync failed (current symlink left intact)")?;
|
| 156 |
171 |
|
|
| 157 |
172 |
|
tracing::info!(node = %node.name, version, "deploy: symlink swap + service reload");
|
| 158 |
|
- |
// Symlink swap is atomic via `mv -T` of a freshly-created symlink over
|
| 159 |
|
- |
// the old one (the rename(2) is the atomic step; `ln -sfn` does
|
| 160 |
|
- |
// unlink+symlink which has a window).
|
|
173 |
+ |
// Symlink swap is atomic via `mv -T` of a freshly-created symlink over the
|
|
174 |
+ |
// old one (the rename(2) is the atomic step; `ln -sfn` does unlink+symlink
|
|
175 |
+ |
// which has a window).
|
| 161 |
176 |
|
let swap_and_restart = format!(
|
| 162 |
177 |
|
"set -e; \
|
| 163 |
178 |
|
cd {root}; \
|
| 168 |
183 |
|
ver = sh_quote(version),
|
| 169 |
184 |
|
svc = sh_quote(service),
|
| 170 |
185 |
|
);
|
| 171 |
|
- |
ssh(ssh_target, &swap_and_restart)
|
| 172 |
|
- |
.await
|
| 173 |
|
- |
.context("symlink swap + systemctl reload-or-restart")?;
|
|
186 |
+ |
run_checked(executor, &swap_and_restart, "symlink swap + systemctl reload-or-restart").await?;
|
| 174 |
187 |
|
|
| 175 |
|
- |
if let Err(e) = gc_remote_releases(ssh_target, release_root).await {
|
|
188 |
+ |
if let Err(e) = gc_remote_releases(executor, release_root).await {
|
| 176 |
189 |
|
tracing::warn!(error = %e, "remote release GC failed (non-fatal)");
|
| 177 |
190 |
|
}
|
| 178 |
191 |
|
|
| 179 |
192 |
|
Ok(PathBuf::from(release_root).join("releases").join(version))
|
| 180 |
193 |
|
}
|
| 181 |
194 |
|
|
| 182 |
|
- |
async fn ssh(target: &str, script: &str) -> Result<()> {
|
| 183 |
|
- |
let mut cmd = Command::new("ssh");
|
| 184 |
|
- |
cmd.args(SSH_FLAGS).arg(target).arg(script);
|
| 185 |
|
- |
let out = cmd.output().await.context("spawning ssh")?;
|
| 186 |
|
- |
anyhow::ensure!(
|
| 187 |
|
- |
out.status.success(),
|
| 188 |
|
- |
"ssh {target} failed: {}",
|
| 189 |
|
- |
String::from_utf8_lossy(&out.stderr),
|
| 190 |
|
- |
);
|
| 191 |
|
- |
Ok(())
|
| 192 |
|
- |
}
|
| 193 |
|
- |
|
| 194 |
195 |
|
async fn gc_local_releases(release_root: &Path) -> Result<()> {
|
| 195 |
196 |
|
let releases = release_root.join("releases");
|
| 196 |
197 |
|
if !releases.exists() {
|
| 216 |
217 |
|
Ok(())
|
| 217 |
218 |
|
}
|
| 218 |
219 |
|
|
| 219 |
|
- |
async fn gc_remote_releases(ssh_target: &str, release_root: &str) -> Result<()> {
|
|
220 |
+ |
async fn gc_remote_releases(executor: &dyn Executor, release_root: &str) -> Result<()> {
|
| 220 |
221 |
|
// `ls -t` orders by mtime desc. Skip the first N, rm the rest. `xargs -r`
|
| 221 |
222 |
|
// is a no-op when stdin is empty (avoids `rm` complaining).
|
| 222 |
223 |
|
let script = format!(
|
| 225 |
226 |
|
root = sh_quote(release_root),
|
| 226 |
227 |
|
keep_plus_one = RELEASES_TO_KEEP + 1,
|
| 227 |
228 |
|
);
|
| 228 |
|
- |
ssh(ssh_target, &script).await
|
| 229 |
|
- |
}
|
| 230 |
|
- |
|
| 231 |
|
- |
/// Single-quote a string for safe inclusion in a /bin/sh command, escaping
|
| 232 |
|
- |
/// any single quote inside. Not bulletproof for adversarial input, but every
|
| 233 |
|
- |
/// path here comes from our own config files.
|
| 234 |
|
- |
fn sh_quote(s: &str) -> String {
|
| 235 |
|
- |
let escaped = s.replace('\'', r"'\''");
|
| 236 |
|
- |
format!("'{escaped}'")
|
|
229 |
+ |
run_checked(executor, &script, "remote release gc").await.map(|_| ())
|
| 237 |
230 |
|
}
|
| 238 |
231 |
|
|
| 239 |
232 |
|
#[cfg(test)]
|
| 240 |
233 |
|
mod tests {
|
| 241 |
234 |
|
use super::*;
|
|
235 |
+ |
use ops_exec::{CapabilitySet, LocalExec, SshExec};
|
| 242 |
236 |
|
use std::time::SystemTime;
|
| 243 |
237 |
|
|
| 244 |
|
- |
#[test]
|
| 245 |
|
- |
fn sh_quote_no_quote() {
|
| 246 |
|
- |
assert_eq!(sh_quote("hello"), "'hello'");
|
| 247 |
|
- |
assert_eq!(sh_quote("/opt/mnw/releases/0.8.12"), "'/opt/mnw/releases/0.8.12'");
|
| 248 |
|
- |
}
|
| 249 |
|
- |
|
| 250 |
|
- |
#[test]
|
| 251 |
|
- |
fn sh_quote_with_quote() {
|
| 252 |
|
- |
// The string `it's` becomes `'it'\''s'` — close, escape, open.
|
| 253 |
|
- |
assert_eq!(sh_quote("it's"), r"'it'\''s'");
|
|
238 |
+ |
/// A LocalExec granted the default node capabilities (deploy + restart).
|
|
239 |
+ |
fn local_executor() -> LocalExec {
|
|
240 |
+ |
LocalExec::new(CapabilitySet::from_tokens(["deploy", "restart"], ["health"]))
|
| 254 |
241 |
|
}
|
| 255 |
242 |
|
|
| 256 |
243 |
|
#[tokio::test]
|
| 258 |
245 |
|
let tmp = tempfile::tempdir().unwrap();
|
| 259 |
246 |
|
let root = tmp.path();
|
| 260 |
247 |
|
|
| 261 |
|
- |
// Source binaries (worktree's target/release/)
|
| 262 |
248 |
|
let src_dir = root.join("src");
|
| 263 |
249 |
|
tokio::fs::create_dir_all(&src_dir).await.unwrap();
|
| 264 |
250 |
|
let primary = src_dir.join("makenotwork");
|
| 266 |
252 |
|
tokio::fs::write(&primary, b"PRIMARY").await.unwrap();
|
| 267 |
253 |
|
tokio::fs::write(&admin, b"ADMIN").await.unwrap();
|
| 268 |
254 |
|
|
| 269 |
|
- |
// Release root (where staged versions live)
|
| 270 |
255 |
|
let release_root = root.join("releases-root");
|
| 271 |
256 |
|
tokio::fs::create_dir_all(&release_root).await.unwrap();
|
| 272 |
257 |
|
|
| 282 |
267 |
|
assert_eq!(tokio::fs::read(staged.join("makenotwork")).await.unwrap(), b"PRIMARY");
|
| 283 |
268 |
|
assert_eq!(tokio::fs::read(staged.join("mnw-admin")).await.unwrap(), b"ADMIN");
|
| 284 |
269 |
|
|
| 285 |
|
- |
// current symlink should resolve to staged
|
| 286 |
270 |
|
let current = release_root.join("current");
|
| 287 |
271 |
|
let target = tokio::fs::read_link(¤t).await.unwrap();
|
| 288 |
272 |
|
assert_eq!(target.to_string_lossy(), "releases/0.8.12");
|
| 289 |
|
- |
// And reading through `current/` should give the new content.
|
| 290 |
273 |
|
let via_current = tokio::fs::read(current.join("makenotwork")).await.unwrap();
|
| 291 |
274 |
|
assert_eq!(via_current, b"PRIMARY");
|
| 292 |
275 |
|
}
|
| 304 |
287 |
|
tokio::fs::create_dir_all(&release_root).await.unwrap();
|
| 305 |
288 |
|
|
| 306 |
289 |
|
deploy_local(&release_root, &"0.1.0".parse().unwrap(), &[bin.clone()]).await.unwrap();
|
| 307 |
|
- |
// Rewrite source then deploy 0.2.0.
|
| 308 |
290 |
|
tokio::fs::write(&bin, b"V2").await.unwrap();
|
| 309 |
291 |
|
deploy_local(&release_root, &"0.2.0".parse().unwrap(), &[bin.clone()]).await.unwrap();
|
| 310 |
292 |
|
|
| 311 |
|
- |
// Both versions present on disk.
|
| 312 |
293 |
|
assert!(release_root.join("releases/0.1.0/server").exists());
|
| 313 |
294 |
|
assert!(release_root.join("releases/0.2.0/server").exists());
|
| 314 |
|
- |
// current points at the new one.
|
| 315 |
295 |
|
let target = tokio::fs::read_link(release_root.join("current")).await.unwrap();
|
| 316 |
296 |
|
assert_eq!(target.to_string_lossy(), "releases/0.2.0");
|
| 317 |
297 |
|
let via_current = tokio::fs::read(release_root.join("current/server")).await.unwrap();
|
| 320 |
300 |
|
|
| 321 |
301 |
|
#[tokio::test]
|
| 322 |
302 |
|
async fn gc_local_releases_keeps_last_n_by_mtime() {
|
| 323 |
|
- |
// Build > RELEASES_TO_KEEP fake release dirs with distinct mtimes,
|
| 324 |
|
- |
// then run gc and check which survived.
|
| 325 |
303 |
|
let tmp = tempfile::tempdir().unwrap();
|
| 326 |
304 |
|
let root = tmp.path();
|
| 327 |
305 |
|
let releases = root.join("releases");
|
| 333 |
311 |
|
let name = format!("v{i:02}");
|
| 334 |
312 |
|
let dir = releases.join(&name);
|
| 335 |
313 |
|
tokio::fs::create_dir(&dir).await.unwrap();
|
| 336 |
|
- |
// Stagger mtimes deterministically. tokio's File doesn't expose
|
| 337 |
|
- |
// set_times, so reach for std::fs::File + std::fs::FileTimes
|
| 338 |
|
- |
// (stable since 1.75). Synchronous is fine here — this is test
|
| 339 |
|
- |
// setup, not the hot path.
|
| 340 |
314 |
|
let f = std::fs::File::open(&dir).unwrap();
|
| 341 |
315 |
|
let when = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000 + i as u64);
|
| 342 |
316 |
|
let times = std::fs::FileTimes::new().set_modified(when);
|
| 346 |
320 |
|
|
| 347 |
321 |
|
gc_local_releases(root).await.unwrap();
|
| 348 |
322 |
|
|
| 349 |
|
- |
// The last RELEASES_TO_KEEP by mtime (i.e. highest i) survive.
|
| 350 |
323 |
|
let surviving_expected: Vec<_> = names
|
| 351 |
324 |
|
.iter()
|
| 352 |
325 |
|
.skip(total - RELEASES_TO_KEEP)
|
| 353 |
326 |
|
.cloned()
|
| 354 |
327 |
|
.collect();
|
| 355 |
328 |
|
for name in &surviving_expected {
|
| 356 |
|
- |
assert!(
|
| 357 |
|
- |
releases.join(name).exists(),
|
| 358 |
|
- |
"expected to survive: {name}"
|
| 359 |
|
- |
);
|
|
329 |
+ |
assert!(releases.join(name).exists(), "expected to survive: {name}");
|
| 360 |
330 |
|
}
|
| 361 |
331 |
|
for name in names.iter().take(total - RELEASES_TO_KEEP) {
|
| 362 |
|
- |
assert!(
|
| 363 |
|
- |
!releases.join(name).exists(),
|
| 364 |
|
- |
"expected to be pruned: {name}"
|
| 365 |
|
- |
);
|
|
332 |
+ |
assert!(!releases.join(name).exists(), "expected to be pruned: {name}");
|
| 366 |
333 |
|
}
|
| 367 |
334 |
|
}
|
| 368 |
335 |
|
|
| 401 |
368 |
|
ssh_target: "deploy@192.0.2.1".into(),
|
| 402 |
369 |
|
release_root: "/opt/never".into(),
|
| 403 |
370 |
|
service_name: "makenotwork.service".into(),
|
|
371 |
+ |
actuate: crate::topology::default_actuate(),
|
|
372 |
+ |
observe: crate::topology::default_observe(),
|
| 404 |
373 |
|
};
|
|
374 |
+ |
let executor = SshExec::new(
|
|
375 |
+ |
node.ssh_target.clone(),
|
|
376 |
+ |
CapabilitySet::from_tokens(["deploy", "restart"], ["health"]),
|
|
377 |
+ |
);
|
| 405 |
378 |
|
|
| 406 |
|
- |
let result = deploy_node(&node, "0.0.1", &staged, "server").await;
|
|
379 |
+ |
let result = deploy_node(&executor, &node, "0.0.1", &staged, "server").await;
|
| 407 |
380 |
|
let err = result.expect_err("deploy to unreachable host should fail");
|
| 408 |
381 |
|
let msg = format!("{err:#}");
|
| 409 |
|
- |
// The ssh helper returns `ssh <target> failed: ...`. Don't pin the
|
| 410 |
|
- |
// exact wording, just that the failure is attributed and that no
|
| 411 |
|
- |
// panic / hang happened.
|
|
382 |
+ |
// Don't pin exact wording, just that the failure is attributed (ssh /
|
|
383 |
+ |
// rsync / connection) and that no panic / hang happened.
|
| 412 |
384 |
|
assert!(
|
| 413 |
|
- |
msg.contains("ssh") || msg.contains("rsync") || msg.contains("connection"),
|
|
385 |
+ |
msg.contains("ssh")
|
|
386 |
+ |
|| msg.contains("rsync")
|
|
387 |
+ |
|| msg.contains("connection")
|
|
388 |
+ |
|| msg.contains("Connection"),
|
| 414 |
389 |
|
"unexpected error: {msg}"
|
| 415 |
390 |
|
);
|
| 416 |
391 |
|
}
|
| 417 |
392 |
|
|
| 418 |
393 |
|
#[tokio::test]
|
| 419 |
394 |
|
async fn deploy_node_with_local_ssh_target_swaps_symlink() {
|
| 420 |
|
- |
// ssh_target="local" should route to the local fast-path: just a
|
| 421 |
|
- |
// symlink swap, no remote calls. Helpful for dev loops.
|
|
395 |
+ |
// ssh_target="local" routes to the local fast-path: just a symlink
|
|
396 |
+ |
// swap, no remote calls.
|
| 422 |
397 |
|
let tmp = tempfile::tempdir().unwrap();
|
| 423 |
398 |
|
let release_root = tmp.path().to_path_buf();
|
| 424 |
399 |
|
let staged = release_root.join("releases").join("0.0.1");
|
| 430 |
405 |
|
ssh_target: "local".into(),
|
| 431 |
406 |
|
release_root: release_root.to_string_lossy().into_owned(),
|
| 432 |
407 |
|
service_name: "makenotwork.service".into(),
|
|
408 |
+ |
actuate: crate::topology::default_actuate(),
|
|
409 |
+ |
observe: crate::topology::default_observe(),
|
| 433 |
410 |
|
};
|
|
411 |
+ |
let executor = local_executor();
|
| 434 |
412 |
|
|
| 435 |
|
- |
let out = deploy_node(&node, "0.0.1", &staged, "server").await.unwrap();
|
|
413 |
+ |
let out = deploy_node(&executor, &node, "0.0.1", &staged, "server").await.unwrap();
|
| 436 |
414 |
|
assert_eq!(out, staged);
|
| 437 |
415 |
|
let target = tokio::fs::read_link(release_root.join("current")).await.unwrap();
|
| 438 |
416 |
|
assert_eq!(target.to_string_lossy(), "releases/0.0.1");
|
| 439 |
417 |
|
}
|
|
418 |
+ |
|
|
419 |
+ |
#[tokio::test]
|
|
420 |
+ |
async fn deploy_node_denied_when_executor_lacks_deploy_grant() {
|
|
421 |
+ |
// Defense in depth: an executor without the deploy grant refuses the
|
|
422 |
+ |
// step before any filesystem / ssh action.
|
|
423 |
+ |
let tmp = tempfile::tempdir().unwrap();
|
|
424 |
+ |
let release_root = tmp.path().to_path_buf();
|
|
425 |
+ |
let staged = release_root.join("releases").join("0.0.1");
|
|
426 |
+ |
tokio::fs::create_dir_all(&staged).await.unwrap();
|
|
427 |
+ |
|
|
428 |
+ |
let node = crate::topology::Node {
|
|
429 |
+ |
name: "local-dev".into(),
|
|
430 |
+ |
ssh_target: "local".into(),
|
|
431 |
+ |
release_root: release_root.to_string_lossy().into_owned(),
|
|
432 |
+ |
service_name: "makenotwork.service".into(),
|
|
433 |
+ |
actuate: vec!["restart".into()], // no deploy
|
|
434 |
+ |
observe: vec![],
|
|
435 |
+ |
};
|
|
436 |
+ |
let executor = LocalExec::new(CapabilitySet::from_tokens(["restart"], Vec::<&str>::new()));
|
|
437 |
+ |
let err = deploy_node(&executor, &node, "0.0.1", &staged, "server").await.unwrap_err();
|
|
438 |
+ |
assert!(format!("{err:#}").contains("capability denied"), "expected capability denial");
|
|
439 |
+ |
}
|
| 440 |
440 |
|
}
|