1use std::collections::BTreeMap;
17use std::time::{Duration, Instant};
18
19use crate::client::Client;
20use crate::error::{Error, Result};
21use crate::exec::{ExecOptions, ExecOutput};
22use crate::sandbox::Sandbox;
23use crate::spec::{RestartCondition, RestartMode, SandboxSpec};
24
25pub const SECRETS_STORE: &str = "/var/lib/isb/secrets";
27pub const SECRETS_RESTORE: &str = "/var/lib/isb/secrets/restore";
28
29pub fn unit_name(service: &str) -> String {
31 format!("isb-{}.service", crate::compose::sanitize_name(service))
32}
33
34fn env_path(service: &str) -> String {
35 format!("/etc/isb/{}.env", crate::compose::sanitize_name(service))
36}
37
38pub fn effective_argv(spec: &SandboxSpec, login_shell: Option<&str>) -> Option<Vec<String>> {
43 let argv = spec.command.clone().filter(|a| !a.is_empty())?;
44 let wrap = |shell: &str, login: bool| {
45 let mut v = vec![shell.to_string()];
46 if login {
47 v.push("-l".into());
48 }
49 v.extend(["-c".into(), "exec \"$@\"".into(), "isb".into()]);
50 v.extend(argv.clone());
51 v
52 };
53 Some(if spec.exec.login {
54 wrap(login_shell.unwrap_or("/bin/sh"), true)
55 } else if !argv[0].starts_with('/') {
56 wrap("/bin/sh", false)
57 } else {
58 argv
59 })
60}
61
62fn unit_word(s: &str) -> String {
65 let mut out = String::from("\"");
66 for c in s.chars() {
67 match c {
68 '\\' => out.push_str("\\\\"),
69 '"' => out.push_str("\\\""),
70 '$' => out.push_str("$$"),
71 '%' => out.push_str("%%"),
72 '\n' => out.push_str("\\n"),
73 c => out.push(c),
74 }
75 }
76 out.push('"');
77 out
78}
79
80fn env_line(k: &str, v: &str) -> String {
83 let mut out = format!("{k}=\"");
84 for c in v.chars() {
85 if matches!(c, '\\' | '"' | '`' | '$') {
86 out.push('\\');
87 }
88 out.push(c);
89 }
90 out.push_str("\"\n");
91 out
92}
93
94#[derive(Debug, Clone, PartialEq)]
96pub struct UnitFiles {
97 pub unit: String,
98 pub env: String,
99}
100
101pub fn secret_env(
104 spec: &SandboxSpec,
105 values: &BTreeMap<String, Vec<u8>>,
106) -> Result<BTreeMap<String, String>> {
107 let mut out = BTreeMap::new();
108 for (var, key) in &spec.env.secrets {
109 let v = values
110 .get(key)
111 .ok_or_else(|| Error::invalid(format!("no value for secret {key:?}")))?;
112 let text = String::from_utf8(v.clone())
113 .ok()
114 .filter(|t| !t.contains('\0'))
115 .ok_or_else(|| {
116 Error::invalid(format!(
117 "secret {key:?} cannot be the variable {var}: it is not text (NUL or invalid UTF-8); mount it as a file instead"
118 ))
119 })?;
120 out.insert(var.clone(), text);
121 }
122 Ok(out)
123}
124
125pub fn render(
129 service: &str,
130 spec: &SandboxSpec,
131 login_shell: Option<&str>,
132 uses_secrets: bool,
133 secret_env: &BTreeMap<String, String>,
134) -> Result<UnitFiles> {
135 let argv = effective_argv(spec, login_shell).ok_or_else(|| {
136 Error::invalid(format!("{service}: restart needs a command to supervise"))
137 })?;
138 let policy = spec.deploy.as_ref().and_then(|d| d.restart_policy.clone());
139 let restart = match (
140 spec.restart.unwrap_or_default(),
141 policy.as_ref().and_then(|p| p.condition),
142 ) {
143 (_, Some(RestartCondition::None)) => "no",
144 (_, Some(RestartCondition::OnFailure)) => "on-failure",
145 (_, Some(RestartCondition::Any)) => "always",
146 (RestartMode::OnFailure, None) => "on-failure",
147 (RestartMode::No, None) => "no",
148 _ => "always",
149 };
150 let delay = match policy.as_ref().and_then(|p| p.delay.as_deref()) {
151 Some(d) => crate::flex::parse_duration(d).map_err(Error::invalid)?,
152 None => Duration::from_secs(5),
153 };
154 let (burst, interval) = match policy.as_ref().and_then(|p| p.max_attempts) {
155 Some(n) => {
156 let window = match policy.as_ref().and_then(|p| p.window.as_deref()) {
157 Some(w) => crate::flex::parse_duration(w).map_err(Error::invalid)?,
158 None => Duration::from_secs(86400),
160 };
161 (Some(n.max(1)), window.as_secs().max(1))
162 }
163 None => (None, 0),
164 };
165
166 let mut u = String::new();
167 u.push_str("# Written by isb; overwritten on the next `isb up` or deploy.\n");
168 u.push_str(&format!("[Unit]\nDescription=isb service {service}\n"));
169 u.push_str("After=network-online.target\nWants=network-online.target\n");
170 u.push_str(&format!("StartLimitIntervalSec={interval}\n"));
171 if let Some(b) = burst {
172 u.push_str(&format!("StartLimitBurst={b}\n"));
173 }
174 u.push_str("\n[Service]\nType=simple\n");
175 if let Some(user) = &spec.user {
176 match user.split_once(':') {
177 Some((name, group)) => {
178 u.push_str(&format!("User={name}\nGroup={group}\n"));
179 }
180 None => u.push_str(&format!("User={user}\n")),
181 }
182 }
183 match &spec.working_dir {
184 Some(w) => u.push_str(&format!("WorkingDirectory={w}\n")),
185 None if spec.user.is_some() => u.push_str("WorkingDirectory=~\n"),
186 None => {}
187 }
188 u.push_str(&format!("EnvironmentFile={}\n", env_path(service)));
189 if uses_secrets {
190 u.push_str(&format!("ExecStartPre=+/bin/sh {SECRETS_RESTORE}\n"));
192 }
193 u.push_str("ExecStart=");
194 u.push_str(
195 &argv
196 .iter()
197 .map(|a| unit_word(a))
198 .collect::<Vec<_>>()
199 .join(" "),
200 );
201 u.push('\n');
202 u.push_str(&format!(
203 "Restart={restart}\nRestartSec={}ms\n",
204 delay.as_millis()
205 ));
206 u.push_str("KillMode=mixed\nTimeoutStopSec=10\n");
207 u.push_str("\n[Install]\nWantedBy=multi-user.target\n");
208
209 let mut env: BTreeMap<String, String> = spec.env.vars.clone();
211 env.extend(spec.exec.env.clone());
212 env.extend(secret_env.clone());
213 let mut e = String::from("# Written by isb.\n");
214 for (k, v) in &env {
215 e.push_str(&env_line(k, v));
216 }
217 Ok(UnitFiles { unit: u, env: e })
218}
219
220fn root_exec(sb: &Sandbox, argv: &[&str], timeout: Duration) -> Result<ExecOutput> {
221 sb.exec_with(
222 argv.iter().map(|s| s.to_string()),
223 ExecOptions::default()
224 .user("root")
225 .cwd("/")
226 .timeout(timeout),
227 )
228}
229
230fn check(out: ExecOutput, what: &str) -> Result<ExecOutput> {
231 if out.success() {
232 return Ok(out);
233 }
234 let detail = format!("{}{}", out.stdout_text(), out.stderr_text());
235 Err(Error::OperationFailed {
236 step: what.to_string(),
237 message: format!("exit {}: {}", out.exit_code, detail.trim()),
238 })
239}
240
241pub fn install(
245 sb: &Sandbox,
246 service: &str,
247 spec: &SandboxSpec,
248 uses_secrets: bool,
249 secret_env: &BTreeMap<String, String>,
250) -> Result<bool> {
251 let client = sb.client();
252 let name = sb.name();
253 if !root_exec(
254 sb,
255 &["test", "-d", "/run/systemd/system"],
256 Duration::from_secs(30),
257 )?
258 .success()
259 {
260 return Err(Error::invalid(format!(
261 "{name}: restart needs systemd in the guest to supervise command; this image has none (use an OCI image, or drop restart)"
262 )));
263 }
264 wait_for_systemd(sb, Duration::from_secs(120))?;
265 let shell = match (&spec.user, spec.exec.login) {
266 (Some(u), true) => crate::exec::resolve_user(client, name, u)?.shell,
267 (None, true) => crate::exec::resolve_user(client, name, "root")?.shell,
268 _ => None,
269 }
270 .filter(|s| !s.ends_with("nologin") && !s.ends_with("/false"));
271 let files = render(service, spec, shell.as_deref(), uses_secrets, secret_env)?;
272 let unit_path = format!("/etc/systemd/system/{}", unit_name(service));
273 let env_file = env_path(service);
274 let same = |path: &str, want: &str| -> Result<bool> {
275 Ok(client.read_file(name, path)?.as_deref() == Some(want.as_bytes()))
276 };
277 let changed = !same(&unit_path, &files.unit)? || !same(&env_file, &files.env)?;
278 let unit = unit_name(service);
279 if changed {
280 client.make_dir(name, "/etc/isb", 0, 0, 0o755)?;
281 client.push_file(name, &env_file, files.env.as_bytes(), 0, 0, 0o600)?;
282 client.push_file(name, &unit_path, files.unit.as_bytes(), 0, 0, 0o644)?;
283 }
284 let loaded = root_exec(
287 sb,
288 &[
289 "systemctl",
290 "show",
291 "--value",
292 "-p",
293 "NeedDaemonReload",
294 &unit,
295 ],
296 Duration::from_secs(30),
297 )?;
298 if changed || loaded.stdout_text().trim() != "no" {
299 check(
300 root_exec(sb, &["systemctl", "daemon-reload"], Duration::from_secs(60))?,
301 "systemctl daemon-reload",
302 )?;
303 }
304 check(
305 root_exec(
306 sb,
307 &["systemctl", "enable", "--quiet", &unit],
308 Duration::from_secs(60),
309 )?,
310 &format!("systemctl enable {unit}"),
311 )?;
312 let verb = if changed { "restart" } else { "start" };
314 check(
315 root_exec(
316 sb,
317 &["systemctl", verb, "--no-block", &unit],
318 Duration::from_secs(60),
319 )?,
320 &format!("systemctl {verb} {unit}"),
321 )?;
322 Ok(changed)
323}
324
325fn wait_for_systemd(sb: &Sandbox, deadline: Duration) -> Result<()> {
329 let started = Instant::now();
330 loop {
331 let out = root_exec(
332 sb,
333 &["systemctl", "is-system-running", "--wait"],
334 Duration::from_secs(60),
335 )?;
336 let state = out.stdout_text().trim().to_string();
337 if matches!(state.as_str(), "running" | "degraded" | "maintenance") {
338 return Ok(());
339 }
340 if started.elapsed() >= deadline {
341 return Err(Error::NotReady {
342 sandbox: sb.name().to_string(),
343 check: "systemd".into(),
344 detail: format!("{state} {}", out.stderr_text().trim()),
345 waited: started.elapsed(),
346 });
347 }
348 std::thread::sleep(Duration::from_millis(500));
349 }
350}
351
352pub fn uninstall(sb: &Sandbox, service: &str) -> Result<()> {
354 let unit = unit_name(service);
355 let _ = root_exec(
356 sb,
357 &["systemctl", "disable", "--now", "--quiet", &unit],
358 Duration::from_secs(60),
359 )?;
360 Ok(())
361}
362
363pub fn restart_app(sb: &Sandbox, service: &str, oci: bool) -> Result<()> {
365 if oci {
366 return sb.restart();
367 }
368 let unit = unit_name(service);
369 check(
370 root_exec(
371 sb,
372 &["systemctl", "restart", "--no-block", &unit],
373 Duration::from_secs(60),
374 )?,
375 &format!("systemctl restart {unit}"),
376 )
377 .map(|_| ())
378}
379
380pub fn unit_state(sb: &Sandbox, service: &str) -> Result<String> {
382 let out = root_exec(
383 sb,
384 &["systemctl", "is-active", &unit_name(service)],
385 Duration::from_secs(30),
386 )?;
387 Ok(out.stdout_text().trim().to_string())
388}
389
390pub fn push_secrets(
396 sb: &Sandbox,
397 spec: &SandboxSpec,
398 values: &BTreeMap<String, Vec<u8>>,
399) -> Result<bool> {
400 if spec.secrets.is_empty() {
401 return Ok(false);
402 }
403 let mut changed = false;
404 let client = sb.client();
405 let name = sb.name();
406 let (def_uid, def_gid) = numeric_user(spec.user.as_deref()).unwrap_or((0, 0));
407 make_dirs(client, name, "/var/lib/isb")?;
408 client.make_dir(name, SECRETS_STORE, 0, 0, 0o700)?;
409 let mut script =
410 String::from("#!/bin/sh\n# Written by isb: puts the secrets back after a boot.\nset -e\n");
411 for (n, s) in spec.secrets.iter().enumerate() {
412 let value = values
413 .get(&s.source)
414 .ok_or_else(|| Error::invalid(format!("{name}: no value for secret {:?}", s.source)))?;
415 let path = s.guest_path();
416 let parent = path.rsplit_once('/').map(|(p, _)| p).unwrap_or("/");
417 make_dirs(client, name, parent)?;
418 let mode = s.file_mode().map_err(Error::invalid)?;
419 let (uid, gid) = (s.uid.unwrap_or(def_uid), s.gid.or(s.uid).unwrap_or(def_gid));
420 changed |=
421 client.read_file(name, &path).ok().flatten().as_deref() != Some(value.as_slice());
422 client.push_file(name, &path, value, uid, gid, mode)?;
423 let stored = format!("{SECRETS_STORE}/{n}");
424 client.push_file(name, &stored, value, 0, 0, 0o400)?;
425 script.push_str(&format!(
426 "install -D -m {mode:04o} -o {uid} -g {gid} {} {}\n",
427 sh_quote(&stored),
428 sh_quote(&path)
429 ));
430 }
431 client.push_file(name, SECRETS_RESTORE, script.as_bytes(), 0, 0, 0o700)?;
432 Ok(changed)
433}
434
435fn sh_quote(s: &str) -> String {
436 format!("'{}'", s.replace('\'', "'\\''"))
437}
438
439fn make_dirs(client: &Client, name: &str, path: &str) -> Result<()> {
440 let mut cur = String::new();
441 for part in path.split('/').filter(|p| !p.is_empty()) {
442 cur.push('/');
443 cur.push_str(part);
444 client.make_dir(name, &cur, 0, 0, 0o755)?;
445 }
446 Ok(())
447}
448
449fn numeric_user(user: Option<&str>) -> Option<(u32, u32)> {
451 let u = user?;
452 let (a, b) = u.split_once(':').unwrap_or((u, u));
453 Some((a.parse().ok()?, b.parse().ok()?))
454}
455
456pub fn resolve_secret_values(
461 file: &crate::spec::ComposeFile,
462 base: &std::path::Path,
463 lookup: &dyn Fn(&str) -> Option<String>,
464) -> Result<BTreeMap<String, Vec<u8>>> {
465 let used = crate::stack::secrets::used_keys(file);
466 let mut out = BTreeMap::new();
467 for (key, def) in &file.secrets {
468 if !used.contains(key) {
469 continue;
470 }
471 let v = if let Some(f) = &def.file {
472 let p = crate::plan::resolve_host_path(f, base)?;
473 std::fs::read(&p)
474 .map_err(|e| Error::invalid(format!("secret {key:?}: cannot read {p}: {e}")))?
475 } else if let Some(var) = &def.environment {
476 lookup(var)
477 .ok_or_else(|| {
478 Error::invalid(format!(
479 "secret {key:?}: environment variable {var} is not set"
480 ))
481 })?
482 .into_bytes()
483 } else {
484 continue;
485 };
486 out.insert(key.clone(), v);
487 }
488 Ok(out)
489}
490
491#[derive(Debug, Clone, PartialEq, serde::Serialize)]
493pub struct Probe {
494 pub ok: bool,
495 pub exit_code: Option<i32>,
497 pub output: String,
499 #[serde(skip)]
500 pub took: Duration,
501}
502
503pub fn probe(sb: &Sandbox, check: &crate::spec::HealthProbe) -> Probe {
505 let started = Instant::now();
506 let r = sb.exec_with(
507 check.argv.clone(),
508 ExecOptions::default().timeout(check.timeout),
509 );
510 let took = started.elapsed();
511 match r {
512 Ok(out) => {
513 let mut text = format!("{}{}", out.stdout_text(), out.stderr_text());
514 if text.len() > 512 {
515 text = text[text.len() - 512..].to_string();
516 }
517 Probe {
518 ok: out.success(),
519 exit_code: Some(out.exit_code),
520 output: text.trim().to_string(),
521 took,
522 }
523 }
524 Err(e) => Probe {
525 ok: false,
526 exit_code: None,
527 output: e.to_string(),
528 took,
529 },
530 }
531}
532
533pub fn logs(sb: &Sandbox, service: &str, oci: bool, lines: usize) -> Result<String> {
536 if oci {
537 let raw = console_log(sb.client(), sb.name())?;
538 let text = String::from_utf8_lossy(&raw);
539 let all: Vec<&str> = text.lines().collect();
540 return Ok(all[all.len().saturating_sub(lines)..].join("\n"));
541 }
542 let n = lines.to_string();
543 let out = root_exec(
544 sb,
545 &[
546 "journalctl",
547 "-u",
548 &unit_name(service),
549 "-n",
550 &n,
551 "-o",
552 "short-iso",
553 "--no-pager",
554 ],
555 Duration::from_secs(60),
556 )?;
557 Ok(check(out, "journalctl")?.stdout_text())
558}
559
560pub fn follow_argv(service: &str) -> Vec<String> {
562 ["journalctl", "-f", "-n", "0", "-o", "cat", "-u"]
563 .iter()
564 .map(|s| s.to_string())
565 .chain([unit_name(service)])
566 .collect()
567}
568
569pub fn console_log(client: &Client, name: &str) -> Result<Vec<u8>> {
571 client.console_log(name)
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577
578 fn spec(y: &str) -> SandboxSpec {
579 serde_yaml_ng::from_str(y).unwrap()
580 }
581
582 #[test]
583 fn renders_a_unit() {
584 let s = spec(
585 "image: x\nuser: dev\nworking_dir: /srv\nrestart: always\ncommand: bun run dev --port=$PORT\nenvironment: {A: 'x \"y\" $z'}\n",
586 );
587 let f = render("web", &s, None, false, &BTreeMap::new()).unwrap();
588 assert!(f.unit.contains("User=dev\n"), "{}", f.unit);
589 assert!(f.unit.contains("WorkingDirectory=/srv\n"));
590 assert!(f.unit.contains("Restart=always\n"));
591 assert!(f.unit.contains("RestartSec=5000ms\n"));
592 assert!(f.unit.contains("StartLimitIntervalSec=0\n"));
593 assert!(
594 f.unit.contains(
595 "ExecStart=\"/bin/sh\" \"-c\" \"exec \\\"$$@\\\"\" \"isb\" \"bun\" \"run\" \"dev\" \"--port=$$PORT\"\n"
596 ),
597 "{}",
598 f.unit
599 );
600 assert!(!f.unit.contains("ExecStartPre"));
601 assert_eq!(f.env, "# Written by isb.\nA=\"x \\\"y\\\" \\$z\"\n");
602 }
603
604 #[test]
605 fn absolute_command_runs_directly_and_policy_maps() {
606 let s = spec(
607 "image: x\nrestart: on-failure\ncommand: [/usr/bin/app, '50%']\ndeploy: {restart_policy: {delay: 2s, max_attempts: 3, window: 1m}}\n",
608 );
609 let f = render("api", &s, None, true, &BTreeMap::new()).unwrap();
610 assert!(
611 f.unit.contains("ExecStart=\"/usr/bin/app\" \"50%%\"\n"),
612 "{}",
613 f.unit
614 );
615 assert!(f.unit.contains("Restart=on-failure\n"));
616 assert!(f.unit.contains("RestartSec=2000ms\n"));
617 assert!(
618 f.unit
619 .contains("StartLimitIntervalSec=60\nStartLimitBurst=3\n")
620 );
621 assert!(
622 f.unit
623 .contains("ExecStartPre=+/bin/sh /var/lib/isb/secrets/restore\n")
624 );
625 assert!(!f.unit.contains("User="));
626 }
627
628 #[test]
629 fn login_shell_wraps() {
630 let s = spec("image: x\nrestart: always\ncommand: [bun, dev]\nexec: {login: true}\n");
631 assert_eq!(
632 effective_argv(&s, Some("/bin/bash")).unwrap(),
633 ["/bin/bash", "-l", "-c", "exec \"$@\"", "isb", "bun", "dev"]
634 );
635 assert!(
636 render(
637 "x",
638 &spec("image: x\nrestart: always\n"),
639 None,
640 false,
641 &BTreeMap::new()
642 )
643 .is_err()
644 );
645 }
646
647 #[test]
648 fn secret_variables_go_to_the_env_file_only() {
649 let s = spec(
650 "image: x\nrestart: always\ncommand: [/usr/bin/app]\nenvironment: {A: plain, TOKEN: {secret: tok}, DB: {secret: db}}\nexec: {env: {B: two}}\n",
651 );
652 let values = BTreeMap::from([
653 ("tok".to_string(), b"s3cr$t \"x\"".to_vec()),
654 ("db".to_string(), b"pw".to_vec()),
655 ]);
656 let env = secret_env(&s, &values).unwrap();
657 assert_eq!(env["TOKEN"], "s3cr$t \"x\"");
658 let f = render("app", &s, None, false, &env).unwrap();
659 assert_eq!(
660 f.env,
661 "# Written by isb.\nA=\"plain\"\nB=\"two\"\nDB=\"pw\"\nTOKEN=\"s3cr\\$t \\\"x\\\"\"\n"
662 );
663 assert!(!f.unit.contains("s3cr"), "{}", f.unit);
664 let bad = BTreeMap::from([
666 ("tok".to_string(), vec![0xff, 0x00]),
667 ("db".to_string(), b"pw".to_vec()),
668 ]);
669 let e = secret_env(&s, &bad).unwrap_err().to_string();
670 assert!(e.contains("not text") && e.contains("TOKEN"), "{e}");
671 let e = secret_env(&s, &BTreeMap::new()).unwrap_err().to_string();
672 assert!(e.contains("no value"), "{e}");
673 }
674
675 #[test]
676 fn numeric_users() {
677 assert_eq!(numeric_user(Some("1000")), Some((1000, 1000)));
678 assert_eq!(numeric_user(Some("1000:44")), Some((1000, 44)));
679 assert_eq!(numeric_user(Some("dev")), None);
680 }
681}