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    fn binding(name: &str, version: u64) -> SecretBinding {
593        SecretBinding {
594            name: name.into(),
595            driver: "local".into(),
596            version,
597            owned: false,
598        }
599    }
600
601    #[test]
602    fn revision_follows_secret_versions() {
603        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";
604        let mut a = def(y);
605        a.secrets.insert("k".into(), binding("k", 1));
606        a.secrets.insert("e".into(), binding("e", 1));
607        let (web, api) = (a.revision("web").unwrap(), a.revision("api").unwrap());
608        // A new version of the file secret rolls web, not api.
609        let mut b = a.clone();
610        b.secrets.get_mut("k").unwrap().version = 2;
611        assert_ne!(b.revision("web").unwrap(), web);
612        assert_eq!(b.revision("api").unwrap(), api);
613        // A new version of the env secret rolls api, not web.
614        let mut c = a.clone();
615        c.secrets.get_mut("e").unwrap().version = 2;
616        assert_eq!(c.revision("web").unwrap(), web);
617        assert_ne!(c.revision("api").unwrap(), api);
618        // So does pointing it at another store name, at the same version.
619        let mut d = a.clone();
620        d.secrets.get_mut("e").unwrap().name = "other".into();
621        assert_ne!(d.revision("api").unwrap(), api);
622        // And delivering it as another variable.
623        let mut e = a.clone();
624        let env = &mut e.file.services.get_mut("api").unwrap().env.secrets;
625        env.clear();
626        env.insert("TOKEN2".into(), "e".into());
627        assert_ne!(e.revision("api").unwrap(), api);
628        // Bookkeeping is not part of it.
629        let mut f = a.clone();
630        f.secrets.get_mut("k").unwrap().owned = true;
631        f.deployed_at = 99;
632        assert_eq!(f.revision("web").unwrap(), web);
633    }
634
635    /// Specs written before `as: file` (secret variables, secret files, a
636    /// healthcheck without `start_period`) keep their revisions, so an
637    /// upgraded daemon replaces none of their instances. `as: file` is a
638    /// revision of its own.
639    #[test]
640    fn existing_specs_keep_their_revisions() {
641        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";
642        let mut a = def(y);
643        a.secrets.insert("k".into(), binding("k", 1));
644        a.secrets.insert("e".into(), binding("e", 1));
645        assert_eq!(a.revision("web").unwrap(), "76486ba4");
646        assert_eq!(a.revision("api").unwrap(), "8d6c644f");
647        let file = y.replace("TOKEN: {secret: e}", "TOKEN: {secret: e, as: file}");
648        let mut b = def(&file);
649        b.secrets = a.secrets.clone();
650        assert_ne!(b.revision("api").unwrap(), a.revision("api").unwrap());
651        let env = y.replace("TOKEN: {secret: e}", "TOKEN: {secret: e, as: env}");
652        let mut c = def(&env);
653        c.secrets = a.secrets.clone();
654        assert_eq!(c.revision("api").unwrap(), a.revision("api").unwrap());
655    }
656
657    #[test]
658    fn udp_ports_are_part_of_the_revision_tcp_ports_are_not() {
659        let rev = |ports: &str| {
660            def(&format!("services:\n  m: {{image: x, ports: {ports}}}\n"))
661                .revision("m")
662                .unwrap()
663        };
664        let none = rev("[]");
665        assert_eq!(rev("['8080:80']"), none);
666        let udp = rev("['203.0.113.7:10000:10000/udp']");
667        assert_ne!(udp, none);
668        assert_ne!(rev("['203.0.113.7:10001:10000/udp']"), udp);
669    }
670
671    #[test]
672    fn on_change_decides_what_a_new_version_rolls() {
673        let y = concat!(
674            "secrets:\n",
675            "  k: {external: true, on_change: restart}\n",
676            "  e: {external: true}\n",
677            "  n: {external: true, on_change: restart}\n",
678            "services:\n",
679            "  web: {image: x, secrets: [k, {source: n, on_change: none}]}\n",
680            "  api: {image: docker:busybox, secrets: [k], environment: {T: {secret: e, on_change: none}, U: {secret: k, on_change: roll}}}\n",
681        );
682        let mut a = def(y);
683        for k in ["k", "e", "n"] {
684            a.secrets.insert(k.into(), binding(k, 1));
685        }
686        // The service's own references win, the strongest of them; then
687        // the top-level setting; then roll.
688        assert_eq!(a.on_change("web", "k"), OnChange::Restart);
689        assert_eq!(a.on_change("web", "n"), OnChange::None);
690        assert_eq!(a.on_change("api", "k"), OnChange::Roll);
691        assert_eq!(a.on_change("api", "e"), OnChange::None);
692        assert_eq!(
693            a.live_secrets("web"),
694            BTreeMap::from([
695                ("k".to_string(), (OnChange::Restart, 1)),
696                ("n".to_string(), (OnChange::None, 1)),
697            ])
698        );
699        assert_eq!(a.services_using("k"), ["api", "web"]);
700        let (web, api) = (a.revision("web").unwrap(), a.revision("api").unwrap());
701        // k: web restarts in place (same revision), api rolls.
702        let mut b = a.clone();
703        b.secrets.get_mut("k").unwrap().version = 2;
704        assert_eq!(b.revision("web").unwrap(), web);
705        assert_ne!(b.revision("api").unwrap(), api);
706        // e and n roll nothing.
707        let mut c = a.clone();
708        c.secrets.get_mut("e").unwrap().version = 2;
709        c.secrets.get_mut("n").unwrap().version = 2;
710        assert_eq!(c.revision("web").unwrap(), web);
711        assert_eq!(c.revision("api").unwrap(), api);
712        // Moving between restart and none is not an instance change.
713        let mut d = a.clone();
714        d.file.secrets.get_mut("k").unwrap().on_change = Some(OnChange::None);
715        assert_eq!(d.revision("web").unwrap(), web);
716        // An explicit roll hashes exactly as no setting at all.
717        let plain = def(&y
718            .replace(", on_change: restart}", "}")
719            .replace(", on_change: none}", "}")
720            .replace(", on_change: roll}", "}"));
721        let mut p = plain.clone();
722        p.secrets = a.secrets.clone();
723        let mut r = p.clone();
724        for s in r.file.secrets.values_mut() {
725            s.on_change = Some(OnChange::Roll);
726        }
727        assert_eq!(p.revision("api").unwrap(), r.revision("api").unwrap());
728        assert_eq!(p.revision("web").unwrap(), r.revision("web").unwrap());
729        // The file round-trips its settings.
730        let y2 = serde_yaml_ng::to_string(&a.file).unwrap();
731        assert!(y2.contains("on_change: none"), "{y2}");
732        let back: ComposeFile = serde_yaml_ng::from_str(&y2).unwrap();
733        assert_eq!(back, a.file);
734    }
735
736    #[test]
737    fn revision_without_secrets_is_unchanged_by_bindings() {
738        // A stack with no secrets hashes exactly as before secrets became
739        // references, so upgrading never rolls it.
740        let a = def("services:\n  web: {image: x, environment: {A: '1'}}\n");
741        assert_eq!(
742            a.revision("web").unwrap(),
743            a.revision_with("web", &|_| b"ignored".to_vec()).unwrap()
744        );
745    }
746
747    #[test]
748    fn store_secrets_are_the_bound_names() {
749        let mut d = def(concat!(
750            "secrets:\n",
751            "  a: {external: true}\n",
752            "  b: {external: true, name: db.password}\n",
753            "  c: {environment: C}\n",
754            "  e: {external: true}\n",
755            "  unused: {external: true}\n",
756            "services:\n",
757            "  web: {image: x, secrets: [a, c]}\n",
758            "  db: {image: x, secrets: [{source: b, target: pw}], command: [x], environment: {E: {secret: e}}}\n",
759        ));
760        d.secrets.insert("a".into(), binding("a", 1));
761        d.secrets.insert("b".into(), binding("db.password", 1));
762        d.secrets.insert("c".into(), binding("app_c", 1));
763        d.secrets.insert("e".into(), binding("e", 1));
764        // A stale binding for a key no service uses does not count.
765        d.secrets.insert("unused".into(), binding("unused", 1));
766        let s: Vec<String> = d.store_secrets().into_iter().collect();
767        assert_eq!(s, ["a", "app_c", "db.password", "e"]);
768    }
769
770    #[test]
771    fn environment_round_trips_with_secrets() {
772        let d = def(
773            "services:\n  web: {image: x, environment: {A: 1, T: {secret: tok}}}\nsecrets: {tok: {external: true}}\n",
774        );
775        let env = &d.file.services["web"].env;
776        assert_eq!(env["A"], "1");
777        assert_eq!(env.secrets["T"], "tok");
778        let json = serde_json::to_string(&d.file).unwrap();
779        assert!(
780            json.contains(r#""environment":{"A":"1","T":{"secret":"tok"}}"#),
781            "{json}"
782        );
783        let back: crate::spec::ComposeFile = serde_json::from_str(&json).unwrap();
784        assert_eq!(back, d.file);
785        assert!(
786            serde_yaml_ng::from_str::<crate::spec::SandboxSpec>(
787                "image: x\nenvironment: {T: {secret: tok, extra: 1}}\n"
788            )
789            .is_err()
790        );
791        assert!(
792            serde_yaml_ng::from_str::<crate::spec::SandboxSpec>(
793                "image: x\nenvironment: {T: {secret: ''}}\n"
794            )
795            .is_err()
796        );
797    }
798
799    #[test]
800    fn names() {
801        assert_eq!(
802            instance_name("app", "web", 2, "ab12").unwrap(),
803            "app-web-2-ab12"
804        );
805        assert!(validate_stack_name("my-app").is_ok());
806        assert!(validate_stack_name("My_App").is_err());
807        assert!(validate_stack_name("1app").is_err());
808    }
809
810    #[test]
811    fn names_that_fit_are_unchanged() {
812        // 63 exactly: as it always was.
813        let stack = "project-management-production";
814        let svc = "a".repeat(63 - stack.len() - 1 - "-1-ab12".len());
815        let n = instance_name(stack, &svc, 1, "ab12").unwrap();
816        assert_eq!(n, format!("{stack}-{svc}-1-ab12"));
817        assert_eq!(n.len(), 63);
818        assert!(instance_name_fits(stack, &svc, 1));
819        assert_eq!(
820            instance_name("shop-production", "My_Web", 12, "ab12").unwrap(),
821            "shop-production-my-web-12-ab12"
822        );
823    }
824
825    #[test]
826    fn long_names_are_shortened_with_a_hash() {
827        let stack = "project-management-production";
828        let svc = "project-management-postgres";
829        assert!(!instance_name_fits(stack, svc, 1));
830        let n = instance_name(stack, svc, 1, "ab12").unwrap();
831        assert!(n.len() <= 63, "{n}");
832        assert!(n.starts_with(&format!("{stack}-project-")), "{n}");
833        assert!(n.ends_with("-1-ab12"), "{n}");
834        crate::plan::validate_instance_name(&n).unwrap();
835        // Deterministic.
836        assert_eq!(n, instance_name(stack, svc, 1, "ab12").unwrap());
837        // Two long services sharing a prefix still differ.
838        let other = instance_name(stack, "project-management-postgres-replica", 1, "ab12").unwrap();
839        assert_ne!(n, other);
840        // The slot and id stay at the end, at any width.
841        let wide = instance_name(&"a".repeat(30), &"b".repeat(40), 4_000_000_000, "ab12").unwrap();
842        assert!(
843            wide.len() <= 63 && wide.ends_with("-4000000000-ab12"),
844            "{wide}"
845        );
846        // A stack too long to leave room for any service is refused, saying so.
847        let e = instance_name(&"s".repeat(50), "web-frontend-x", 4_000_000_000, "ab12")
848            .unwrap_err()
849            .to_string();
850        assert!(e.contains("shorten the stack name"), "{e}");
851    }
852
853    #[test]
854    fn stack_names_prefer_unshortened_instances() {
855        let c = ["aaaa-production-pr-1".to_string(), "aaaa-pr-1".to_string()];
856        assert_eq!(pick_stack_name(&c, "web", 100).unwrap(), c[0]);
857        // As before shortening existed: the shorter stack, whose names fit.
858        assert_eq!(pick_stack_name(&c, &"w".repeat(35), 100).unwrap(), c[1]);
859        // Fits nowhere unshortened: the first, shortened.
860        assert_eq!(pick_stack_name(&c, &"w".repeat(60), 100).unwrap(), c[0]);
861        assert_eq!(pick_stack_name(&["Bad".to_string()], "web", 1), None);
862    }
863
864    #[test]
865    fn store_round_trip() {
866        let dir = tempfile::tempdir().unwrap();
867        let s = Store::open(dir.path()).unwrap();
868        let d = def("services:\n  web: {image: x}\n");
869        s.save(&d).unwrap();
870        let all = s.load_all().unwrap();
871        assert_eq!(all.len(), 1);
872        assert_eq!(all[0].name, "app");
873        use std::os::unix::fs::PermissionsExt;
874        let mode = std::fs::metadata(dir.path().join("stacks/app.json"))
875            .unwrap()
876            .permissions()
877            .mode();
878        assert_eq!(mode & 0o777, 0o600);
879        let mut other = d.clone();
880        other.org = OrgId::new("alpha").unwrap();
881        s.save(&other).unwrap();
882        assert!(dir.path().join("orgs/alpha/stacks/app.json").is_file());
883        let all = s.load_all().unwrap();
884        assert_eq!(
885            all.iter().map(|d| d.qualified()).collect::<Vec<_>>(),
886            vec!["alpha/app", "app"]
887        );
888        s.remove(&OrgId::default_org(), "app").unwrap();
889        s.remove(&other.org, "app").unwrap();
890        assert!(s.load_all().unwrap().is_empty());
891    }
892}