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, 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> {
107 self.revision_with(service, &|key| {
108 self.secrets
109 .get(key)
110 .map(|b| format!("{}\0{}\0{}", b.name, b.driver, b.version).into_bytes())
111 .unwrap_or_default()
112 })
113 }
114
115 pub(crate) fn revision_with(
117 &self,
118 service: &str,
119 secret: &dyn Fn(&str) -> Vec<u8>,
120 ) -> Result<String> {
121 let spec = self.service(service)?;
122 let mut s = spec.clone();
123 s.name = None;
124 s.depends_on.clear();
125 s.domains.clear();
127 s.ports.retain(|p| p.bind == crate::spec::PortBind::Guest);
129 if let Some(d) = &mut s.deploy {
130 d.replicas = None;
131 d.update_config = None;
132 d.rollback_config = None;
133 }
134 if s.deploy.as_ref().is_some_and(|d| *d == Default::default()) {
135 s.deploy = None;
136 }
137 let mut h = Fnv64::new();
138 h.write(serde_json::to_string(&s)?.as_bytes());
139 for r in &spec.secrets {
140 h.write(r.source.as_bytes());
141 h.write(&secret(&r.source));
142 }
143 for (var, key) in &spec.env.secrets {
144 h.write(b"env");
145 h.write(var.as_bytes());
146 h.write(&secret(key));
147 }
148 for v in &spec.volumes {
151 if let Some(d) = self.file.volumes.get(&v.source) {
152 h.write(serde_json::to_string(d)?.as_bytes());
153 }
154 }
155 h.write(&self.force.get(service).copied().unwrap_or(0).to_le_bytes());
156 if let Some(d) = self.images.get(service) {
159 h.write(b"image");
160 h.write(d.as_bytes());
161 }
162 for p in ports::published(spec).unwrap_or_default() {
165 if p.udp {
166 h.write(format!("udp {} {}", p.listen, p.target).as_bytes());
167 }
168 }
169 Ok(format!("{:08x}", h.finish() as u32))
170 }
171
172 pub fn store_secrets(&self) -> std::collections::BTreeSet<String> {
176 let used = secrets::used_keys(&self.file);
177 self.secrets
178 .iter()
179 .filter(|(k, _)| used.contains(*k))
180 .map(|(_, b)| b.name.clone())
181 .collect()
182 }
183
184 pub fn instance_image(&self, service: &str, image: &str) -> String {
187 let (Some(r), Some(d)) = (image.strip_prefix("registry:"), self.images.get(service)) else {
188 return image.to_string();
189 };
190 match crate::registry::ImageRef::parse(r) {
191 Ok(r) if r.digest.is_none() => format!("registry:{}", r.pinned(d).render()),
192 _ => image.to_string(),
193 }
194 }
195
196 pub fn service(&self, service: &str) -> Result<&SandboxSpec> {
197 self.file
198 .services
199 .get(service)
200 .ok_or_else(|| Error::NotFound(format!("service {service} in stack {}", self.name)))
201 }
202}
203
204struct Fnv64(u64);
206
207impl Fnv64 {
208 fn new() -> Self {
209 Fnv64(0xcbf29ce484222325)
210 }
211 fn write(&mut self, b: &[u8]) {
212 for x in b {
213 self.0 ^= *x as u64;
214 self.0 = self.0.wrapping_mul(0x100000001b3);
215 }
216 self.0 ^= 0xff;
218 self.0 = self.0.wrapping_mul(0x100000001b3);
219 }
220 fn finish(&self) -> u64 {
221 self.0
222 }
223}
224
225pub fn validate_stack_name(name: &str) -> Result<()> {
227 let ok = !name.is_empty()
228 && name.len() <= 30
229 && name.starts_with(|c: char| c.is_ascii_lowercase())
230 && !name.ends_with('-')
231 && name
232 .chars()
233 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
234 if ok {
235 Ok(())
236 } else {
237 Err(Error::invalid(format!(
238 "stack name {name:?}: up to 30 characters of [a-z0-9-], starting with a letter"
239 )))
240 }
241}
242
243pub fn instance_name(stack: &str, service: &str, slot: u32, id: &str) -> Result<String> {
245 let n = format!(
246 "{stack}-{}-{slot}-{id}",
247 crate::compose::sanitize_name(service)
248 );
249 crate::plan::validate_instance_name(&n).map_err(|_| {
250 Error::invalid(format!(
251 "instance name {n:?} is too long; shorten the stack or service name"
252 ))
253 })?;
254 Ok(n)
255}
256
257pub fn new_id() -> String {
259 let mut b = [0u8; 2];
260 if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
261 use std::io::Read;
262 let _ = f.read_exact(&mut b);
263 }
264 format!("{:02x}{:02x}", b[0], b[1])
265}
266
267#[derive(Debug, Clone)]
270pub struct Store {
271 dir: PathBuf,
272}
273
274impl Store {
275 pub fn default_dir() -> PathBuf {
276 std::env::var_os("XDG_STATE_HOME")
277 .filter(|s| !s.is_empty())
278 .map(PathBuf::from)
279 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
280 .unwrap_or_else(|| PathBuf::from("/var/lib"))
281 .join("isb")
282 }
283
284 pub fn open(dir: impl Into<PathBuf>) -> Result<Store> {
285 let dir = dir.into();
286 let stacks = dir.join("stacks");
287 std::fs::create_dir_all(&stacks)?;
288 set_mode(&dir, 0o700)?;
289 set_mode(&stacks, 0o700)?;
290 Ok(Store { dir })
291 }
292
293 pub fn dir(&self) -> &Path {
294 &self.dir
295 }
296
297 fn path(&self, org: &OrgId, name: &str) -> PathBuf {
298 self.stacks_dir(org).join(format!("{name}.json"))
299 }
300
301 fn stacks_dir(&self, org: &OrgId) -> PathBuf {
303 if org.is_default() {
304 self.dir.join("stacks")
305 } else {
306 org.dir(&self.dir).join("stacks")
307 }
308 }
309
310 pub fn files(&self) -> Result<Vec<PathBuf>> {
312 let mut dirs = vec![self.dir.join("stacks")];
313 if let Ok(rd) = std::fs::read_dir(self.dir.join("orgs")) {
314 for e in rd.flatten() {
315 dirs.push(e.path().join("stacks"));
316 }
317 }
318 let mut out = Vec::new();
319 for d in dirs {
320 let Ok(rd) = std::fs::read_dir(&d) else {
321 continue;
322 };
323 for e in rd {
324 let p = e?.path();
325 if p.extension().is_some_and(|x| x == "json") {
326 out.push(p);
327 }
328 }
329 }
330 out.sort();
331 Ok(out)
332 }
333
334 pub fn load_all(&self) -> Result<Vec<StackDef>> {
335 let mut out = Vec::new();
336 for p in self.files()? {
337 let text = std::fs::read_to_string(&p)?;
338 match serde_json::from_str::<StackDef>(&text) {
339 Ok(d) => out.push(d),
340 Err(e) => eprintln!("isb serve: skipping {}: {e}", p.display()),
341 }
342 }
343 out.sort_by_key(|d| d.qualified());
344 Ok(out)
345 }
346
347 pub fn save(&self, def: &StackDef) -> Result<()> {
350 use std::io::Write;
351 use std::os::unix::fs::OpenOptionsExt;
352 let dir = self.stacks_dir(&def.org);
353 std::fs::create_dir_all(&dir)?;
354 set_mode(&dir, 0o700)?;
355 let path = self.path(&def.org, &def.name);
356 let tmp = path.with_extension("json.tmp");
357 let mut f = std::fs::OpenOptions::new()
358 .write(true)
359 .create(true)
360 .truncate(true)
361 .mode(0o600)
362 .open(&tmp)?;
363 f.write_all(serde_json::to_string_pretty(def)?.as_bytes())?;
364 f.sync_all()?;
365 std::fs::rename(&tmp, &path)?;
366 Ok(())
367 }
368
369 pub fn remove(&self, org: &OrgId, name: &str) -> Result<()> {
370 match std::fs::remove_file(self.path(org, name)) {
371 Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
372 _ => Ok(()),
373 }
374 }
375}
376
377fn set_mode(p: &Path, mode: u32) -> Result<()> {
378 use std::os::unix::fs::PermissionsExt;
379 std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode))?;
380 Ok(())
381}
382
383pub fn now_secs() -> u64 {
384 std::time::SystemTime::now()
385 .duration_since(std::time::UNIX_EPOCH)
386 .map(|d| d.as_secs())
387 .unwrap_or(0)
388}
389
390pub fn local_deploy_args(
393 project: &crate::compose::Project,
394 name: &str,
395 wait: bool,
396 timeout: Option<&str>,
397) -> crate::Result<serde_json::Value> {
398 let secrets: std::collections::BTreeMap<String, String> =
401 crate::supervise::resolve_secret_values(&project.file, &project.base_dir, &|k| {
402 project.lookup(k)
403 })?
404 .into_iter()
405 .map(|(k, v)| {
406 String::from_utf8(v)
407 .map(|s| (k.clone(), s))
408 .map_err(|_| crate::Error::invalid(format!("secret {k:?} is not UTF-8 text")))
409 })
410 .collect::<crate::Result<_>>()?;
411 let mut v = serde_json::json!({
412 "name": name,
413 "file": project.file,
414 "base_dir": project.base_dir,
415 "secrets": secrets,
416 "wait": wait,
417 });
418 if let Some(t) = timeout {
419 v["timeout"] = serde_json::json!(t);
420 }
421 Ok(v)
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 fn def(y: &str) -> StackDef {
429 StackDef {
430 name: "app".into(),
431 org: OrgId::default_org(),
432 file: serde_yaml_ng::from_str(y).unwrap(),
433 base_dir: "/".into(),
434 secrets: BTreeMap::new(),
435 force: BTreeMap::new(),
436 images: BTreeMap::new(),
437 deployed_at: 0,
438 deployed_by: String::new(),
439 previous: None,
440 }
441 }
442
443 #[test]
444 fn revision_ignores_replicas_and_rollout_settings() {
445 let a = def("services:\n web: {image: x, deploy: {replicas: 1}}\n");
446 let b = def(
447 "services:\n web: {image: x, deploy: {replicas: 5, update_config: {order: start-first}}}\n",
448 );
449 assert_eq!(a.revision("web").unwrap(), b.revision("web").unwrap());
450 let c = def("services:\n web: {image: y}\n");
451 assert_ne!(a.revision("web").unwrap(), c.revision("web").unwrap());
452 let mut d = a.clone();
453 d.force.insert("web".into(), 1);
454 assert_ne!(a.revision("web").unwrap(), d.revision("web").unwrap());
455 }
456
457 fn binding(name: &str, version: u64) -> SecretBinding {
458 SecretBinding {
459 name: name.into(),
460 driver: "local".into(),
461 version,
462 owned: false,
463 }
464 }
465
466 #[test]
467 fn revision_follows_secret_versions() {
468 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";
469 let mut a = def(y);
470 a.secrets.insert("k".into(), binding("k", 1));
471 a.secrets.insert("e".into(), binding("e", 1));
472 let (web, api) = (a.revision("web").unwrap(), a.revision("api").unwrap());
473 let mut b = a.clone();
475 b.secrets.get_mut("k").unwrap().version = 2;
476 assert_ne!(b.revision("web").unwrap(), web);
477 assert_eq!(b.revision("api").unwrap(), api);
478 let mut c = a.clone();
480 c.secrets.get_mut("e").unwrap().version = 2;
481 assert_eq!(c.revision("web").unwrap(), web);
482 assert_ne!(c.revision("api").unwrap(), api);
483 let mut d = a.clone();
485 d.secrets.get_mut("e").unwrap().name = "other".into();
486 assert_ne!(d.revision("api").unwrap(), api);
487 let mut e = a.clone();
489 let env = &mut e.file.services.get_mut("api").unwrap().env.secrets;
490 env.clear();
491 env.insert("TOKEN2".into(), "e".into());
492 assert_ne!(e.revision("api").unwrap(), api);
493 let mut f = a.clone();
495 f.secrets.get_mut("k").unwrap().owned = true;
496 f.deployed_at = 99;
497 assert_eq!(f.revision("web").unwrap(), web);
498 }
499
500 #[test]
501 fn udp_ports_are_part_of_the_revision_tcp_ports_are_not() {
502 let rev = |ports: &str| {
503 def(&format!("services:\n m: {{image: x, ports: {ports}}}\n"))
504 .revision("m")
505 .unwrap()
506 };
507 let none = rev("[]");
508 assert_eq!(rev("['8080:80']"), none);
509 let udp = rev("['203.0.113.7:10000:10000/udp']");
510 assert_ne!(udp, none);
511 assert_ne!(rev("['203.0.113.7:10001:10000/udp']"), udp);
512 }
513
514 #[test]
515 fn revision_without_secrets_is_unchanged_by_bindings() {
516 let a = def("services:\n web: {image: x, environment: {A: '1'}}\n");
519 assert_eq!(
520 a.revision("web").unwrap(),
521 a.revision_with("web", &|_| b"ignored".to_vec()).unwrap()
522 );
523 }
524
525 #[test]
526 fn store_secrets_are_the_bound_names() {
527 let mut d = def(concat!(
528 "secrets:\n",
529 " a: {external: true}\n",
530 " b: {external: true, name: db.password}\n",
531 " c: {environment: C}\n",
532 " e: {external: true}\n",
533 " unused: {external: true}\n",
534 "services:\n",
535 " web: {image: x, secrets: [a, c]}\n",
536 " db: {image: x, secrets: [{source: b, target: pw}], command: [x], environment: {E: {secret: e}}}\n",
537 ));
538 d.secrets.insert("a".into(), binding("a", 1));
539 d.secrets.insert("b".into(), binding("db.password", 1));
540 d.secrets.insert("c".into(), binding("app_c", 1));
541 d.secrets.insert("e".into(), binding("e", 1));
542 d.secrets.insert("unused".into(), binding("unused", 1));
544 let s: Vec<String> = d.store_secrets().into_iter().collect();
545 assert_eq!(s, ["a", "app_c", "db.password", "e"]);
546 }
547
548 #[test]
549 fn environment_round_trips_with_secrets() {
550 let d = def(
551 "services:\n web: {image: x, environment: {A: 1, T: {secret: tok}}}\nsecrets: {tok: {external: true}}\n",
552 );
553 let env = &d.file.services["web"].env;
554 assert_eq!(env["A"], "1");
555 assert_eq!(env.secrets["T"], "tok");
556 let json = serde_json::to_string(&d.file).unwrap();
557 assert!(
558 json.contains(r#""environment":{"A":"1","T":{"secret":"tok"}}"#),
559 "{json}"
560 );
561 let back: crate::spec::ComposeFile = serde_json::from_str(&json).unwrap();
562 assert_eq!(back, d.file);
563 assert!(
564 serde_yaml_ng::from_str::<crate::spec::SandboxSpec>(
565 "image: x\nenvironment: {T: {secret: tok, extra: 1}}\n"
566 )
567 .is_err()
568 );
569 assert!(
570 serde_yaml_ng::from_str::<crate::spec::SandboxSpec>(
571 "image: x\nenvironment: {T: {secret: ''}}\n"
572 )
573 .is_err()
574 );
575 }
576
577 #[test]
578 fn names() {
579 assert_eq!(
580 instance_name("app", "web", 2, "ab12").unwrap(),
581 "app-web-2-ab12"
582 );
583 assert!(instance_name(&"a".repeat(30), &"b".repeat(40), 1, "ab12").is_err());
584 assert!(validate_stack_name("my-app").is_ok());
585 assert!(validate_stack_name("My_App").is_err());
586 assert!(validate_stack_name("1app").is_err());
587 }
588
589 #[test]
590 fn store_round_trip() {
591 let dir = tempfile::tempdir().unwrap();
592 let s = Store::open(dir.path()).unwrap();
593 let d = def("services:\n web: {image: x}\n");
594 s.save(&d).unwrap();
595 let all = s.load_all().unwrap();
596 assert_eq!(all.len(), 1);
597 assert_eq!(all[0].name, "app");
598 use std::os::unix::fs::PermissionsExt;
599 let mode = std::fs::metadata(dir.path().join("stacks/app.json"))
600 .unwrap()
601 .permissions()
602 .mode();
603 assert_eq!(mode & 0o777, 0o600);
604 let mut other = d.clone();
605 other.org = OrgId::new("alpha").unwrap();
606 s.save(&other).unwrap();
607 assert!(dir.path().join("orgs/alpha/stacks/app.json").is_file());
608 let all = s.load_all().unwrap();
609 assert_eq!(
610 all.iter().map(|d| d.qualified()).collect::<Vec<_>>(),
611 vec!["alpha/app", "app"]
612 );
613 s.remove(&OrgId::default_org(), "app").unwrap();
614 s.remove(&other.org, "app").unwrap();
615 assert!(s.load_all().unwrap().is_empty());
616 }
617}