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 install_with(sb, service, spec, uses_secrets, secret_env, true)
252}
253
254pub fn install_with(
258 sb: &Sandbox,
259 service: &str,
260 spec: &SandboxSpec,
261 uses_secrets: bool,
262 secret_env: &BTreeMap<String, String>,
263 restart_on_env: bool,
264) -> Result<bool> {
265 let client = sb.client();
266 let name = sb.name();
267 if !root_exec(
268 sb,
269 &["test", "-d", "/run/systemd/system"],
270 Duration::from_secs(30),
271 )?
272 .success()
273 {
274 return Err(Error::invalid(format!(
275 "{name}: restart needs systemd in the guest to supervise command; this image has none (use an OCI image, or drop restart)"
276 )));
277 }
278 wait_for_systemd(sb, Duration::from_secs(120))?;
279 let shell = match (&spec.user, spec.exec.login) {
280 (Some(u), true) => crate::exec::resolve_user(client, name, u)?.shell,
281 (None, true) => crate::exec::resolve_user(client, name, "root")?.shell,
282 _ => None,
283 }
284 .filter(|s| !s.ends_with("nologin") && !s.ends_with("/false"));
285 let files = render(service, spec, shell.as_deref(), uses_secrets, secret_env)?;
286 let unit_path = format!("/etc/systemd/system/{}", unit_name(service));
287 let env_file = env_path(service);
288 let same = |path: &str, want: &str| -> Result<bool> {
289 Ok(client.read_file(name, path)?.as_deref() == Some(want.as_bytes()))
290 };
291 let unit_changed = !same(&unit_path, &files.unit)?;
292 let env_changed = !same(&env_file, &files.env)?;
293 let changed = unit_changed || env_changed;
294 let unit = unit_name(service);
295 if changed {
296 client.make_dir(name, "/etc/isb", 0, 0, 0o755)?;
297 client.push_file(name, &env_file, files.env.as_bytes(), 0, 0, 0o600)?;
298 client.push_file(name, &unit_path, files.unit.as_bytes(), 0, 0, 0o644)?;
299 }
300 let loaded = root_exec(
303 sb,
304 &[
305 "systemctl",
306 "show",
307 "--value",
308 "-p",
309 "NeedDaemonReload",
310 &unit,
311 ],
312 Duration::from_secs(30),
313 )?;
314 if changed || loaded.stdout_text().trim() != "no" {
315 check(
316 root_exec(sb, &["systemctl", "daemon-reload"], Duration::from_secs(60))?,
317 "systemctl daemon-reload",
318 )?;
319 }
320 check(
321 root_exec(
322 sb,
323 &["systemctl", "enable", "--quiet", &unit],
324 Duration::from_secs(60),
325 )?,
326 &format!("systemctl enable {unit}"),
327 )?;
328 let restart = unit_changed || (env_changed && restart_on_env);
330 let verb = if restart { "restart" } else { "start" };
331 check(
332 root_exec(
333 sb,
334 &["systemctl", verb, "--no-block", &unit],
335 Duration::from_secs(60),
336 )?,
337 &format!("systemctl {verb} {unit}"),
338 )?;
339 Ok(restart)
340}
341
342fn wait_for_systemd(sb: &Sandbox, deadline: Duration) -> Result<()> {
346 let started = Instant::now();
347 loop {
348 let out = root_exec(
349 sb,
350 &["systemctl", "is-system-running", "--wait"],
351 Duration::from_secs(60),
352 )?;
353 let state = out.stdout_text().trim().to_string();
354 if matches!(state.as_str(), "running" | "degraded" | "maintenance") {
355 return Ok(());
356 }
357 if started.elapsed() >= deadline {
358 return Err(Error::NotReady {
359 sandbox: sb.name().to_string(),
360 check: "systemd".into(),
361 detail: format!("{state} {}", out.stderr_text().trim()),
362 waited: started.elapsed(),
363 });
364 }
365 std::thread::sleep(Duration::from_millis(500));
366 }
367}
368
369pub fn uninstall(sb: &Sandbox, service: &str) -> Result<()> {
371 let unit = unit_name(service);
372 let _ = root_exec(
373 sb,
374 &["systemctl", "disable", "--now", "--quiet", &unit],
375 Duration::from_secs(60),
376 )?;
377 Ok(())
378}
379
380pub fn restart_app(sb: &Sandbox, service: &str, oci: bool) -> Result<()> {
382 if oci {
383 return sb.restart();
384 }
385 let unit = unit_name(service);
386 check(
387 root_exec(
388 sb,
389 &["systemctl", "restart", "--no-block", &unit],
390 Duration::from_secs(60),
391 )?,
392 &format!("systemctl restart {unit}"),
393 )
394 .map(|_| ())
395}
396
397pub fn unit_state(sb: &Sandbox, service: &str) -> Result<String> {
399 let out = root_exec(
400 sb,
401 &["systemctl", "is-active", &unit_name(service)],
402 Duration::from_secs(30),
403 )?;
404 Ok(out.stdout_text().trim().to_string())
405}
406
407pub fn push_secrets(
413 sb: &Sandbox,
414 spec: &SandboxSpec,
415 values: &BTreeMap<String, Vec<u8>>,
416) -> Result<bool> {
417 push_secrets_detailed(sb, spec, values).map(|p| p.missing || !p.changed.is_empty())
418}
419
420#[derive(Debug, Default)]
422pub struct Pushed {
423 pub missing: bool,
425 pub changed: std::collections::BTreeSet<String>,
427}
428
429pub fn push_secrets_detailed(
432 sb: &Sandbox,
433 spec: &SandboxSpec,
434 values: &BTreeMap<String, Vec<u8>>,
435) -> Result<Pushed> {
436 let mut pushed = Pushed::default();
437 if spec.secrets.is_empty() {
438 return Ok(pushed);
439 }
440 let client = sb.client();
441 let name = sb.name();
442 let (def_uid, def_gid) = numeric_user(spec.user.as_deref()).unwrap_or((0, 0));
443 make_dirs(client, name, "/var/lib/isb")?;
444 client.make_dir(name, SECRETS_STORE, 0, 0, 0o700)?;
445 let mut script =
446 String::from("#!/bin/sh\n# Written by isb: puts the secrets back after a boot.\nset -e\n");
447 for (n, s) in spec.secrets.iter().enumerate() {
448 let value = values
449 .get(&s.source)
450 .ok_or_else(|| Error::invalid(format!("{name}: no value for secret {:?}", s.source)))?;
451 let path = s.guest_path();
452 let parent = path.rsplit_once('/').map(|(p, _)| p).unwrap_or("/");
453 make_dirs(client, name, parent)?;
454 let mode = s.file_mode().map_err(Error::invalid)?;
455 let (uid, gid) = (s.uid.unwrap_or(def_uid), s.gid.or(s.uid).unwrap_or(def_gid));
456 match client.read_file(name, &path).ok().flatten() {
457 None => pushed.missing = true,
458 Some(v) if v != *value => {
459 pushed.changed.insert(s.source.clone());
460 }
461 Some(_) => {}
462 }
463 client.push_file(name, &path, value, uid, gid, mode)?;
464 let stored = format!("{SECRETS_STORE}/{n}");
465 client.push_file(name, &stored, value, 0, 0, 0o400)?;
466 script.push_str(&format!(
467 "install -D -m {mode:04o} -o {uid} -g {gid} {} {}\n",
468 sh_quote(&stored),
469 sh_quote(&path)
470 ));
471 }
472 client.push_file(name, SECRETS_RESTORE, script.as_bytes(), 0, 0, 0o700)?;
473 Ok(pushed)
474}
475
476pub fn set_oci_env(sb: &Sandbox, env: &BTreeMap<String, String>) -> Result<()> {
479 if env.is_empty() {
480 return Ok(());
481 }
482 let config: serde_json::Map<String, serde_json::Value> = env
483 .iter()
484 .map(|(k, v)| {
485 (
486 format!("environment.{k}"),
487 serde_json::Value::from(v.as_str()),
488 )
489 })
490 .collect();
491 sb.client().mutate(
492 "PATCH",
493 &format!(
494 "/1.0/instances/{}",
495 crate::client::encode_segment(sb.name())
496 ),
497 Some(&serde_json::json!({ "config": config })),
498 "set secret variables",
499 Duration::from_secs(60),
500 )?;
501 Ok(())
502}
503
504fn sh_quote(s: &str) -> String {
505 format!("'{}'", s.replace('\'', "'\\''"))
506}
507
508fn make_dirs(client: &Client, name: &str, path: &str) -> Result<()> {
509 let mut cur = String::new();
510 for part in path.split('/').filter(|p| !p.is_empty()) {
511 cur.push('/');
512 cur.push_str(part);
513 client.make_dir(name, &cur, 0, 0, 0o755)?;
514 }
515 Ok(())
516}
517
518fn numeric_user(user: Option<&str>) -> Option<(u32, u32)> {
520 let u = user?;
521 let (a, b) = u.split_once(':').unwrap_or((u, u));
522 Some((a.parse().ok()?, b.parse().ok()?))
523}
524
525pub fn resolve_secret_values(
530 file: &crate::spec::ComposeFile,
531 base: &std::path::Path,
532 lookup: &dyn Fn(&str) -> Option<String>,
533) -> Result<BTreeMap<String, Vec<u8>>> {
534 let used = crate::stack::secrets::used_keys(file);
535 let mut out = BTreeMap::new();
536 for (key, def) in &file.secrets {
537 if !used.contains(key) {
538 continue;
539 }
540 let v = if let Some(f) = &def.file {
541 let p = crate::plan::resolve_host_path(f, base)?;
542 std::fs::read(&p)
543 .map_err(|e| Error::invalid(format!("secret {key:?}: cannot read {p}: {e}")))?
544 } else if let Some(var) = &def.environment {
545 lookup(var)
546 .ok_or_else(|| {
547 Error::invalid(format!(
548 "secret {key:?}: environment variable {var} is not set"
549 ))
550 })?
551 .into_bytes()
552 } else {
553 continue;
554 };
555 out.insert(key.clone(), v);
556 }
557 Ok(out)
558}
559
560#[derive(Debug, Clone, PartialEq, serde::Serialize)]
562pub struct Probe {
563 pub ok: bool,
564 pub exit_code: Option<i32>,
566 pub output: String,
568 #[serde(skip)]
569 pub took: Duration,
570}
571
572pub fn probe(sb: &Sandbox, check: &crate::spec::HealthProbe) -> Probe {
574 let started = Instant::now();
575 let r = sb.exec_with(
576 check.argv.clone(),
577 ExecOptions::default().timeout(check.timeout),
578 );
579 let took = started.elapsed();
580 match r {
581 Ok(out) => {
582 let mut text = format!("{}{}", out.stdout_text(), out.stderr_text());
583 if text.len() > 512 {
584 text = text[text.len() - 512..].to_string();
585 }
586 Probe {
587 ok: out.success(),
588 exit_code: Some(out.exit_code),
589 output: text.trim().to_string(),
590 took,
591 }
592 }
593 Err(e) => Probe {
594 ok: false,
595 exit_code: None,
596 output: e.to_string(),
597 took,
598 },
599 }
600}
601
602pub fn logs(sb: &Sandbox, service: &str, oci: bool, lines: usize) -> Result<String> {
605 if oci {
606 let raw = console_log(sb.client(), sb.name())?;
607 let text = String::from_utf8_lossy(&raw);
608 let all: Vec<&str> = text.lines().collect();
609 return Ok(all[all.len().saturating_sub(lines)..].join("\n"));
610 }
611 let n = lines.to_string();
612 let out = root_exec(
613 sb,
614 &[
615 "journalctl",
616 "-u",
617 &unit_name(service),
618 "-n",
619 &n,
620 "-o",
621 "short-iso",
622 "--no-pager",
623 ],
624 Duration::from_secs(60),
625 )?;
626 Ok(check(out, "journalctl")?.stdout_text())
627}
628
629pub fn follow_argv(service: &str) -> Vec<String> {
631 ["journalctl", "-f", "-n", "0", "-o", "cat", "-u"]
632 .iter()
633 .map(|s| s.to_string())
634 .chain([unit_name(service)])
635 .collect()
636}
637
638pub fn console_log(client: &Client, name: &str) -> Result<Vec<u8>> {
640 client.console_log(name)
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646
647 fn spec(y: &str) -> SandboxSpec {
648 serde_yaml_ng::from_str(y).unwrap()
649 }
650
651 #[test]
652 fn renders_a_unit() {
653 let s = spec(
654 "image: x\nuser: dev\nworking_dir: /srv\nrestart: always\ncommand: bun run dev --port=$PORT\nenvironment: {A: 'x \"y\" $z'}\n",
655 );
656 let f = render("web", &s, None, false, &BTreeMap::new()).unwrap();
657 assert!(f.unit.contains("User=dev\n"), "{}", f.unit);
658 assert!(f.unit.contains("WorkingDirectory=/srv\n"));
659 assert!(f.unit.contains("Restart=always\n"));
660 assert!(f.unit.contains("RestartSec=5000ms\n"));
661 assert!(f.unit.contains("StartLimitIntervalSec=0\n"));
662 assert!(
663 f.unit.contains(
664 "ExecStart=\"/bin/sh\" \"-c\" \"exec \\\"$$@\\\"\" \"isb\" \"bun\" \"run\" \"dev\" \"--port=$$PORT\"\n"
665 ),
666 "{}",
667 f.unit
668 );
669 assert!(!f.unit.contains("ExecStartPre"));
670 assert_eq!(f.env, "# Written by isb.\nA=\"x \\\"y\\\" \\$z\"\n");
671 }
672
673 #[test]
674 fn absolute_command_runs_directly_and_policy_maps() {
675 let s = spec(
676 "image: x\nrestart: on-failure\ncommand: [/usr/bin/app, '50%']\ndeploy: {restart_policy: {delay: 2s, max_attempts: 3, window: 1m}}\n",
677 );
678 let f = render("api", &s, None, true, &BTreeMap::new()).unwrap();
679 assert!(
680 f.unit.contains("ExecStart=\"/usr/bin/app\" \"50%%\"\n"),
681 "{}",
682 f.unit
683 );
684 assert!(f.unit.contains("Restart=on-failure\n"));
685 assert!(f.unit.contains("RestartSec=2000ms\n"));
686 assert!(
687 f.unit
688 .contains("StartLimitIntervalSec=60\nStartLimitBurst=3\n")
689 );
690 assert!(
691 f.unit
692 .contains("ExecStartPre=+/bin/sh /var/lib/isb/secrets/restore\n")
693 );
694 assert!(!f.unit.contains("User="));
695 }
696
697 #[test]
698 fn login_shell_wraps() {
699 let s = spec("image: x\nrestart: always\ncommand: [bun, dev]\nexec: {login: true}\n");
700 assert_eq!(
701 effective_argv(&s, Some("/bin/bash")).unwrap(),
702 ["/bin/bash", "-l", "-c", "exec \"$@\"", "isb", "bun", "dev"]
703 );
704 assert!(
705 render(
706 "x",
707 &spec("image: x\nrestart: always\n"),
708 None,
709 false,
710 &BTreeMap::new()
711 )
712 .is_err()
713 );
714 }
715
716 #[test]
717 fn secret_variables_go_to_the_env_file_only() {
718 let s = spec(
719 "image: x\nrestart: always\ncommand: [/usr/bin/app]\nenvironment: {A: plain, TOKEN: {secret: tok}, DB: {secret: db}}\nexec: {env: {B: two}}\n",
720 );
721 let values = BTreeMap::from([
722 ("tok".to_string(), b"s3cr$t \"x\"".to_vec()),
723 ("db".to_string(), b"pw".to_vec()),
724 ]);
725 let env = secret_env(&s, &values).unwrap();
726 assert_eq!(env["TOKEN"], "s3cr$t \"x\"");
727 let f = render("app", &s, None, false, &env).unwrap();
728 assert_eq!(
729 f.env,
730 "# Written by isb.\nA=\"plain\"\nB=\"two\"\nDB=\"pw\"\nTOKEN=\"s3cr\\$t \\\"x\\\"\"\n"
731 );
732 assert!(!f.unit.contains("s3cr"), "{}", f.unit);
733 let bad = BTreeMap::from([
735 ("tok".to_string(), vec![0xff, 0x00]),
736 ("db".to_string(), b"pw".to_vec()),
737 ]);
738 let e = secret_env(&s, &bad).unwrap_err().to_string();
739 assert!(e.contains("not text") && e.contains("TOKEN"), "{e}");
740 let e = secret_env(&s, &BTreeMap::new()).unwrap_err().to_string();
741 assert!(e.contains("no value"), "{e}");
742 }
743
744 #[test]
745 fn numeric_users() {
746 assert_eq!(numeric_user(Some("1000")), Some((1000, 1000)));
747 assert_eq!(numeric_user(Some("1000:44")), Some((1000, 44)));
748 assert_eq!(numeric_user(Some("dev")), None);
749 }
750}