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 deployments;
23pub mod failure;
24pub mod migrate;
25mod ports;
26pub mod secrets;
27pub mod source;
28
29use std::collections::BTreeMap;
30use std::path::{Path, PathBuf};
31
32use serde::{Deserialize, Serialize};
33
34use crate::error::{Error, Result};
35use crate::org::OrgId;
36use crate::spec::{ComposeFile, OnChange, SandboxSpec};
37
38pub use controller::Controller;
39pub use secrets::SecretBinding;
40
41/// Instance config keys (without `user.`) that tie an instance to its stack.
42pub const LABEL_STACK: &str = "isb.stack";
43pub const LABEL_SERVICE: &str = "isb.service";
44pub const LABEL_SLOT: &str = "isb.slot";
45pub const LABEL_REV: &str = "isb.rev";
46
47/// A deployed stack, as the daemon stores it.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct StackDef {
50    pub name: String,
51    /// The org the stack runs in (its incus project).
52    #[serde(default = "OrgId::default_org")]
53    pub org: OrgId,
54    /// The resolved compose file.
55    pub file: ComposeFile,
56    /// Where relative bind paths resolve.
57    pub base_dir: PathBuf,
58    /// The secrets the services use, by top-level key: references into the
59    /// org's store by name and version, never values ([`secrets`]).
60    #[serde(default)]
61    pub secrets: BTreeMap<String, SecretBinding>,
62    /// Bumped per service by a forced update, to replace instances whose spec
63    /// did not change (a moved image tag, say).
64    #[serde(default)]
65    pub force: BTreeMap<String, u64>,
66    /// The digest each `registry:` image resolved to at deploy time, by
67    /// service: a moved tag is a new revision, and a rollback runs exactly
68    /// what ran before.
69    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
70    pub images: BTreeMap<String, String>,
71    /// Unix seconds.
72    pub deployed_at: u64,
73    /// Who deployed it (an Access identity, or `local`).
74    #[serde(default)]
75    pub deployed_by: String,
76    /// The compose file as written (`${VAR}` unresolved), when it was
77    /// deployed as text and needed no variables from the caller: what
78    /// stack_export hands out. `file` is what it resolved to.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub source: Option<String>,
81    /// The stack's managed domains merged into `file` ([`source`]), per
82    /// service, so a rollback puts them back too.
83    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
84    pub domains: BTreeMap<String, Vec<crate::spec::DomainSpec>>,
85    /// The deployment this one replaced, for rollback.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub previous: Option<Box<StackDef>>,
88}
89
90/// A stack's name qualified by its org: `web` in the default org,
91/// `alpha/web` in org `alpha`. The controller keys everything by it.
92pub fn qualified(org: &OrgId, name: &str) -> String {
93    if org.is_default() {
94        name.to_string()
95    } else {
96        format!("{org}/{name}")
97    }
98}
99
100/// The org and name of a qualified stack name.
101pub fn split_qualified(q: &str) -> Result<(OrgId, String)> {
102    match q.split_once('/') {
103        Some((o, n)) => Ok((OrgId::new(o)?, n.to_string())),
104        None => Ok((OrgId::default_org(), q.to_string())),
105    }
106}
107
108impl StackDef {
109    pub fn qualified(&self) -> String {
110        qualified(&self.org, &self.name)
111    }
112
113    /// The service's revision: a hash of what shapes its instances. Replica
114    /// count, rollout settings and dependencies are left out, so changing
115    /// them never replaces an instance. A secret counts by its binding
116    /// (store name, driver, version), so a new version is a new revision,
117    /// unless its `on_change` for the service is `restart` or `none`: then
118    /// the version is left out, and a new one is delivered to the running
119    /// replicas instead ([`StackDef::live_secrets`]).
120    pub fn revision(&self, service: &str) -> Result<String> {
121        self.revision_with(service, &|key| {
122            self.secrets
123                .get(key)
124                .map(|b| match self.on_change(service, key) {
125                    OnChange::Roll => format!("{}\0{}\0{}", b.name, b.driver, b.version),
126                    _ => format!("{}\0{}\0live", b.name, b.driver),
127                })
128                .map(String::into_bytes)
129                .unwrap_or_default()
130        })
131    }
132
133    /// What a new version of the top-level secret `key` does to `service`:
134    /// the service's own references first, then the secret's `on_change`,
135    /// then `roll`.
136    pub fn on_change(&self, service: &str, key: &str) -> OnChange {
137        self.file
138            .services
139            .get(service)
140            .and_then(|s| s.secret_on_change(key))
141            .or_else(|| self.file.secrets.get(key).and_then(|d| d.on_change))
142            .unwrap_or_default()
143    }
144
145    /// The secrets `service` uses whose new versions reach its running
146    /// replicas in place (`on_change: restart` or `none`), with the setting
147    /// and the version bound now.
148    pub fn live_secrets(&self, service: &str) -> BTreeMap<String, (OnChange, u64)> {
149        let Ok(spec) = self.service(service) else {
150            return BTreeMap::new();
151        };
152        spec.secret_keys()
153            .into_iter()
154            .filter_map(|k| {
155                let mode = self.on_change(service, k);
156                let b = self.secrets.get(k)?;
157                (mode != OnChange::Roll).then(|| (k.to_string(), (mode, b.version)))
158            })
159            .collect()
160    }
161
162    /// The services using the top-level secret `key`.
163    pub fn services_using(&self, key: &str) -> Vec<String> {
164        self.file
165            .services
166            .iter()
167            .filter(|(_, s)| s.secret_keys().contains(key))
168            .map(|(n, _)| n.clone())
169            .collect()
170    }
171
172    /// [`StackDef::revision`] with each secret's contribution given.
173    pub(crate) fn revision_with(
174        &self,
175        service: &str,
176        secret: &dyn Fn(&str) -> Vec<u8>,
177    ) -> Result<String> {
178        let spec = self.service(service)?;
179        let mut s = spec.clone();
180        s.name = None;
181        s.depends_on.clear();
182        // What a new secret version does is not part of the instance; the
183        // version itself counts below, or not.
184        for r in &mut s.secrets {
185            r.on_change = None;
186        }
187        s.env.on_change.clear();
188        // Domains are the ingress's: changing them never replaces an instance.
189        s.domains.clear();
190        // Published ports are the balancer's, not the instance's (UDP: below).
191        s.ports.retain(|p| p.bind == crate::spec::PortBind::Guest);
192        if let Some(d) = &mut s.deploy {
193            d.replicas = None;
194            d.update_config = None;
195            d.rollback_config = None;
196        }
197        if s.deploy.as_ref().is_some_and(|d| *d == Default::default()) {
198            s.deploy = None;
199        }
200        let mut h = Fnv64::new();
201        h.write(serde_json::to_string(&s)?.as_bytes());
202        for r in &spec.secrets {
203            h.write(r.source.as_bytes());
204            h.write(&secret(&r.source));
205        }
206        for (var, key) in &spec.env.secrets {
207            h.write(b"env");
208            h.write(var.as_bytes());
209            h.write(&secret(key));
210        }
211        // Named volumes are part of the instance's devices; their definitions
212        // are only used at creation, but a renamed one must move the instance.
213        for v in &spec.volumes {
214            if let Some(d) = self.file.volumes.get(&v.source) {
215                h.write(serde_json::to_string(d)?.as_bytes());
216            }
217        }
218        h.write(&self.force.get(service).copied().unwrap_or(0).to_le_bytes());
219        // Only when there is one, so stacks without registry images keep
220        // their revisions.
221        if let Some(d) = self.images.get(service) {
222            h.write(b"image");
223            h.write(d.as_bytes());
224        }
225        // A UDP port is a device on the instance (see `ports`), so a changed
226        // one replaces it. Only when there is one, as above.
227        for p in ports::published(spec).unwrap_or_default() {
228            if p.udp {
229                h.write(format!("udp {} {}", p.listen, p.target).as_bytes());
230            }
231        }
232        Ok(format!("{:08x}", h.finish() as u32))
233    }
234
235    /// Names in the org's store that the stack's services use (external
236    /// ones, and the `<stack>_<key>` ones it owns): what `isb secret rm`
237    /// must not pull out from under it.
238    pub fn store_secrets(&self) -> std::collections::BTreeSet<String> {
239        let used = secrets::used_keys(&self.file);
240        self.secrets
241            .iter()
242            .filter(|(k, _)| used.contains(*k))
243            .map(|(_, b)| b.name.clone())
244            .collect()
245    }
246
247    /// A service's image as its instances get it: a `registry:` tag pinned
248    /// to the digest it named at deploy time ([`StackDef::images`]).
249    pub fn instance_image(&self, service: &str, image: &str) -> String {
250        let (Some(r), Some(d)) = (image.strip_prefix("registry:"), self.images.get(service)) else {
251            return image.to_string();
252        };
253        match crate::registry::ImageRef::parse(r) {
254            Ok(r) if r.digest.is_none() => format!("registry:{}", r.pinned(d).render()),
255            _ => image.to_string(),
256        }
257    }
258
259    pub fn service(&self, service: &str) -> Result<&SandboxSpec> {
260        self.file
261            .services
262            .get(service)
263            .ok_or_else(|| Error::NotFound(format!("service {service} in stack {}", self.name)))
264    }
265}
266
267/// FNV-1a, 64-bit: stable across builds and platforms, unlike std's hasher.
268struct Fnv64(u64);
269
270impl Fnv64 {
271    fn new() -> Self {
272        Fnv64(0xcbf29ce484222325)
273    }
274    fn write(&mut self, b: &[u8]) {
275        for x in b {
276            self.0 ^= *x as u64;
277            self.0 = self.0.wrapping_mul(0x100000001b3);
278        }
279        // A separator, so ("ab", "c") and ("a", "bc") differ.
280        self.0 ^= 0xff;
281        self.0 = self.0.wrapping_mul(0x100000001b3);
282    }
283    fn finish(&self) -> u64 {
284        self.0
285    }
286}
287
288/// Valid stack name: what an instance name prefix allows.
289pub fn validate_stack_name(name: &str) -> Result<()> {
290    let ok = !name.is_empty()
291        && name.len() <= 30
292        && name.starts_with(|c: char| c.is_ascii_lowercase())
293        && !name.ends_with('-')
294        && name
295            .chars()
296            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
297    if ok {
298        Ok(())
299    } else {
300        Err(Error::invalid(format!(
301            "stack name {name:?}: up to 30 characters of [a-z0-9-], starting with a letter"
302        )))
303    }
304}
305
306/// `<stack>-<service>-<slot>-<id>`, checked against incus' 63-character limit.
307pub fn instance_name(stack: &str, service: &str, slot: u32, id: &str) -> Result<String> {
308    let n = format!(
309        "{stack}-{}-{slot}-{id}",
310        crate::compose::sanitize_name(service)
311    );
312    crate::plan::validate_instance_name(&n).map_err(|_| {
313        Error::invalid(format!(
314            "instance name {n:?} is too long; shorten the stack or service name"
315        ))
316    })?;
317    Ok(n)
318}
319
320/// A short random id for a new instance.
321pub fn new_id() -> String {
322    let mut b = [0u8; 2];
323    if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
324        use std::io::Read;
325        let _ = f.read_exact(&mut b);
326    }
327    format!("{:02x}{:02x}", b[0], b[1])
328}
329
330/// Where stack definitions live: `$XDG_STATE_HOME/isb` (else
331/// `~/.local/state/isb`), one 0600 file per stack under `stacks/`.
332#[derive(Debug, Clone)]
333pub struct Store {
334    dir: PathBuf,
335}
336
337impl Store {
338    pub fn default_dir() -> PathBuf {
339        std::env::var_os("XDG_STATE_HOME")
340            .filter(|s| !s.is_empty())
341            .map(PathBuf::from)
342            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
343            .unwrap_or_else(|| PathBuf::from("/var/lib"))
344            .join("isb")
345    }
346
347    pub fn open(dir: impl Into<PathBuf>) -> Result<Store> {
348        let dir = dir.into();
349        let stacks = dir.join("stacks");
350        std::fs::create_dir_all(&stacks)?;
351        set_mode(&dir, 0o700)?;
352        set_mode(&stacks, 0o700)?;
353        Ok(Store { dir })
354    }
355
356    pub fn dir(&self) -> &Path {
357        &self.dir
358    }
359
360    fn path(&self, org: &OrgId, name: &str) -> PathBuf {
361        self.stacks_dir(org).join(format!("{name}.json"))
362    }
363
364    /// Default-org stacks stay where they always were, under `stacks/`.
365    fn stacks_dir(&self, org: &OrgId) -> PathBuf {
366        if org.is_default() {
367            self.dir.join("stacks")
368        } else {
369            org.dir(&self.dir).join("stacks")
370        }
371    }
372
373    /// Every stored definition's file, in every org.
374    pub fn files(&self) -> Result<Vec<PathBuf>> {
375        let mut dirs = vec![self.dir.join("stacks")];
376        if let Ok(rd) = std::fs::read_dir(self.dir.join("orgs")) {
377            for e in rd.flatten() {
378                dirs.push(e.path().join("stacks"));
379            }
380        }
381        let mut out = Vec::new();
382        for d in dirs {
383            let Ok(rd) = std::fs::read_dir(&d) else {
384                continue;
385            };
386            for e in rd {
387                let p = e?.path();
388                if p.extension().is_some_and(|x| x == "json") {
389                    out.push(p);
390                }
391            }
392        }
393        out.sort();
394        Ok(out)
395    }
396
397    pub fn load_all(&self) -> Result<Vec<StackDef>> {
398        let mut out = Vec::new();
399        for p in self.files()? {
400            let text = std::fs::read_to_string(&p)?;
401            match serde_json::from_str::<StackDef>(&text) {
402                Ok(d) => out.push(d),
403                Err(e) => eprintln!("isb serve: skipping {}: {e}", p.display()),
404            }
405        }
406        out.sort_by_key(|d| d.qualified());
407        Ok(out)
408    }
409
410    /// Write atomically (temp file, fsync, rename), so a crash never leaves
411    /// half a stack behind.
412    pub fn save(&self, def: &StackDef) -> Result<()> {
413        use std::io::Write;
414        use std::os::unix::fs::OpenOptionsExt;
415        let dir = self.stacks_dir(&def.org);
416        std::fs::create_dir_all(&dir)?;
417        set_mode(&dir, 0o700)?;
418        let path = self.path(&def.org, &def.name);
419        let tmp = path.with_extension("json.tmp");
420        let mut f = std::fs::OpenOptions::new()
421            .write(true)
422            .create(true)
423            .truncate(true)
424            .mode(0o600)
425            .open(&tmp)?;
426        f.write_all(serde_json::to_string_pretty(def)?.as_bytes())?;
427        f.sync_all()?;
428        std::fs::rename(&tmp, &path)?;
429        Ok(())
430    }
431
432    pub fn remove(&self, org: &OrgId, name: &str) -> Result<()> {
433        match std::fs::remove_file(self.path(org, name)) {
434            Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
435            _ => Ok(()),
436        }
437    }
438}
439
440fn set_mode(p: &Path, mode: u32) -> Result<()> {
441    use std::os::unix::fs::PermissionsExt;
442    std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode))?;
443    Ok(())
444}
445
446pub fn now_secs() -> u64 {
447    std::time::SystemTime::now()
448        .duration_since(std::time::UNIX_EPOCH)
449        .map(|d| d.as_secs())
450        .unwrap_or(0)
451}
452
453/// Resolve the paths the local CLI sends with a deploy: the project's own
454/// directory, so relative binds and `file:` secrets work as with `isb up`.
455pub fn local_deploy_args(
456    project: &crate::compose::Project,
457    name: &str,
458    wait: bool,
459    timeout: Option<&str>,
460) -> crate::Result<serde_json::Value> {
461    // Only `file:`/`environment:` values travel; the daemon reads the rest
462    // from the org's store.
463    let secrets: std::collections::BTreeMap<String, String> =
464        crate::supervise::resolve_secret_values(&project.file, &project.base_dir, &|k| {
465            project.lookup(k)
466        })?
467        .into_iter()
468        .map(|(k, v)| {
469            String::from_utf8(v)
470                .map(|s| (k.clone(), s))
471                .map_err(|_| crate::Error::invalid(format!("secret {k:?} is not UTF-8 text")))
472        })
473        .collect::<crate::Result<_>>()?;
474    let mut v = serde_json::json!({
475        "name": name,
476        "file": project.file,
477        "base_dir": project.base_dir,
478        "secrets": secrets,
479        "wait": wait,
480    });
481    if let Some(t) = timeout {
482        v["timeout"] = serde_json::json!(t);
483    }
484    Ok(v)
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    fn def(y: &str) -> StackDef {
492        StackDef {
493            source: None,
494            domains: Default::default(),
495            name: "app".into(),
496            org: OrgId::default_org(),
497            file: serde_yaml_ng::from_str(y).unwrap(),
498            base_dir: "/".into(),
499            secrets: BTreeMap::new(),
500            force: BTreeMap::new(),
501            images: BTreeMap::new(),
502            deployed_at: 0,
503            deployed_by: String::new(),
504            previous: None,
505        }
506    }
507
508    #[test]
509    fn revision_ignores_replicas_and_rollout_settings() {
510        let a = def("services:\n  web: {image: x, deploy: {replicas: 1}}\n");
511        let b = def(
512            "services:\n  web: {image: x, deploy: {replicas: 5, update_config: {order: start-first}}}\n",
513        );
514        assert_eq!(a.revision("web").unwrap(), b.revision("web").unwrap());
515        let c = def("services:\n  web: {image: y}\n");
516        assert_ne!(a.revision("web").unwrap(), c.revision("web").unwrap());
517        let mut d = a.clone();
518        d.force.insert("web".into(), 1);
519        assert_ne!(a.revision("web").unwrap(), d.revision("web").unwrap());
520    }
521
522    fn binding(name: &str, version: u64) -> SecretBinding {
523        SecretBinding {
524            name: name.into(),
525            driver: "local".into(),
526            version,
527            owned: false,
528        }
529    }
530
531    #[test]
532    fn revision_follows_secret_versions() {
533        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";
534        let mut a = def(y);
535        a.secrets.insert("k".into(), binding("k", 1));
536        a.secrets.insert("e".into(), binding("e", 1));
537        let (web, api) = (a.revision("web").unwrap(), a.revision("api").unwrap());
538        // A new version of the file secret rolls web, not api.
539        let mut b = a.clone();
540        b.secrets.get_mut("k").unwrap().version = 2;
541        assert_ne!(b.revision("web").unwrap(), web);
542        assert_eq!(b.revision("api").unwrap(), api);
543        // A new version of the env secret rolls api, not web.
544        let mut c = a.clone();
545        c.secrets.get_mut("e").unwrap().version = 2;
546        assert_eq!(c.revision("web").unwrap(), web);
547        assert_ne!(c.revision("api").unwrap(), api);
548        // So does pointing it at another store name, at the same version.
549        let mut d = a.clone();
550        d.secrets.get_mut("e").unwrap().name = "other".into();
551        assert_ne!(d.revision("api").unwrap(), api);
552        // And delivering it as another variable.
553        let mut e = a.clone();
554        let env = &mut e.file.services.get_mut("api").unwrap().env.secrets;
555        env.clear();
556        env.insert("TOKEN2".into(), "e".into());
557        assert_ne!(e.revision("api").unwrap(), api);
558        // Bookkeeping is not part of it.
559        let mut f = a.clone();
560        f.secrets.get_mut("k").unwrap().owned = true;
561        f.deployed_at = 99;
562        assert_eq!(f.revision("web").unwrap(), web);
563    }
564
565    #[test]
566    fn udp_ports_are_part_of_the_revision_tcp_ports_are_not() {
567        let rev = |ports: &str| {
568            def(&format!("services:\n  m: {{image: x, ports: {ports}}}\n"))
569                .revision("m")
570                .unwrap()
571        };
572        let none = rev("[]");
573        assert_eq!(rev("['8080:80']"), none);
574        let udp = rev("['203.0.113.7:10000:10000/udp']");
575        assert_ne!(udp, none);
576        assert_ne!(rev("['203.0.113.7:10001:10000/udp']"), udp);
577    }
578
579    #[test]
580    fn on_change_decides_what_a_new_version_rolls() {
581        let y = concat!(
582            "secrets:\n",
583            "  k: {external: true, on_change: restart}\n",
584            "  e: {external: true}\n",
585            "  n: {external: true, on_change: restart}\n",
586            "services:\n",
587            "  web: {image: x, secrets: [k, {source: n, on_change: none}]}\n",
588            "  api: {image: docker:busybox, secrets: [k], environment: {T: {secret: e, on_change: none}, U: {secret: k, on_change: roll}}}\n",
589        );
590        let mut a = def(y);
591        for k in ["k", "e", "n"] {
592            a.secrets.insert(k.into(), binding(k, 1));
593        }
594        // The service's own references win, the strongest of them; then
595        // the top-level setting; then roll.
596        assert_eq!(a.on_change("web", "k"), OnChange::Restart);
597        assert_eq!(a.on_change("web", "n"), OnChange::None);
598        assert_eq!(a.on_change("api", "k"), OnChange::Roll);
599        assert_eq!(a.on_change("api", "e"), OnChange::None);
600        assert_eq!(
601            a.live_secrets("web"),
602            BTreeMap::from([
603                ("k".to_string(), (OnChange::Restart, 1)),
604                ("n".to_string(), (OnChange::None, 1)),
605            ])
606        );
607        assert_eq!(a.services_using("k"), ["api", "web"]);
608        let (web, api) = (a.revision("web").unwrap(), a.revision("api").unwrap());
609        // k: web restarts in place (same revision), api rolls.
610        let mut b = a.clone();
611        b.secrets.get_mut("k").unwrap().version = 2;
612        assert_eq!(b.revision("web").unwrap(), web);
613        assert_ne!(b.revision("api").unwrap(), api);
614        // e and n roll nothing.
615        let mut c = a.clone();
616        c.secrets.get_mut("e").unwrap().version = 2;
617        c.secrets.get_mut("n").unwrap().version = 2;
618        assert_eq!(c.revision("web").unwrap(), web);
619        assert_eq!(c.revision("api").unwrap(), api);
620        // Moving between restart and none is not an instance change.
621        let mut d = a.clone();
622        d.file.secrets.get_mut("k").unwrap().on_change = Some(OnChange::None);
623        assert_eq!(d.revision("web").unwrap(), web);
624        // An explicit roll hashes exactly as no setting at all.
625        let plain = def(&y
626            .replace(", on_change: restart}", "}")
627            .replace(", on_change: none}", "}")
628            .replace(", on_change: roll}", "}"));
629        let mut p = plain.clone();
630        p.secrets = a.secrets.clone();
631        let mut r = p.clone();
632        for s in r.file.secrets.values_mut() {
633            s.on_change = Some(OnChange::Roll);
634        }
635        assert_eq!(p.revision("api").unwrap(), r.revision("api").unwrap());
636        assert_eq!(p.revision("web").unwrap(), r.revision("web").unwrap());
637        // The file round-trips its settings.
638        let y2 = serde_yaml_ng::to_string(&a.file).unwrap();
639        assert!(y2.contains("on_change: none"), "{y2}");
640        let back: ComposeFile = serde_yaml_ng::from_str(&y2).unwrap();
641        assert_eq!(back, a.file);
642    }
643
644    #[test]
645    fn revision_without_secrets_is_unchanged_by_bindings() {
646        // A stack with no secrets hashes exactly as before secrets became
647        // references, so upgrading never rolls it.
648        let a = def("services:\n  web: {image: x, environment: {A: '1'}}\n");
649        assert_eq!(
650            a.revision("web").unwrap(),
651            a.revision_with("web", &|_| b"ignored".to_vec()).unwrap()
652        );
653    }
654
655    #[test]
656    fn store_secrets_are_the_bound_names() {
657        let mut d = def(concat!(
658            "secrets:\n",
659            "  a: {external: true}\n",
660            "  b: {external: true, name: db.password}\n",
661            "  c: {environment: C}\n",
662            "  e: {external: true}\n",
663            "  unused: {external: true}\n",
664            "services:\n",
665            "  web: {image: x, secrets: [a, c]}\n",
666            "  db: {image: x, secrets: [{source: b, target: pw}], command: [x], environment: {E: {secret: e}}}\n",
667        ));
668        d.secrets.insert("a".into(), binding("a", 1));
669        d.secrets.insert("b".into(), binding("db.password", 1));
670        d.secrets.insert("c".into(), binding("app_c", 1));
671        d.secrets.insert("e".into(), binding("e", 1));
672        // A stale binding for a key no service uses does not count.
673        d.secrets.insert("unused".into(), binding("unused", 1));
674        let s: Vec<String> = d.store_secrets().into_iter().collect();
675        assert_eq!(s, ["a", "app_c", "db.password", "e"]);
676    }
677
678    #[test]
679    fn environment_round_trips_with_secrets() {
680        let d = def(
681            "services:\n  web: {image: x, environment: {A: 1, T: {secret: tok}}}\nsecrets: {tok: {external: true}}\n",
682        );
683        let env = &d.file.services["web"].env;
684        assert_eq!(env["A"], "1");
685        assert_eq!(env.secrets["T"], "tok");
686        let json = serde_json::to_string(&d.file).unwrap();
687        assert!(
688            json.contains(r#""environment":{"A":"1","T":{"secret":"tok"}}"#),
689            "{json}"
690        );
691        let back: crate::spec::ComposeFile = serde_json::from_str(&json).unwrap();
692        assert_eq!(back, d.file);
693        assert!(
694            serde_yaml_ng::from_str::<crate::spec::SandboxSpec>(
695                "image: x\nenvironment: {T: {secret: tok, extra: 1}}\n"
696            )
697            .is_err()
698        );
699        assert!(
700            serde_yaml_ng::from_str::<crate::spec::SandboxSpec>(
701                "image: x\nenvironment: {T: {secret: ''}}\n"
702            )
703            .is_err()
704        );
705    }
706
707    #[test]
708    fn names() {
709        assert_eq!(
710            instance_name("app", "web", 2, "ab12").unwrap(),
711            "app-web-2-ab12"
712        );
713        assert!(instance_name(&"a".repeat(30), &"b".repeat(40), 1, "ab12").is_err());
714        assert!(validate_stack_name("my-app").is_ok());
715        assert!(validate_stack_name("My_App").is_err());
716        assert!(validate_stack_name("1app").is_err());
717    }
718
719    #[test]
720    fn store_round_trip() {
721        let dir = tempfile::tempdir().unwrap();
722        let s = Store::open(dir.path()).unwrap();
723        let d = def("services:\n  web: {image: x}\n");
724        s.save(&d).unwrap();
725        let all = s.load_all().unwrap();
726        assert_eq!(all.len(), 1);
727        assert_eq!(all[0].name, "app");
728        use std::os::unix::fs::PermissionsExt;
729        let mode = std::fs::metadata(dir.path().join("stacks/app.json"))
730            .unwrap()
731            .permissions()
732            .mode();
733        assert_eq!(mode & 0o777, 0o600);
734        let mut other = d.clone();
735        other.org = OrgId::new("alpha").unwrap();
736        s.save(&other).unwrap();
737        assert!(dir.path().join("orgs/alpha/stacks/app.json").is_file());
738        let all = s.load_all().unwrap();
739        assert_eq!(
740            all.iter().map(|d| d.qualified()).collect::<Vec<_>>(),
741            vec!["alpha/app", "app"]
742        );
743        s.remove(&OrgId::default_org(), "app").unwrap();
744        s.remove(&other.org, "app").unwrap();
745        assert!(s.load_all().unwrap().is_empty());
746    }
747}