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, OnChange, 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    /// unless its `on_change` for the service is `restart` or `none`: then
107    /// the version is left out, and a new one is delivered to the running
108    /// replicas instead ([`StackDef::live_secrets`]).
109    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    /// What a new version of the top-level secret `key` does to `service`:
123    /// the service's own references first, then the secret's `on_change`,
124    /// then `roll`.
125    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    /// The secrets `service` uses whose new versions reach its running
135    /// replicas in place (`on_change: restart` or `none`), with the setting
136    /// and the version bound now.
137    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    /// The services using the top-level secret `key`.
152    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    /// [`StackDef::revision`] with each secret's contribution given.
162    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        // What a new secret version does is not part of the instance; the
172        // version itself counts below, or not.
173        for r in &mut s.secrets {
174            r.on_change = None;
175        }
176        s.env.on_change.clear();
177        // Domains are the ingress's: changing them never replaces an instance.
178        s.domains.clear();
179        // Published ports are the balancer's, not the instance's (UDP: below).
180        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        // Named volumes are part of the instance's devices; their definitions
201        // are only used at creation, but a renamed one must move the instance.
202        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        // Only when there is one, so stacks without registry images keep
209        // their revisions.
210        if let Some(d) = self.images.get(service) {
211            h.write(b"image");
212            h.write(d.as_bytes());
213        }
214        // A UDP port is a device on the instance (see `ports`), so a changed
215        // one replaces it. Only when there is one, as above.
216        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    /// Names in the org's store that the stack's services use (external
225    /// ones, and the `<stack>_<key>` ones it owns): what `isb secret rm`
226    /// must not pull out from under it.
227    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    /// A service's image as its instances get it: a `registry:` tag pinned
237    /// to the digest it named at deploy time ([`StackDef::images`]).
238    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
256/// FNV-1a, 64-bit: stable across builds and platforms, unlike std's hasher.
257struct 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        // A separator, so ("ab", "c") and ("a", "bc") differ.
269        self.0 ^= 0xff;
270        self.0 = self.0.wrapping_mul(0x100000001b3);
271    }
272    fn finish(&self) -> u64 {
273        self.0
274    }
275}
276
277/// Valid stack name: what an instance name prefix allows.
278pub 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
295/// `<stack>-<service>-<slot>-<id>`, checked against incus' 63-character limit.
296pub 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
309/// A short random id for a new instance.
310pub 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/// Where stack definitions live: `$XDG_STATE_HOME/isb` (else
320/// `~/.local/state/isb`), one 0600 file per stack under `stacks/`.
321#[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    /// Default-org stacks stay where they always were, under `stacks/`.
354    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    /// Every stored definition's file, in every org.
363    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    /// Write atomically (temp file, fsync, rename), so a crash never leaves
400    /// half a stack behind.
401    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
442/// Resolve the paths the local CLI sends with a deploy: the project's own
443/// directory, so relative binds and `file:` secrets work as with `isb up`.
444pub 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    // Only `file:`/`environment:` values travel; the daemon reads the rest
451    // from the org's store.
452    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        // A new version of the file secret rolls web, not api.
526        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        // A new version of the env secret rolls api, not web.
531        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        // So does pointing it at another store name, at the same version.
536        let mut d = a.clone();
537        d.secrets.get_mut("e").unwrap().name = "other".into();
538        assert_ne!(d.revision("api").unwrap(), api);
539        // And delivering it as another variable.
540        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        // Bookkeeping is not part of it.
546        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        // The service's own references win, the strongest of them; then
582        // the top-level setting; then roll.
583        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        // k: web restarts in place (same revision), api rolls.
597        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        // e and n roll nothing.
602        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        // Moving between restart and none is not an instance change.
608        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        // An explicit roll hashes exactly as no setting at all.
612        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        // The file round-trips its settings.
625        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        // A stack with no secrets hashes exactly as before secrets became
634        // references, so upgrading never rolls it.
635        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        // A stale binding for a key no service uses does not count.
660        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}