Skip to main content

isb_core/stack/
mod.rs

1//! Stacks: a compose file deployed to the `isb serve` daemon, which keeps it
2//! running the way docker swarm keeps a stack running, on one host.
3//!
4//! The daemon's desired state is a file per stack under its state directory,
5//! and every instance it creates carries `user.isb.stack`, `user.isb.service`,
6//! `user.isb.slot` and `user.isb.rev`. Both survive a daemon restart, so a new
7//! daemon picks up exactly where the last one stopped. Apps never depend on
8//! the daemon being alive: they are supervised inside their guests (see
9//! [`crate::supervise`]) and start with the host.
10//!
11//! - A service has `deploy.replicas` slots. Each slot holds one instance,
12//!   named `<stack>-<service>-<slot>-<id>`.
13//! - A service's revision is a hash of everything that shapes an instance. An
14//!   instance whose revision is not the current one is replaced, in batches,
15//!   per `deploy.update_config`.
16//! - Published host ports are served by the daemon's load balancer
17//!   ([`crate::balance`]), which only sends traffic to healthy replicas, so a
18//!   `start-first` rollout has no gap.
19
20mod 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
39/// Instance config keys (without `user.`) that tie an instance to its stack.
40pub 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/// A deployed stack, as the daemon stores it.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct StackDef {
48    pub name: String,
49    /// The org the stack runs in (its incus project).
50    #[serde(default = "OrgId::default_org")]
51    pub org: OrgId,
52    /// The resolved compose file.
53    pub file: ComposeFile,
54    /// Where relative bind paths resolve.
55    pub base_dir: PathBuf,
56    /// The secrets the services use, by top-level key: references into the
57    /// org's store by name and version, never values ([`secrets`]).
58    #[serde(default)]
59    pub secrets: BTreeMap<String, SecretBinding>,
60    /// Bumped per service by a forced update, to replace instances whose spec
61    /// did not change (a moved image tag, say).
62    #[serde(default)]
63    pub force: BTreeMap<String, u64>,
64    /// The digest each `registry:` image resolved to at deploy time, by
65    /// service: a moved tag is a new revision, and a rollback runs exactly
66    /// what ran before.
67    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
68    pub images: BTreeMap<String, String>,
69    /// Unix seconds.
70    pub deployed_at: u64,
71    /// Who deployed it (an Access identity, or `local`).
72    #[serde(default)]
73    pub deployed_by: String,
74    /// The deployment this one replaced, for rollback.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub previous: Option<Box<StackDef>>,
77}
78
79/// A stack's name qualified by its org: `web` in the default org,
80/// `alpha/web` in org `alpha`. The controller keys everything by it.
81pub 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
89/// The org and name of a qualified stack name.
90pub 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    /// The service's revision: a hash of what shapes its instances. Replica
103    /// count, rollout settings and dependencies are left out, so changing
104    /// them never replaces an instance. A secret counts by its binding
105    /// (store name, driver, version), so a new version is a new revision.
106    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    /// [`StackDef::revision`] with each secret's contribution given.
116    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        // Domains are the ingress's: changing them never replaces an instance.
126        s.domains.clear();
127        // Published ports are the balancer's, not the instance's (UDP: below).
128        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        // Named volumes are part of the instance's devices; their definitions
149        // are only used at creation, but a renamed one must move the instance.
150        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        // Only when there is one, so stacks without registry images keep
157        // their revisions.
158        if let Some(d) = self.images.get(service) {
159            h.write(b"image");
160            h.write(d.as_bytes());
161        }
162        // A UDP port is a device on the instance (see `ports`), so a changed
163        // one replaces it. Only when there is one, as above.
164        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    /// Names in the org's store that the stack's services use (external
173    /// ones, and the `<stack>_<key>` ones it owns): what `isb secret rm`
174    /// must not pull out from under it.
175    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    /// A service's image as its instances get it: a `registry:` tag pinned
185    /// to the digest it named at deploy time ([`StackDef::images`]).
186    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
204/// FNV-1a, 64-bit: stable across builds and platforms, unlike std's hasher.
205struct 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        // A separator, so ("ab", "c") and ("a", "bc") differ.
217        self.0 ^= 0xff;
218        self.0 = self.0.wrapping_mul(0x100000001b3);
219    }
220    fn finish(&self) -> u64 {
221        self.0
222    }
223}
224
225/// Valid stack name: what an instance name prefix allows.
226pub 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
243/// `<stack>-<service>-<slot>-<id>`, checked against incus' 63-character limit.
244pub 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
257/// A short random id for a new instance.
258pub 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/// Where stack definitions live: `$XDG_STATE_HOME/isb` (else
268/// `~/.local/state/isb`), one 0600 file per stack under `stacks/`.
269#[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    /// Default-org stacks stay where they always were, under `stacks/`.
302    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    /// Every stored definition's file, in every org.
311    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    /// Write atomically (temp file, fsync, rename), so a crash never leaves
348    /// half a stack behind.
349    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
390/// Resolve the paths the local CLI sends with a deploy: the project's own
391/// directory, so relative binds and `file:` secrets work as with `isb up`.
392pub 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    // Only `file:`/`environment:` values travel; the daemon reads the rest
399    // from the org's store.
400    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        // A new version of the file secret rolls web, not api.
474        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        // A new version of the env secret rolls api, not web.
479        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        // So does pointing it at another store name, at the same version.
484        let mut d = a.clone();
485        d.secrets.get_mut("e").unwrap().name = "other".into();
486        assert_ne!(d.revision("api").unwrap(), api);
487        // And delivering it as another variable.
488        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        // Bookkeeping is not part of it.
494        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        // A stack with no secrets hashes exactly as before secrets became
517        // references, so upgrading never rolls it.
518        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        // A stale binding for a key no service uses does not count.
543        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}