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 push_secret_files(sb.client(), sb.name(), spec, values)
437}
438
439pub fn push_secret_files(
443 client: &Client,
444 name: &str,
445 spec: &SandboxSpec,
446 values: &BTreeMap<String, Vec<u8>>,
447) -> Result<Pushed> {
448 let mut pushed = Pushed::default();
449 if !spec.has_secret_files() {
450 return Ok(pushed);
451 }
452 let (def_uid, def_gid) = numeric_user(spec.user.as_deref()).unwrap_or((0, 0));
453 let refs = file_refs(client, name, spec)?;
454 make_dirs(client, name, "/var/lib/isb")?;
455 client.make_dir(name, SECRETS_STORE, 0, 0, 0o700)?;
456 let mut script =
457 String::from("#!/bin/sh\n# Written by isb: puts the secrets back after a boot.\nset -e\n");
458 for (n, s) in refs.iter().enumerate() {
459 let value = values
460 .get(&s.source)
461 .ok_or_else(|| Error::invalid(format!("{name}: no value for secret {:?}", s.source)))?;
462 let path = s.guest_path();
463 let parent = path.rsplit_once('/').map(|(p, _)| p).unwrap_or("/");
464 make_dirs(client, name, parent)?;
465 let mode = s.file_mode().map_err(Error::invalid)?;
466 let (uid, gid) = (s.uid.unwrap_or(def_uid), s.gid.or(s.uid).unwrap_or(def_gid));
467 match client.read_file(name, &path).ok().flatten() {
468 None => pushed.missing = true,
469 Some(v) if v != *value => {
470 pushed.changed.insert(s.source.clone());
471 }
472 Some(_) => {}
473 }
474 client.push_file(name, &path, value, uid, gid, mode)?;
475 let stored = format!("{SECRETS_STORE}/{n}");
476 client.push_file(name, &stored, value, 0, 0, 0o400)?;
477 script.push_str(&format!(
478 "install -D -m {mode:04o} -o {uid} -g {gid} {} {}\n",
479 sh_quote(&stored),
480 sh_quote(&path)
481 ));
482 }
483 client.push_file(name, SECRETS_RESTORE, script.as_bytes(), 0, 0, 0o700)?;
484 Ok(pushed)
485}
486
487fn file_refs(
493 client: &Client,
494 name: &str,
495 spec: &SandboxSpec,
496) -> Result<Vec<crate::spec::SecretRef>> {
497 let mut refs = spec.secrets.clone();
498 if spec.env.files.is_empty() {
499 return Ok(refs);
500 }
501 let (uid, gid) = match numeric_user(spec.user.as_deref()) {
502 Some(ids) => ids,
503 None => oci_ids(client, name)?,
504 };
505 let taken: std::collections::BTreeSet<String> = refs.iter().map(|r| r.guest_path()).collect();
506 let keys: std::collections::BTreeSet<&String> = spec.env.files.values().collect();
507 for key in keys {
508 let r = crate::spec::SecretRef {
509 source: key.clone(),
510 uid: Some(uid),
511 gid: Some(gid),
512 mode: Some("0400".into()),
513 ..Default::default()
514 };
515 if !taken.contains(&r.guest_path()) {
516 refs.push(r);
517 }
518 }
519 Ok(refs)
520}
521
522fn oci_ids(client: &Client, name: &str) -> Result<(u32, u32)> {
525 let inst = client.get(&format!(
526 "/1.0/instances/{}",
527 crate::client::encode_segment(name)
528 ))?;
529 let id = |k: &str| {
530 inst.pointer(&format!("/config/{}", k.replace('/', "~1")))
531 .and_then(serde_json::Value::as_str)
532 .and_then(|v| v.trim().parse().ok())
533 .unwrap_or(0)
534 };
535 Ok((id("oci.uid"), id("oci.gid")))
536}
537
538pub fn set_oci_env(sb: &Sandbox, env: &BTreeMap<String, String>) -> Result<()> {
541 if env.is_empty() {
542 return Ok(());
543 }
544 let config: serde_json::Map<String, serde_json::Value> = env
545 .iter()
546 .map(|(k, v)| {
547 (
548 format!("environment.{k}"),
549 serde_json::Value::from(v.as_str()),
550 )
551 })
552 .collect();
553 sb.client().mutate(
554 "PATCH",
555 &format!(
556 "/1.0/instances/{}",
557 crate::client::encode_segment(sb.name())
558 ),
559 Some(&serde_json::json!({ "config": config })),
560 "set secret variables",
561 Duration::from_secs(60),
562 )?;
563 Ok(())
564}
565
566fn sh_quote(s: &str) -> String {
567 format!("'{}'", s.replace('\'', "'\\''"))
568}
569
570fn make_dirs(client: &Client, name: &str, path: &str) -> Result<()> {
571 let mut cur = String::new();
572 for part in path.split('/').filter(|p| !p.is_empty()) {
573 cur.push('/');
574 cur.push_str(part);
575 client.make_dir(name, &cur, 0, 0, 0o755)?;
576 }
577 Ok(())
578}
579
580fn numeric_user(user: Option<&str>) -> Option<(u32, u32)> {
582 let u = user?;
583 let (a, b) = u.split_once(':').unwrap_or((u, u));
584 Some((a.parse().ok()?, b.parse().ok()?))
585}
586
587pub fn resolve_secret_values(
592 file: &crate::spec::ComposeFile,
593 base: &std::path::Path,
594 lookup: &dyn Fn(&str) -> Option<String>,
595) -> Result<BTreeMap<String, Vec<u8>>> {
596 let used = crate::stack::secrets::used_keys(file);
597 let mut out = BTreeMap::new();
598 for (key, def) in &file.secrets {
599 if !used.contains(key) {
600 continue;
601 }
602 let v = if let Some(f) = &def.file {
603 let p = crate::plan::resolve_host_path(f, base)?;
604 std::fs::read(&p)
605 .map_err(|e| Error::invalid(format!("secret {key:?}: cannot read {p}: {e}")))?
606 } else if let Some(var) = &def.environment {
607 lookup(var)
608 .ok_or_else(|| {
609 Error::invalid(format!(
610 "secret {key:?}: environment variable {var} is not set"
611 ))
612 })?
613 .into_bytes()
614 } else {
615 continue;
616 };
617 out.insert(key.clone(), v);
618 }
619 Ok(out)
620}
621
622#[derive(Debug, Clone, PartialEq, serde::Serialize)]
624pub struct Probe {
625 pub ok: bool,
626 pub exit_code: Option<i32>,
628 pub output: String,
630 #[serde(skip)]
631 pub took: Duration,
632}
633
634pub fn probe(sb: &Sandbox, check: &crate::spec::HealthProbe) -> Probe {
636 let started = Instant::now();
637 let r = sb.exec_with(
638 check.argv.clone(),
639 ExecOptions::default().timeout(check.timeout),
640 );
641 let took = started.elapsed();
642 match r {
643 Ok(out) => {
644 let mut text = format!("{}{}", out.stdout_text(), out.stderr_text());
645 if text.len() > 512 {
646 text = text[text.len() - 512..].to_string();
647 }
648 Probe {
649 ok: out.success(),
650 exit_code: Some(out.exit_code),
651 output: text.trim().to_string(),
652 took,
653 }
654 }
655 Err(e) => Probe {
656 ok: false,
657 exit_code: None,
658 output: e.to_string(),
659 took,
660 },
661 }
662}
663
664pub fn logs(sb: &Sandbox, service: &str, oci: bool, lines: usize) -> Result<String> {
667 if oci {
668 let raw = console_log(sb.client(), sb.name())?;
669 let text = String::from_utf8_lossy(&raw);
670 let all: Vec<&str> = text.lines().collect();
671 return Ok(all[all.len().saturating_sub(lines)..].join("\n"));
672 }
673 let n = lines.to_string();
674 let out = root_exec(
675 sb,
676 &[
677 "journalctl",
678 "-u",
679 &unit_name(service),
680 "-n",
681 &n,
682 "-o",
683 "short-iso",
684 "--no-pager",
685 ],
686 Duration::from_secs(60),
687 )?;
688 Ok(check(out, "journalctl")?.stdout_text())
689}
690
691pub fn follow_argv(service: &str) -> Vec<String> {
693 ["journalctl", "-f", "-n", "0", "-o", "cat", "-u"]
694 .iter()
695 .map(|s| s.to_string())
696 .chain([unit_name(service)])
697 .collect()
698}
699
700pub fn console_log(client: &Client, name: &str) -> Result<Vec<u8>> {
702 client.console_log(name)
703}
704
705pub fn supervised(sb: &Sandbox) -> Result<Vec<String>> {
709 let out = root_exec(
710 sb,
711 &[
712 "find",
713 "/etc/systemd/system",
714 "-maxdepth",
715 "1",
716 "-name",
717 "isb-*.service",
718 ],
719 Duration::from_secs(30),
720 )?;
721 Ok(services_of(&check(out, "find")?.stdout_text()))
722}
723
724fn services_of(listing: &str) -> Vec<String> {
725 let mut out: Vec<String> = listing
726 .lines()
727 .filter_map(|l| l.trim().rsplit('/').next())
728 .filter_map(|f| f.strip_prefix("isb-")?.strip_suffix(".service"))
729 .filter(|s| !s.is_empty())
730 .map(String::from)
731 .collect();
732 out.sort();
733 out.dedup();
734 out
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740
741 fn spec(y: &str) -> SandboxSpec {
742 serde_yaml_ng::from_str(y).unwrap()
743 }
744
745 #[test]
746 fn supervised_services_are_read_back_from_their_unit_files() {
747 let ls = "/etc/systemd/system/isb-web.service\n/etc/systemd/system/isb-api-v2.service\n\n/etc/systemd/system/isb-.service\n";
748 assert_eq!(services_of(ls), ["api-v2", "web"]);
749 assert_eq!(unit_name("api-v2"), "isb-api-v2.service");
751 assert!(services_of("").is_empty());
752 }
753
754 #[test]
755 fn renders_a_unit() {
756 let s = spec(
757 "image: x\nuser: dev\nworking_dir: /srv\nrestart: always\ncommand: bun run dev --port=$PORT\nenvironment: {A: 'x \"y\" $z'}\n",
758 );
759 let f = render("web", &s, None, false, &BTreeMap::new()).unwrap();
760 assert!(f.unit.contains("User=dev\n"), "{}", f.unit);
761 assert!(f.unit.contains("WorkingDirectory=/srv\n"));
762 assert!(f.unit.contains("Restart=always\n"));
763 assert!(f.unit.contains("RestartSec=5000ms\n"));
764 assert!(f.unit.contains("StartLimitIntervalSec=0\n"));
765 assert!(
766 f.unit.contains(
767 "ExecStart=\"/bin/sh\" \"-c\" \"exec \\\"$$@\\\"\" \"isb\" \"bun\" \"run\" \"dev\" \"--port=$$PORT\"\n"
768 ),
769 "{}",
770 f.unit
771 );
772 assert!(!f.unit.contains("ExecStartPre"));
773 assert_eq!(f.env, "# Written by isb.\nA=\"x \\\"y\\\" \\$z\"\n");
774 }
775
776 #[test]
777 fn absolute_command_runs_directly_and_policy_maps() {
778 let s = spec(
779 "image: x\nrestart: on-failure\ncommand: [/usr/bin/app, '50%']\ndeploy: {restart_policy: {delay: 2s, max_attempts: 3, window: 1m}}\n",
780 );
781 let f = render("api", &s, None, true, &BTreeMap::new()).unwrap();
782 assert!(
783 f.unit.contains("ExecStart=\"/usr/bin/app\" \"50%%\"\n"),
784 "{}",
785 f.unit
786 );
787 assert!(f.unit.contains("Restart=on-failure\n"));
788 assert!(f.unit.contains("RestartSec=2000ms\n"));
789 assert!(
790 f.unit
791 .contains("StartLimitIntervalSec=60\nStartLimitBurst=3\n")
792 );
793 assert!(
794 f.unit
795 .contains("ExecStartPre=+/bin/sh /var/lib/isb/secrets/restore\n")
796 );
797 assert!(!f.unit.contains("User="));
798 }
799
800 #[test]
801 fn login_shell_wraps() {
802 let s = spec("image: x\nrestart: always\ncommand: [bun, dev]\nexec: {login: true}\n");
803 assert_eq!(
804 effective_argv(&s, Some("/bin/bash")).unwrap(),
805 ["/bin/bash", "-l", "-c", "exec \"$@\"", "isb", "bun", "dev"]
806 );
807 assert!(
808 render(
809 "x",
810 &spec("image: x\nrestart: always\n"),
811 None,
812 false,
813 &BTreeMap::new()
814 )
815 .is_err()
816 );
817 }
818
819 #[test]
820 fn secret_variables_go_to_the_env_file_only() {
821 let s = spec(
822 "image: x\nrestart: always\ncommand: [/usr/bin/app]\nenvironment: {A: plain, TOKEN: {secret: tok}, DB: {secret: db}}\nexec: {env: {B: two}}\n",
823 );
824 let values = BTreeMap::from([
825 ("tok".to_string(), b"s3cr$t \"x\"".to_vec()),
826 ("db".to_string(), b"pw".to_vec()),
827 ]);
828 let env = secret_env(&s, &values).unwrap();
829 assert_eq!(env["TOKEN"], "s3cr$t \"x\"");
830 let f = render("app", &s, None, false, &env).unwrap();
831 assert_eq!(
832 f.env,
833 "# Written by isb.\nA=\"plain\"\nB=\"two\"\nDB=\"pw\"\nTOKEN=\"s3cr\\$t \\\"x\\\"\"\n"
834 );
835 assert!(!f.unit.contains("s3cr"), "{}", f.unit);
836 let bad = BTreeMap::from([
838 ("tok".to_string(), vec![0xff, 0x00]),
839 ("db".to_string(), b"pw".to_vec()),
840 ]);
841 let e = secret_env(&s, &bad).unwrap_err().to_string();
842 assert!(e.contains("not text") && e.contains("TOKEN"), "{e}");
843 let e = secret_env(&s, &BTreeMap::new()).unwrap_err().to_string();
844 assert!(e.contains("no value"), "{e}");
845 }
846
847 #[test]
848 fn numeric_users() {
849 assert_eq!(numeric_user(Some("1000")), Some((1000, 1000)));
850 assert_eq!(numeric_user(Some("1000:44")), Some((1000, 44)));
851 assert_eq!(numeric_user(Some("dev")), None);
852 }
853}