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