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        for (var, key) in &spec.env.files {
212            h.write(b"envfile");
213            h.write(var.as_bytes());
214            h.write(&secret(key));
215        }
216        // Named volumes are part of the instance's devices; their definitions
217        // are only used at creation, but a renamed one must move the instance.
218        for v in &spec.volumes {
219            if let Some(d) = self.file.volumes.get(&v.source) {
220                h.write(serde_json::to_string(d)?.as_bytes());
221            }
222        }
223        h.write(&self.force.get(service).copied().unwrap_or(0).to_le_bytes());
224        // Only when there is one, so stacks without registry images keep
225        // their revisions.
226        if let Some(d) = self.images.get(service) {
227            h.write(b"image");
228            h.write(d.as_bytes());
229        }
230        // A UDP port is a device on the instance (see `ports`), so a changed
231        // one replaces it. Only when there is one, as above.
232        for p in ports::published(spec).unwrap_or_default() {
233            if p.udp {
234                h.write(format!("udp {} {}", p.listen, p.target).as_bytes());
235            }
236        }
237        Ok(format!("{:08x}", h.finish() as u32))
238    }
239
240    /// Names in the org's store that the stack's services use (external
241    /// ones, and the `<stack>_<key>` ones it owns): what `isb secret rm`
242    /// must not pull out from under it.
243    pub fn store_secrets(&self) -> std::collections::BTreeSet<String> {
244        let used = secrets::used_keys(&self.file);
245        self.secrets
246            .iter()
247            .filter(|(k, _)| used.contains(*k))
248            .map(|(_, b)| b.name.clone())
249            .collect()
250    }
251
252    /// A service's image as its instances get it: a `registry:` tag pinned
253    /// to the digest it named at deploy time ([`StackDef::images`]).
254    pub fn instance_image(&self, service: &str, image: &str) -> String {
255        let (Some(r), Some(d)) = (image.strip_prefix("registry:"), self.images.get(service)) else {
256            return image.to_string();
257        };
258        match crate::registry::ImageRef::parse(r) {
259            Ok(r) if r.digest.is_none() => format!("registry:{}", r.pinned(d).render()),
260            _ => image.to_string(),
261        }
262    }
263
264    pub fn service(&self, service: &str) -> Result<&SandboxSpec> {
265        self.file
266            .services
267            .get(service)
268            .ok_or_else(|| Error::NotFound(format!("service {service} in stack {}", self.name)))
269    }
270}
271
272/// FNV-1a, 64-bit: stable across builds and platforms, unlike std's hasher.
273struct Fnv64(u64);
274
275impl Fnv64 {
276    fn new() -> Self {
277        Fnv64(0xcbf29ce484222325)
278    }
279    fn write(&mut self, b: &[u8]) {
280        for x in b {
281            self.0 ^= *x as u64;
282            self.0 = self.0.wrapping_mul(0x100000001b3);
283        }
284        // A separator, so ("ab", "c") and ("a", "bc") differ.
285        self.0 ^= 0xff;
286        self.0 = self.0.wrapping_mul(0x100000001b3);
287    }
288    fn finish(&self) -> u64 {
289        self.0
290    }
291}
292
293/// Valid stack name: what an instance name prefix allows.
294pub fn validate_stack_name(name: &str) -> Result<()> {
295    let ok = !name.is_empty()
296        && name.len() <= 30
297        && name.starts_with(|c: char| c.is_ascii_lowercase())
298        && !name.ends_with('-')
299        && name
300            .chars()
301            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
302    if ok {
303        Ok(())
304    } else {
305        Err(Error::invalid(format!(
306            "stack name {name:?}: up to 30 characters of [a-z0-9-], starting with a letter"
307        )))
308    }
309}
310
311/// incus' limit on an instance name.
312pub const INSTANCE_NAME_MAX: usize = 63;
313
314/// `<stack>-<service>-<slot>-<id>`, within incus' 63-character limit. A
315/// name that would be longer keeps the start of the service name plus a
316/// hash of all of it (`<stack>-<svc-prefix>-<hash6>-<slot>-<id>`), so two
317/// long services in one stack still differ. Nothing parses these names
318/// back: replicas are found by their `user.isb.*` labels.
319pub fn instance_name(stack: &str, service: &str, slot: u32, id: &str) -> Result<String> {
320    let svc = crate::compose::sanitize_name(service);
321    let n = format!("{stack}-{svc}-{slot}-{id}");
322    if n.len() <= INSTANCE_NAME_MAX {
323        crate::plan::validate_instance_name(&n)?;
324        return Ok(n);
325    }
326    let mut h = Fnv64::new();
327    h.write(service.as_bytes());
328    let hash = format!("{:06x}", h.finish() & 0xff_ffff);
329    let tail = format!("-{hash}-{slot}-{id}");
330    // What the service prefix may use: the stack, its `-`, and the tail.
331    let room = INSTANCE_NAME_MAX.saturating_sub(stack.len() + 1 + tail.len());
332    if room < 1 {
333        return Err(Error::invalid(format!(
334            "instance names of service {service:?} in stack {stack:?} (slot {slot}) cannot fit incus' {INSTANCE_NAME_MAX} characters even with the service name shortened; shorten the stack name ({} characters) or use fewer replicas",
335            stack.len()
336        )));
337    }
338    let prefix: String = svc.chars().take(room).collect();
339    let n = format!("{stack}-{}{tail}", prefix.trim_end_matches('-'));
340    crate::plan::validate_instance_name(&n)?;
341    Ok(n)
342}
343
344/// Whether `<stack>-<service>-<slot>-<id>` fits as is, without the
345/// shortening `instance_name` falls back to.
346pub fn instance_name_fits(stack: &str, service: &str, slot: u32) -> bool {
347    let n = format!(
348        "{stack}-{}-{slot}-0000",
349        crate::compose::sanitize_name(service)
350    );
351    n.len() <= INSTANCE_NAME_MAX && crate::plan::validate_instance_name(&n).is_ok()
352}
353
354/// The first valid stack name of `candidates` whose instance names of
355/// `service` fit unshortened, else the first `instance_name` can shorten.
356/// Unshortened first, because that is how an existing stack (a running
357/// preview's) was picked, so it keeps its name.
358pub fn pick_stack_name(candidates: &[String], service: &str, slot: u32) -> Option<String> {
359    let valid = candidates.iter().filter(|s| validate_stack_name(s).is_ok());
360    valid
361        .clone()
362        .find(|s| instance_name_fits(s, service, slot))
363        .or_else(|| {
364            valid
365                .clone()
366                .find(|s| instance_name(s, service, slot, "0000").is_ok())
367        })
368        .cloned()
369}
370
371/// A short random id for a new instance.
372pub fn new_id() -> String {
373    let mut b = [0u8; 2];
374    if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
375        use std::io::Read;
376        let _ = f.read_exact(&mut b);
377    }
378    format!("{:02x}{:02x}", b[0], b[1])
379}
380
381/// Where stack definitions live: `$XDG_STATE_HOME/isb` (else
382/// `~/.local/state/isb`), one 0600 file per stack under `stacks/`.
383#[derive(Debug, Clone)]
384pub struct Store {
385    dir: PathBuf,
386}
387
388impl Store {
389    pub fn default_dir() -> PathBuf {
390        std::env::var_os("XDG_STATE_HOME")
391            .filter(|s| !s.is_empty())
392            .map(PathBuf::from)
393            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
394            .unwrap_or_else(|| PathBuf::from("/var/lib"))
395            .join("isb")
396    }
397
398    pub fn open(dir: impl Into<PathBuf>) -> Result<Store> {
399        let dir = dir.into();
400        let stacks = dir.join("stacks");
401        std::fs::create_dir_all(&stacks)?;
402        set_mode(&dir, 0o700)?;
403        set_mode(&stacks, 0o700)?;
404        Ok(Store { dir })
405    }
406
407    pub fn dir(&self) -> &Path {
408        &self.dir
409    }
410
411    fn path(&self, org: &OrgId, name: &str) -> PathBuf {
412        self.stacks_dir(org).join(format!("{name}.json"))
413    }
414
415    /// Default-org stacks stay where they always were, under `stacks/`.
416    fn stacks_dir(&self, org: &OrgId) -> PathBuf {
417        if org.is_default() {
418            self.dir.join("stacks")
419        } else {
420            org.dir(&self.dir).join("stacks")
421        }
422    }
423
424    /// Every stored definition's file, in every org.
425    pub fn files(&self) -> Result<Vec<PathBuf>> {
426        let mut dirs = vec![self.dir.join("stacks")];
427        if let Ok(rd) = std::fs::read_dir(self.dir.join("orgs")) {
428            for e in rd.flatten() {
429                dirs.push(e.path().join("stacks"));
430            }
431        }
432        let mut out = Vec::new();
433        for d in dirs {
434            let Ok(rd) = std::fs::read_dir(&d) else {
435                continue;
436            };
437            for e in rd {
438                let p = e?.path();
439                if p.extension().is_some_and(|x| x == "json") {
440                    out.push(p);
441                }
442            }
443        }
444        out.sort();
445        Ok(out)
446    }
447
448    pub fn load_all(&self) -> Result<Vec<StackDef>> {
449        let mut out = Vec::new();
450        for p in self.files()? {
451            let text = std::fs::read_to_string(&p)?;
452            match serde_json::from_str::<StackDef>(&text) {
453                Ok(d) => out.push(d),
454                Err(e) => eprintln!("isb serve: skipping {}: {e}", p.display()),
455            }
456        }
457        out.sort_by_key(|d| d.qualified());
458        Ok(out)
459    }
460
461    /// Write atomically (temp file, fsync, rename), so a crash never leaves
462    /// half a stack behind.
463    pub fn save(&self, def: &StackDef) -> Result<()> {
464        use std::io::Write;
465        use std::os::unix::fs::OpenOptionsExt;
466        let dir = self.stacks_dir(&def.org);
467        std::fs::create_dir_all(&dir)?;
468        set_mode(&dir, 0o700)?;
469        let path = self.path(&def.org, &def.name);
470        let tmp = path.with_extension("json.tmp");
471        let mut f = std::fs::OpenOptions::new()
472            .write(true)
473            .create(true)
474            .truncate(true)
475            .mode(0o600)
476            .open(&tmp)?;
477        f.write_all(serde_json::to_string_pretty(def)?.as_bytes())?;
478        f.sync_all()?;
479        std::fs::rename(&tmp, &path)?;
480        Ok(())
481    }
482
483    pub fn remove(&self, org: &OrgId, name: &str) -> Result<()> {
484        match std::fs::remove_file(self.path(org, name)) {
485            Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
486            _ => Ok(()),
487        }
488    }
489}
490
491fn set_mode(p: &Path, mode: u32) -> Result<()> {
492    use std::os::unix::fs::PermissionsExt;
493    std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode))?;
494    Ok(())
495}
496
497pub fn now_secs() -> u64 {
498    std::time::SystemTime::now()
499        .duration_since(std::time::UNIX_EPOCH)
500        .map(|d| d.as_secs())
501        .unwrap_or(0)
502}
503
504/// Resolve the paths the local CLI sends with a deploy: the project's own
505/// directory, so relative binds and `file:` secrets work as with `isb up`.
506pub fn local_deploy_args(
507    project: &crate::compose::Project,
508    name: &str,
509    wait: bool,
510    timeout: Option<&str>,
511) -> crate::Result<serde_json::Value> {
512    // Only `file:`/`environment:` values travel; the daemon reads the rest
513    // from the org's store.
514    let secrets: std::collections::BTreeMap<String, String> =
515        crate::supervise::resolve_secret_values(&project.file, &project.base_dir, &|k| {
516            project.lookup(k)
517        })?
518        .into_iter()
519        .map(|(k, v)| {
520            String::from_utf8(v)
521                .map(|s| (k.clone(), s))
522                .map_err(|_| crate::Error::invalid(format!("secret {k:?} is not UTF-8 text")))
523        })
524        .collect::<crate::Result<_>>()?;
525    let mut v = serde_json::json!({
526        "name": name,
527        "file": project.file,
528        "base_dir": project.base_dir,
529        "secrets": secrets,
530        "wait": wait,
531    });
532    if let Some(t) = timeout {
533        v["timeout"] = serde_json::json!(t);
534    }
535    Ok(v)
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    fn def(y: &str) -> StackDef {
543        StackDef {
544            source: None,
545            domains: Default::default(),
546            name: "app".into(),
547            org: OrgId::default_org(),
548            file: serde_yaml_ng::from_str(y).unwrap(),
549            base_dir: "/".into(),
550            secrets: BTreeMap::new(),
551            force: BTreeMap::new(),
552            images: BTreeMap::new(),
553            deployed_at: 0,
554            deployed_by: String::new(),
555            previous: None,
556        }
557    }
558
559    #[test]
560    fn revision_ignores_replicas_and_rollout_settings() {
561        let a = def("services:\n  web: {image: x, deploy: {replicas: 1}}\n");
562        let b = def(
563            "services:\n  web: {image: x, deploy: {replicas: 5, update_config: {order: start-first}}}\n",
564        );
565        assert_eq!(a.revision("web").unwrap(), b.revision("web").unwrap());
566        let c = def("services:\n  web: {image: y}\n");
567        assert_ne!(a.revision("web").unwrap(), c.revision("web").unwrap());
568        let mut d = a.clone();
569        d.force.insert("web".into(), 1);
570        assert_ne!(a.revision("web").unwrap(), d.revision("web").unwrap());
571    }
572
573    #[test]
574    fn revision_of_an_existing_spec_is_pinned() {
575        // Upgrading isb must not roll a service whose spec did not change: new
576        // mount and command fields serialize only when set.
577        let a = def(concat!(
578            "volumes: {data: {}}\n",
579            "services:\n",
580            "  web:\n",
581            "    image: docker:busybox\n",
582            "    user: '1000:1000'\n",
583            "    entrypoint: [sh, -c]\n",
584            "    command: ['echo $$HOME; exec sleep 1d']\n",
585            "    volumes:\n",
586            "      - data:/data\n",
587            "      - {source: data, target: /home/x, owner: '1000:1000'}\n",
588        ));
589        assert_eq!(a.revision("web").unwrap(), "d59025b7");
590    }
591
592    #[test]
593    fn an_org_disk_limit_sizes_the_request_not_the_revision() {
594        use crate::client::fake::{Route, serve};
595        let a = def("services:\n  web:\n    image: docker:busybox\n    command: [sleep, 1d]\n");
596        let rev = a.revision("web").unwrap();
597        assert_eq!(rev, "9e74ea35");
598        let body = || {
599            serde_json::json!({
600                "name": "app-web-1-x", "config": {LABEL_REV: rev},
601                "devices": {"root": {"type": "disk", "path": "/", "pool": "default"}},
602            })
603        };
604        for limited in [false, true] {
605            let config = if limited {
606                serde_json::json!({"limits.disk": "100GiB"})
607            } else {
608                serde_json::json!({})
609            };
610            let (_d, c) = serve(vec![Route {
611                prefix: "GET /1.0/projects/isb-default",
612                status: 200,
613                body: serde_json::json!({"config": config}),
614            }]);
615            let c = c.project("isb-default");
616            let sent = crate::org::disk::sized_root(&c, body());
617            // The revision, and the label carrying it, are the same either way.
618            assert_eq!(a.revision("web").unwrap(), rev);
619            assert_eq!(sent["config"], body()["config"]);
620            let size = sent["devices"]["root"].get("size");
621            assert_eq!(size.is_some(), limited, "{sent}");
622        }
623    }
624
625    fn binding(name: &str, version: u64) -> SecretBinding {
626        SecretBinding {
627            name: name.into(),
628            driver: "local".into(),
629            version,
630            owned: false,
631        }
632    }
633
634    #[test]
635    fn revision_follows_secret_versions() {
636        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";
637        let mut a = def(y);
638        a.secrets.insert("k".into(), binding("k", 1));
639        a.secrets.insert("e".into(), binding("e", 1));
640        let (web, api) = (a.revision("web").unwrap(), a.revision("api").unwrap());
641        // A new version of the file secret rolls web, not api.
642        let mut b = a.clone();
643        b.secrets.get_mut("k").unwrap().version = 2;
644        assert_ne!(b.revision("web").unwrap(), web);
645        assert_eq!(b.revision("api").unwrap(), api);
646        // A new version of the env secret rolls api, not web.
647        let mut c = a.clone();
648        c.secrets.get_mut("e").unwrap().version = 2;
649        assert_eq!(c.revision("web").unwrap(), web);
650        assert_ne!(c.revision("api").unwrap(), api);
651        // So does pointing it at another store name, at the same version.
652        let mut d = a.clone();
653        d.secrets.get_mut("e").unwrap().name = "other".into();
654        assert_ne!(d.revision("api").unwrap(), api);
655        // And delivering it as another variable.
656        let mut e = a.clone();
657        let env = &mut e.file.services.get_mut("api").unwrap().env.secrets;
658        env.clear();
659        env.insert("TOKEN2".into(), "e".into());
660        assert_ne!(e.revision("api").unwrap(), api);
661        // Bookkeeping is not part of it.
662        let mut f = a.clone();
663        f.secrets.get_mut("k").unwrap().owned = true;
664        f.deployed_at = 99;
665        assert_eq!(f.revision("web").unwrap(), web);
666    }
667
668    /// Specs written before `as: file` (secret variables, secret files, a
669    /// healthcheck without `start_period`) keep their revisions, so an
670    /// upgraded daemon replaces none of their instances. `as: file` is a
671    /// revision of its own.
672    #[test]
673    fn existing_specs_keep_their_revisions() {
674        let y = "secrets: {k: {external: true}, e: {external: true}}\nservices:\n  web: {image: x, secrets: [k], healthcheck: {test: [CMD, true], interval: 5s}}\n  api: {image: docker:busybox, environment: {PLAIN: '1', TOKEN: {secret: e}, OTHER: {secret: k, on_change: none}}}\n";
675        let mut a = def(y);
676        a.secrets.insert("k".into(), binding("k", 1));
677        a.secrets.insert("e".into(), binding("e", 1));
678        assert_eq!(a.revision("web").unwrap(), "76486ba4");
679        assert_eq!(a.revision("api").unwrap(), "8d6c644f");
680        let file = y.replace("TOKEN: {secret: e}", "TOKEN: {secret: e, as: file}");
681        let mut b = def(&file);
682        b.secrets = a.secrets.clone();
683        assert_ne!(b.revision("api").unwrap(), a.revision("api").unwrap());
684        let env = y.replace("TOKEN: {secret: e}", "TOKEN: {secret: e, as: env}");
685        let mut c = def(&env);
686        c.secrets = a.secrets.clone();
687        assert_eq!(c.revision("api").unwrap(), a.revision("api").unwrap());
688    }
689
690    #[test]
691    fn udp_ports_are_part_of_the_revision_tcp_ports_are_not() {
692        let rev = |ports: &str| {
693            def(&format!("services:\n  m: {{image: x, ports: {ports}}}\n"))
694                .revision("m")
695                .unwrap()
696        };
697        let none = rev("[]");
698        assert_eq!(rev("['8080:80']"), none);
699        let udp = rev("['203.0.113.7:10000:10000/udp']");
700        assert_ne!(udp, none);
701        assert_ne!(rev("['203.0.113.7:10001:10000/udp']"), udp);
702    }
703
704    #[test]
705    fn on_change_decides_what_a_new_version_rolls() {
706        let y = concat!(
707            "secrets:\n",
708            "  k: {external: true, on_change: restart}\n",
709            "  e: {external: true}\n",
710            "  n: {external: true, on_change: restart}\n",
711            "services:\n",
712            "  web: {image: x, secrets: [k, {source: n, on_change: none}]}\n",
713            "  api: {image: docker:busybox, secrets: [k], environment: {T: {secret: e, on_change: none}, U: {secret: k, on_change: roll}}}\n",
714        );
715        let mut a = def(y);
716        for k in ["k", "e", "n"] {
717            a.secrets.insert(k.into(), binding(k, 1));
718        }
719        // The service's own references win, the strongest of them; then
720        // the top-level setting; then roll.
721        assert_eq!(a.on_change("web", "k"), OnChange::Restart);
722        assert_eq!(a.on_change("web", "n"), OnChange::None);
723        assert_eq!(a.on_change("api", "k"), OnChange::Roll);
724        assert_eq!(a.on_change("api", "e"), OnChange::None);
725        assert_eq!(
726            a.live_secrets("web"),
727            BTreeMap::from([
728                ("k".to_string(), (OnChange::Restart, 1)),
729                ("n".to_string(), (OnChange::None, 1)),
730            ])
731        );
732        assert_eq!(a.services_using("k"), ["api", "web"]);
733        let (web, api) = (a.revision("web").unwrap(), a.revision("api").unwrap());
734        // k: web restarts in place (same revision), api rolls.
735        let mut b = a.clone();
736        b.secrets.get_mut("k").unwrap().version = 2;
737        assert_eq!(b.revision("web").unwrap(), web);
738        assert_ne!(b.revision("api").unwrap(), api);
739        // e and n roll nothing.
740        let mut c = a.clone();
741        c.secrets.get_mut("e").unwrap().version = 2;
742        c.secrets.get_mut("n").unwrap().version = 2;
743        assert_eq!(c.revision("web").unwrap(), web);
744        assert_eq!(c.revision("api").unwrap(), api);
745        // Moving between restart and none is not an instance change.
746        let mut d = a.clone();
747        d.file.secrets.get_mut("k").unwrap().on_change = Some(OnChange::None);
748        assert_eq!(d.revision("web").unwrap(), web);
749        // An explicit roll hashes exactly as no setting at all.
750        let plain = def(&y
751            .replace(", on_change: restart}", "}")
752            .replace(", on_change: none}", "}")
753            .replace(", on_change: roll}", "}"));
754        let mut p = plain.clone();
755        p.secrets = a.secrets.clone();
756        let mut r = p.clone();
757        for s in r.file.secrets.values_mut() {
758            s.on_change = Some(OnChange::Roll);
759        }
760        assert_eq!(p.revision("api").unwrap(), r.revision("api").unwrap());
761        assert_eq!(p.revision("web").unwrap(), r.revision("web").unwrap());
762        // The file round-trips its settings.
763        let y2 = serde_yaml_ng::to_string(&a.file).unwrap();
764        assert!(y2.contains("on_change: none"), "{y2}");
765        let back: ComposeFile = serde_yaml_ng::from_str(&y2).unwrap();
766        assert_eq!(back, a.file);
767    }
768
769    #[test]
770    fn revision_without_secrets_is_unchanged_by_bindings() {
771        // A stack with no secrets hashes exactly as before secrets became
772        // references, so upgrading never rolls it.
773        let a = def("services:\n  web: {image: x, environment: {A: '1'}}\n");
774        assert_eq!(
775            a.revision("web").unwrap(),
776            a.revision_with("web", &|_| b"ignored".to_vec()).unwrap()
777        );
778    }
779
780    #[test]
781    fn store_secrets_are_the_bound_names() {
782        let mut d = def(concat!(
783            "secrets:\n",
784            "  a: {external: true}\n",
785            "  b: {external: true, name: db.password}\n",
786            "  c: {environment: C}\n",
787            "  e: {external: true}\n",
788            "  unused: {external: true}\n",
789            "services:\n",
790            "  web: {image: x, secrets: [a, c]}\n",
791            "  db: {image: x, secrets: [{source: b, target: pw}], command: [x], environment: {E: {secret: e}}}\n",
792        ));
793        d.secrets.insert("a".into(), binding("a", 1));
794        d.secrets.insert("b".into(), binding("db.password", 1));
795        d.secrets.insert("c".into(), binding("app_c", 1));
796        d.secrets.insert("e".into(), binding("e", 1));
797        // A stale binding for a key no service uses does not count.
798        d.secrets.insert("unused".into(), binding("unused", 1));
799        let s: Vec<String> = d.store_secrets().into_iter().collect();
800        assert_eq!(s, ["a", "app_c", "db.password", "e"]);
801    }
802
803    #[test]
804    fn environment_round_trips_with_secrets() {
805        let d = def(
806            "services:\n  web: {image: x, environment: {A: 1, T: {secret: tok}}}\nsecrets: {tok: {external: true}}\n",
807        );
808        let env = &d.file.services["web"].env;
809        assert_eq!(env["A"], "1");
810        assert_eq!(env.secrets["T"], "tok");
811        let json = serde_json::to_string(&d.file).unwrap();
812        assert!(
813            json.contains(r#""environment":{"A":"1","T":{"secret":"tok"}}"#),
814            "{json}"
815        );
816        let back: crate::spec::ComposeFile = serde_json::from_str(&json).unwrap();
817        assert_eq!(back, d.file);
818        assert!(
819            serde_yaml_ng::from_str::<crate::spec::SandboxSpec>(
820                "image: x\nenvironment: {T: {secret: tok, extra: 1}}\n"
821            )
822            .is_err()
823        );
824        assert!(
825            serde_yaml_ng::from_str::<crate::spec::SandboxSpec>(
826                "image: x\nenvironment: {T: {secret: ''}}\n"
827            )
828            .is_err()
829        );
830    }
831
832    #[test]
833    fn names() {
834        assert_eq!(
835            instance_name("app", "web", 2, "ab12").unwrap(),
836            "app-web-2-ab12"
837        );
838        assert!(validate_stack_name("my-app").is_ok());
839        assert!(validate_stack_name("My_App").is_err());
840        assert!(validate_stack_name("1app").is_err());
841    }
842
843    #[test]
844    fn names_that_fit_are_unchanged() {
845        // 63 exactly: as it always was.
846        let stack = "project-management-production";
847        let svc = "a".repeat(63 - stack.len() - 1 - "-1-ab12".len());
848        let n = instance_name(stack, &svc, 1, "ab12").unwrap();
849        assert_eq!(n, format!("{stack}-{svc}-1-ab12"));
850        assert_eq!(n.len(), 63);
851        assert!(instance_name_fits(stack, &svc, 1));
852        assert_eq!(
853            instance_name("shop-production", "My_Web", 12, "ab12").unwrap(),
854            "shop-production-my-web-12-ab12"
855        );
856    }
857
858    #[test]
859    fn long_names_are_shortened_with_a_hash() {
860        let stack = "project-management-production";
861        let svc = "project-management-postgres";
862        assert!(!instance_name_fits(stack, svc, 1));
863        let n = instance_name(stack, svc, 1, "ab12").unwrap();
864        assert!(n.len() <= 63, "{n}");
865        assert!(n.starts_with(&format!("{stack}-project-")), "{n}");
866        assert!(n.ends_with("-1-ab12"), "{n}");
867        crate::plan::validate_instance_name(&n).unwrap();
868        // Deterministic.
869        assert_eq!(n, instance_name(stack, svc, 1, "ab12").unwrap());
870        // Two long services sharing a prefix still differ.
871        let other = instance_name(stack, "project-management-postgres-replica", 1, "ab12").unwrap();
872        assert_ne!(n, other);
873        // The slot and id stay at the end, at any width.
874        let wide = instance_name(&"a".repeat(30), &"b".repeat(40), 4_000_000_000, "ab12").unwrap();
875        assert!(
876            wide.len() <= 63 && wide.ends_with("-4000000000-ab12"),
877            "{wide}"
878        );
879        // A stack too long to leave room for any service is refused, saying so.
880        let e = instance_name(&"s".repeat(50), "web-frontend-x", 4_000_000_000, "ab12")
881            .unwrap_err()
882            .to_string();
883        assert!(e.contains("shorten the stack name"), "{e}");
884    }
885
886    #[test]
887    fn stack_names_prefer_unshortened_instances() {
888        let c = ["aaaa-production-pr-1".to_string(), "aaaa-pr-1".to_string()];
889        assert_eq!(pick_stack_name(&c, "web", 100).unwrap(), c[0]);
890        // As before shortening existed: the shorter stack, whose names fit.
891        assert_eq!(pick_stack_name(&c, &"w".repeat(35), 100).unwrap(), c[1]);
892        // Fits nowhere unshortened: the first, shortened.
893        assert_eq!(pick_stack_name(&c, &"w".repeat(60), 100).unwrap(), c[0]);
894        assert_eq!(pick_stack_name(&["Bad".to_string()], "web", 1), None);
895    }
896
897    #[test]
898    fn store_round_trip() {
899        let dir = tempfile::tempdir().unwrap();
900        let s = Store::open(dir.path()).unwrap();
901        let d = def("services:\n  web: {image: x}\n");
902        s.save(&d).unwrap();
903        let all = s.load_all().unwrap();
904        assert_eq!(all.len(), 1);
905        assert_eq!(all[0].name, "app");
906        use std::os::unix::fs::PermissionsExt;
907        let mode = std::fs::metadata(dir.path().join("stacks/app.json"))
908            .unwrap()
909            .permissions()
910            .mode();
911        assert_eq!(mode & 0o777, 0o600);
912        let mut other = d.clone();
913        other.org = OrgId::new("alpha").unwrap();
914        s.save(&other).unwrap();
915        assert!(dir.path().join("orgs/alpha/stacks/app.json").is_file());
916        let all = s.load_all().unwrap();
917        assert_eq!(
918            all.iter().map(|d| d.qualified()).collect::<Vec<_>>(),
919            vec!["alpha/app", "app"]
920        );
921        s.remove(&OrgId::default_org(), "app").unwrap();
922        s.remove(&other.org, "app").unwrap();
923        assert!(s.load_all().unwrap().is_empty());
924    }
925}