1mod changes;
21pub mod controller;
22pub mod failure;
23pub mod migrate;
24mod ports;
25pub mod secrets;
26
27use std::collections::BTreeMap;
28use std::path::{Path, PathBuf};
29
30use serde::{Deserialize, Serialize};
31
32use crate::error::{Error, Result};
33use crate::org::OrgId;
34use crate::spec::{ComposeFile, OnChange, SandboxSpec};
35
36pub use controller::Controller;
37pub use secrets::SecretBinding;
38
39pub const LABEL_STACK: &str = "isb.stack";
41pub const LABEL_SERVICE: &str = "isb.service";
42pub const LABEL_SLOT: &str = "isb.slot";
43pub const LABEL_REV: &str = "isb.rev";
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct StackDef {
48 pub name: String,
49 #[serde(default = "OrgId::default_org")]
51 pub org: OrgId,
52 pub file: ComposeFile,
54 pub base_dir: PathBuf,
56 #[serde(default)]
59 pub secrets: BTreeMap<String, SecretBinding>,
60 #[serde(default)]
63 pub force: BTreeMap<String, u64>,
64 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
68 pub images: BTreeMap<String, String>,
69 pub deployed_at: u64,
71 #[serde(default)]
73 pub deployed_by: String,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub previous: Option<Box<StackDef>>,
77}
78
79pub fn qualified(org: &OrgId, name: &str) -> String {
82 if org.is_default() {
83 name.to_string()
84 } else {
85 format!("{org}/{name}")
86 }
87}
88
89pub fn split_qualified(q: &str) -> Result<(OrgId, String)> {
91 match q.split_once('/') {
92 Some((o, n)) => Ok((OrgId::new(o)?, n.to_string())),
93 None => Ok((OrgId::default_org(), q.to_string())),
94 }
95}
96
97impl StackDef {
98 pub fn qualified(&self) -> String {
99 qualified(&self.org, &self.name)
100 }
101
102 pub fn revision(&self, service: &str) -> Result<String> {
110 self.revision_with(service, &|key| {
111 self.secrets
112 .get(key)
113 .map(|b| match self.on_change(service, key) {
114 OnChange::Roll => format!("{}\0{}\0{}", b.name, b.driver, b.version),
115 _ => format!("{}\0{}\0live", b.name, b.driver),
116 })
117 .map(String::into_bytes)
118 .unwrap_or_default()
119 })
120 }
121
122 pub fn on_change(&self, service: &str, key: &str) -> OnChange {
126 self.file
127 .services
128 .get(service)
129 .and_then(|s| s.secret_on_change(key))
130 .or_else(|| self.file.secrets.get(key).and_then(|d| d.on_change))
131 .unwrap_or_default()
132 }
133
134 pub fn live_secrets(&self, service: &str) -> BTreeMap<String, (OnChange, u64)> {
138 let Ok(spec) = self.service(service) else {
139 return BTreeMap::new();
140 };
141 spec.secret_keys()
142 .into_iter()
143 .filter_map(|k| {
144 let mode = self.on_change(service, k);
145 let b = self.secrets.get(k)?;
146 (mode != OnChange::Roll).then(|| (k.to_string(), (mode, b.version)))
147 })
148 .collect()
149 }
150
151 pub fn services_using(&self, key: &str) -> Vec<String> {
153 self.file
154 .services
155 .iter()
156 .filter(|(_, s)| s.secret_keys().contains(key))
157 .map(|(n, _)| n.clone())
158 .collect()
159 }
160
161 pub(crate) fn revision_with(
163 &self,
164 service: &str,
165 secret: &dyn Fn(&str) -> Vec<u8>,
166 ) -> Result<String> {
167 let spec = self.service(service)?;
168 let mut s = spec.clone();
169 s.name = None;
170 s.depends_on.clear();
171 for r in &mut s.secrets {
174 r.on_change = None;
175 }
176 s.env.on_change.clear();
177 s.domains.clear();
179 s.ports.retain(|p| p.bind == crate::spec::PortBind::Guest);
181 if let Some(d) = &mut s.deploy {
182 d.replicas = None;
183 d.update_config = None;
184 d.rollback_config = None;
185 }
186 if s.deploy.as_ref().is_some_and(|d| *d == Default::default()) {
187 s.deploy = None;
188 }
189 let mut h = Fnv64::new();
190 h.write(serde_json::to_string(&s)?.as_bytes());
191 for r in &spec.secrets {
192 h.write(r.source.as_bytes());
193 h.write(&secret(&r.source));
194 }
195 for (var, key) in &spec.env.secrets {
196 h.write(b"env");
197 h.write(var.as_bytes());
198 h.write(&secret(key));
199 }
200 for v in &spec.volumes {
203 if let Some(d) = self.file.volumes.get(&v.source) {
204 h.write(serde_json::to_string(d)?.as_bytes());
205 }
206 }
207 h.write(&self.force.get(service).copied().unwrap_or(0).to_le_bytes());
208 if let Some(d) = self.images.get(service) {
211 h.write(b"image");
212 h.write(d.as_bytes());
213 }
214 for p in ports::published(spec).unwrap_or_default() {
217 if p.udp {
218 h.write(format!("udp {} {}", p.listen, p.target).as_bytes());
219 }
220 }
221 Ok(format!("{:08x}", h.finish() as u32))
222 }
223
224 pub fn store_secrets(&self) -> std::collections::BTreeSet<String> {
228 let used = secrets::used_keys(&self.file);
229 self.secrets
230 .iter()
231 .filter(|(k, _)| used.contains(*k))
232 .map(|(_, b)| b.name.clone())
233 .collect()
234 }
235
236 pub fn instance_image(&self, service: &str, image: &str) -> String {
239 let (Some(r), Some(d)) = (image.strip_prefix("registry:"), self.images.get(service)) else {
240 return image.to_string();
241 };
242 match crate::registry::ImageRef::parse(r) {
243 Ok(r) if r.digest.is_none() => format!("registry:{}", r.pinned(d).render()),
244 _ => image.to_string(),
245 }
246 }
247
248 pub fn service(&self, service: &str) -> Result<&SandboxSpec> {
249 self.file
250 .services
251 .get(service)
252 .ok_or_else(|| Error::NotFound(format!("service {service} in stack {}", self.name)))
253 }
254}
255
256struct Fnv64(u64);
258
259impl Fnv64 {
260 fn new() -> Self {
261 Fnv64(0xcbf29ce484222325)
262 }
263 fn write(&mut self, b: &[u8]) {
264 for x in b {
265 self.0 ^= *x as u64;
266 self.0 = self.0.wrapping_mul(0x100000001b3);
267 }
268 self.0 ^= 0xff;
270 self.0 = self.0.wrapping_mul(0x100000001b3);
271 }
272 fn finish(&self) -> u64 {
273 self.0
274 }
275}
276
277pub fn validate_stack_name(name: &str) -> Result<()> {
279 let ok = !name.is_empty()
280 && name.len() <= 30
281 && name.starts_with(|c: char| c.is_ascii_lowercase())
282 && !name.ends_with('-')
283 && name
284 .chars()
285 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
286 if ok {
287 Ok(())
288 } else {
289 Err(Error::invalid(format!(
290 "stack name {name:?}: up to 30 characters of [a-z0-9-], starting with a letter"
291 )))
292 }
293}
294
295pub fn instance_name(stack: &str, service: &str, slot: u32, id: &str) -> Result<String> {
297 let n = format!(
298 "{stack}-{}-{slot}-{id}",
299 crate::compose::sanitize_name(service)
300 );
301 crate::plan::validate_instance_name(&n).map_err(|_| {
302 Error::invalid(format!(
303 "instance name {n:?} is too long; shorten the stack or service name"
304 ))
305 })?;
306 Ok(n)
307}
308
309pub fn new_id() -> String {
311 let mut b = [0u8; 2];
312 if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
313 use std::io::Read;
314 let _ = f.read_exact(&mut b);
315 }
316 format!("{:02x}{:02x}", b[0], b[1])
317}
318
319#[derive(Debug, Clone)]
322pub struct Store {
323 dir: PathBuf,
324}
325
326impl Store {
327 pub fn default_dir() -> PathBuf {
328 std::env::var_os("XDG_STATE_HOME")
329 .filter(|s| !s.is_empty())
330 .map(PathBuf::from)
331 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
332 .unwrap_or_else(|| PathBuf::from("/var/lib"))
333 .join("isb")
334 }
335
336 pub fn open(dir: impl Into<PathBuf>) -> Result<Store> {
337 let dir = dir.into();
338 let stacks = dir.join("stacks");
339 std::fs::create_dir_all(&stacks)?;
340 set_mode(&dir, 0o700)?;
341 set_mode(&stacks, 0o700)?;
342 Ok(Store { dir })
343 }
344
345 pub fn dir(&self) -> &Path {
346 &self.dir
347 }
348
349 fn path(&self, org: &OrgId, name: &str) -> PathBuf {
350 self.stacks_dir(org).join(format!("{name}.json"))
351 }
352
353 fn stacks_dir(&self, org: &OrgId) -> PathBuf {
355 if org.is_default() {
356 self.dir.join("stacks")
357 } else {
358 org.dir(&self.dir).join("stacks")
359 }
360 }
361
362 pub fn files(&self) -> Result<Vec<PathBuf>> {
364 let mut dirs = vec![self.dir.join("stacks")];
365 if let Ok(rd) = std::fs::read_dir(self.dir.join("orgs")) {
366 for e in rd.flatten() {
367 dirs.push(e.path().join("stacks"));
368 }
369 }
370 let mut out = Vec::new();
371 for d in dirs {
372 let Ok(rd) = std::fs::read_dir(&d) else {
373 continue;
374 };
375 for e in rd {
376 let p = e?.path();
377 if p.extension().is_some_and(|x| x == "json") {
378 out.push(p);
379 }
380 }
381 }
382 out.sort();
383 Ok(out)
384 }
385
386 pub fn load_all(&self) -> Result<Vec<StackDef>> {
387 let mut out = Vec::new();
388 for p in self.files()? {
389 let text = std::fs::read_to_string(&p)?;
390 match serde_json::from_str::<StackDef>(&text) {
391 Ok(d) => out.push(d),
392 Err(e) => eprintln!("isb serve: skipping {}: {e}", p.display()),
393 }
394 }
395 out.sort_by_key(|d| d.qualified());
396 Ok(out)
397 }
398
399 pub fn save(&self, def: &StackDef) -> Result<()> {
402 use std::io::Write;
403 use std::os::unix::fs::OpenOptionsExt;
404 let dir = self.stacks_dir(&def.org);
405 std::fs::create_dir_all(&dir)?;
406 set_mode(&dir, 0o700)?;
407 let path = self.path(&def.org, &def.name);
408 let tmp = path.with_extension("json.tmp");
409 let mut f = std::fs::OpenOptions::new()
410 .write(true)
411 .create(true)
412 .truncate(true)
413 .mode(0o600)
414 .open(&tmp)?;
415 f.write_all(serde_json::to_string_pretty(def)?.as_bytes())?;
416 f.sync_all()?;
417 std::fs::rename(&tmp, &path)?;
418 Ok(())
419 }
420
421 pub fn remove(&self, org: &OrgId, name: &str) -> Result<()> {
422 match std::fs::remove_file(self.path(org, name)) {
423 Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
424 _ => Ok(()),
425 }
426 }
427}
428
429fn set_mode(p: &Path, mode: u32) -> Result<()> {
430 use std::os::unix::fs::PermissionsExt;
431 std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode))?;
432 Ok(())
433}
434
435pub fn now_secs() -> u64 {
436 std::time::SystemTime::now()
437 .duration_since(std::time::UNIX_EPOCH)
438 .map(|d| d.as_secs())
439 .unwrap_or(0)
440}
441
442pub fn local_deploy_args(
445 project: &crate::compose::Project,
446 name: &str,
447 wait: bool,
448 timeout: Option<&str>,
449) -> crate::Result<serde_json::Value> {
450 let secrets: std::collections::BTreeMap<String, String> =
453 crate::supervise::resolve_secret_values(&project.file, &project.base_dir, &|k| {
454 project.lookup(k)
455 })?
456 .into_iter()
457 .map(|(k, v)| {
458 String::from_utf8(v)
459 .map(|s| (k.clone(), s))
460 .map_err(|_| crate::Error::invalid(format!("secret {k:?} is not UTF-8 text")))
461 })
462 .collect::<crate::Result<_>>()?;
463 let mut v = serde_json::json!({
464 "name": name,
465 "file": project.file,
466 "base_dir": project.base_dir,
467 "secrets": secrets,
468 "wait": wait,
469 });
470 if let Some(t) = timeout {
471 v["timeout"] = serde_json::json!(t);
472 }
473 Ok(v)
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479
480 fn def(y: &str) -> StackDef {
481 StackDef {
482 name: "app".into(),
483 org: OrgId::default_org(),
484 file: serde_yaml_ng::from_str(y).unwrap(),
485 base_dir: "/".into(),
486 secrets: BTreeMap::new(),
487 force: BTreeMap::new(),
488 images: BTreeMap::new(),
489 deployed_at: 0,
490 deployed_by: String::new(),
491 previous: None,
492 }
493 }
494
495 #[test]
496 fn revision_ignores_replicas_and_rollout_settings() {
497 let a = def("services:\n web: {image: x, deploy: {replicas: 1}}\n");
498 let b = def(
499 "services:\n web: {image: x, deploy: {replicas: 5, update_config: {order: start-first}}}\n",
500 );
501 assert_eq!(a.revision("web").unwrap(), b.revision("web").unwrap());
502 let c = def("services:\n web: {image: y}\n");
503 assert_ne!(a.revision("web").unwrap(), c.revision("web").unwrap());
504 let mut d = a.clone();
505 d.force.insert("web".into(), 1);
506 assert_ne!(a.revision("web").unwrap(), d.revision("web").unwrap());
507 }
508
509 fn binding(name: &str, version: u64) -> SecretBinding {
510 SecretBinding {
511 name: name.into(),
512 driver: "local".into(),
513 version,
514 owned: false,
515 }
516 }
517
518 #[test]
519 fn revision_follows_secret_versions() {
520 let y = "secrets: {k: {external: true}, e: {external: true}}\nservices:\n web: {image: x, secrets: [k]}\n api: {image: docker:busybox, environment: {TOKEN: {secret: e}}}\n";
521 let mut a = def(y);
522 a.secrets.insert("k".into(), binding("k", 1));
523 a.secrets.insert("e".into(), binding("e", 1));
524 let (web, api) = (a.revision("web").unwrap(), a.revision("api").unwrap());
525 let mut b = a.clone();
527 b.secrets.get_mut("k").unwrap().version = 2;
528 assert_ne!(b.revision("web").unwrap(), web);
529 assert_eq!(b.revision("api").unwrap(), api);
530 let mut c = a.clone();
532 c.secrets.get_mut("e").unwrap().version = 2;
533 assert_eq!(c.revision("web").unwrap(), web);
534 assert_ne!(c.revision("api").unwrap(), api);
535 let mut d = a.clone();
537 d.secrets.get_mut("e").unwrap().name = "other".into();
538 assert_ne!(d.revision("api").unwrap(), api);
539 let mut e = a.clone();
541 let env = &mut e.file.services.get_mut("api").unwrap().env.secrets;
542 env.clear();
543 env.insert("TOKEN2".into(), "e".into());
544 assert_ne!(e.revision("api").unwrap(), api);
545 let mut f = a.clone();
547 f.secrets.get_mut("k").unwrap().owned = true;
548 f.deployed_at = 99;
549 assert_eq!(f.revision("web").unwrap(), web);
550 }
551
552 #[test]
553 fn udp_ports_are_part_of_the_revision_tcp_ports_are_not() {
554 let rev = |ports: &str| {
555 def(&format!("services:\n m: {{image: x, ports: {ports}}}\n"))
556 .revision("m")
557 .unwrap()
558 };
559 let none = rev("[]");
560 assert_eq!(rev("['8080:80']"), none);
561 let udp = rev("['203.0.113.7:10000:10000/udp']");
562 assert_ne!(udp, none);
563 assert_ne!(rev("['203.0.113.7:10001:10000/udp']"), udp);
564 }
565
566 #[test]
567 fn on_change_decides_what_a_new_version_rolls() {
568 let y = concat!(
569 "secrets:\n",
570 " k: {external: true, on_change: restart}\n",
571 " e: {external: true}\n",
572 " n: {external: true, on_change: restart}\n",
573 "services:\n",
574 " web: {image: x, secrets: [k, {source: n, on_change: none}]}\n",
575 " api: {image: docker:busybox, secrets: [k], environment: {T: {secret: e, on_change: none}, U: {secret: k, on_change: roll}}}\n",
576 );
577 let mut a = def(y);
578 for k in ["k", "e", "n"] {
579 a.secrets.insert(k.into(), binding(k, 1));
580 }
581 assert_eq!(a.on_change("web", "k"), OnChange::Restart);
584 assert_eq!(a.on_change("web", "n"), OnChange::None);
585 assert_eq!(a.on_change("api", "k"), OnChange::Roll);
586 assert_eq!(a.on_change("api", "e"), OnChange::None);
587 assert_eq!(
588 a.live_secrets("web"),
589 BTreeMap::from([
590 ("k".to_string(), (OnChange::Restart, 1)),
591 ("n".to_string(), (OnChange::None, 1)),
592 ])
593 );
594 assert_eq!(a.services_using("k"), ["api", "web"]);
595 let (web, api) = (a.revision("web").unwrap(), a.revision("api").unwrap());
596 let mut b = a.clone();
598 b.secrets.get_mut("k").unwrap().version = 2;
599 assert_eq!(b.revision("web").unwrap(), web);
600 assert_ne!(b.revision("api").unwrap(), api);
601 let mut c = a.clone();
603 c.secrets.get_mut("e").unwrap().version = 2;
604 c.secrets.get_mut("n").unwrap().version = 2;
605 assert_eq!(c.revision("web").unwrap(), web);
606 assert_eq!(c.revision("api").unwrap(), api);
607 let mut d = a.clone();
609 d.file.secrets.get_mut("k").unwrap().on_change = Some(OnChange::None);
610 assert_eq!(d.revision("web").unwrap(), web);
611 let plain = def(&y
613 .replace(", on_change: restart}", "}")
614 .replace(", on_change: none}", "}")
615 .replace(", on_change: roll}", "}"));
616 let mut p = plain.clone();
617 p.secrets = a.secrets.clone();
618 let mut r = p.clone();
619 for s in r.file.secrets.values_mut() {
620 s.on_change = Some(OnChange::Roll);
621 }
622 assert_eq!(p.revision("api").unwrap(), r.revision("api").unwrap());
623 assert_eq!(p.revision("web").unwrap(), r.revision("web").unwrap());
624 let y2 = serde_yaml_ng::to_string(&a.file).unwrap();
626 assert!(y2.contains("on_change: none"), "{y2}");
627 let back: ComposeFile = serde_yaml_ng::from_str(&y2).unwrap();
628 assert_eq!(back, a.file);
629 }
630
631 #[test]
632 fn revision_without_secrets_is_unchanged_by_bindings() {
633 let a = def("services:\n web: {image: x, environment: {A: '1'}}\n");
636 assert_eq!(
637 a.revision("web").unwrap(),
638 a.revision_with("web", &|_| b"ignored".to_vec()).unwrap()
639 );
640 }
641
642 #[test]
643 fn store_secrets_are_the_bound_names() {
644 let mut d = def(concat!(
645 "secrets:\n",
646 " a: {external: true}\n",
647 " b: {external: true, name: db.password}\n",
648 " c: {environment: C}\n",
649 " e: {external: true}\n",
650 " unused: {external: true}\n",
651 "services:\n",
652 " web: {image: x, secrets: [a, c]}\n",
653 " db: {image: x, secrets: [{source: b, target: pw}], command: [x], environment: {E: {secret: e}}}\n",
654 ));
655 d.secrets.insert("a".into(), binding("a", 1));
656 d.secrets.insert("b".into(), binding("db.password", 1));
657 d.secrets.insert("c".into(), binding("app_c", 1));
658 d.secrets.insert("e".into(), binding("e", 1));
659 d.secrets.insert("unused".into(), binding("unused", 1));
661 let s: Vec<String> = d.store_secrets().into_iter().collect();
662 assert_eq!(s, ["a", "app_c", "db.password", "e"]);
663 }
664
665 #[test]
666 fn environment_round_trips_with_secrets() {
667 let d = def(
668 "services:\n web: {image: x, environment: {A: 1, T: {secret: tok}}}\nsecrets: {tok: {external: true}}\n",
669 );
670 let env = &d.file.services["web"].env;
671 assert_eq!(env["A"], "1");
672 assert_eq!(env.secrets["T"], "tok");
673 let json = serde_json::to_string(&d.file).unwrap();
674 assert!(
675 json.contains(r#""environment":{"A":"1","T":{"secret":"tok"}}"#),
676 "{json}"
677 );
678 let back: crate::spec::ComposeFile = serde_json::from_str(&json).unwrap();
679 assert_eq!(back, d.file);
680 assert!(
681 serde_yaml_ng::from_str::<crate::spec::SandboxSpec>(
682 "image: x\nenvironment: {T: {secret: tok, extra: 1}}\n"
683 )
684 .is_err()
685 );
686 assert!(
687 serde_yaml_ng::from_str::<crate::spec::SandboxSpec>(
688 "image: x\nenvironment: {T: {secret: ''}}\n"
689 )
690 .is_err()
691 );
692 }
693
694 #[test]
695 fn names() {
696 assert_eq!(
697 instance_name("app", "web", 2, "ab12").unwrap(),
698 "app-web-2-ab12"
699 );
700 assert!(instance_name(&"a".repeat(30), &"b".repeat(40), 1, "ab12").is_err());
701 assert!(validate_stack_name("my-app").is_ok());
702 assert!(validate_stack_name("My_App").is_err());
703 assert!(validate_stack_name("1app").is_err());
704 }
705
706 #[test]
707 fn store_round_trip() {
708 let dir = tempfile::tempdir().unwrap();
709 let s = Store::open(dir.path()).unwrap();
710 let d = def("services:\n web: {image: x}\n");
711 s.save(&d).unwrap();
712 let all = s.load_all().unwrap();
713 assert_eq!(all.len(), 1);
714 assert_eq!(all[0].name, "app");
715 use std::os::unix::fs::PermissionsExt;
716 let mode = std::fs::metadata(dir.path().join("stacks/app.json"))
717 .unwrap()
718 .permissions()
719 .mode();
720 assert_eq!(mode & 0o777, 0o600);
721 let mut other = d.clone();
722 other.org = OrgId::new("alpha").unwrap();
723 s.save(&other).unwrap();
724 assert!(dir.path().join("orgs/alpha/stacks/app.json").is_file());
725 let all = s.load_all().unwrap();
726 assert_eq!(
727 all.iter().map(|d| d.qualified()).collect::<Vec<_>>(),
728 vec!["alpha/app", "app"]
729 );
730 s.remove(&OrgId::default_org(), "app").unwrap();
731 s.remove(&other.org, "app").unwrap();
732 assert!(s.load_all().unwrap().is_empty());
733 }
734}