Skip to main content

isb_apps/app/
mod.rs

1//! Applications: the Dokploy-style object over stacks.
2//!
3//! An org holds projects; a project holds environments (`production` by
4//! default); an environment holds apps. An app is a source (an image, or a
5//! git repository plus a [`crate::build::Builder`]) and the settings it runs
6//! with: environment, domains, volumes, replicas, port, health check,
7//! resources, command.
8//!
9//! A project's environment renders to ONE ordinary stack named
10//! `<project>-<env>`, each app one service in it, so apps reach each other
11//! as `<app>.<project>-<env>` and the stack controller does the rolling
12//! deploys. Deploying an app replaces only its own service in that stack
13//! (revisions are per service), so only that app rolls.
14//!
15//! Everything lives under the daemon's state directory, next to the org's
16//! stacks: `apps/` in the default org, `orgs/<org>/apps/` in the others.
17//!
18//! ```text
19//! apps/projects/<project>.json
20//! apps/<app>/app.json
21//! apps/<app>/deployments/<n>.json, <n>.log
22//! sources/<app>/repo, known_hosts           (git checkouts)
23//! ```
24
25mod close;
26pub mod database;
27pub mod deploy;
28pub mod env;
29pub mod forge;
30pub mod git;
31pub mod manifest;
32pub mod preview;
33mod removals;
34pub mod webhook;
35
36use std::collections::BTreeMap;
37use std::path::{Path, PathBuf};
38
39use serde::{Deserialize, Serialize};
40use serde_json::{Value, json};
41
42use crate::build::Builder;
43use crate::error::{Error, Result};
44use crate::org::OrgId;
45use crate::spec::{NamedVolumeSpec, SandboxSpec, SecretDef};
46
47pub use database::{DatabaseSource, Engine};
48pub use deploy::{Apps, BuildFn, DigestFn, SecretHook};
49pub use env::{EnvFile, EnvValue};
50pub use git::{GitAuth, GitSource};
51pub use preview::{Preview, PreviewSettings};
52
53/// The environment a project starts with.
54pub const DEFAULT_ENVIRONMENT: &str = "production";
55
56/// Label (`user.isb.app`) on every instance of an app.
57pub const LABEL_APP: &str = "isb.app";
58
59/// Where an org's apps, projects and sources live: next to its stacks.
60pub fn org_root(state: &Path, org: &OrgId) -> PathBuf {
61    if org.is_default() {
62        state.to_path_buf()
63    } else {
64        org.dir(state)
65    }
66}
67
68/// A project: a named group of environments.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct Project {
71    pub name: String,
72    #[serde(default, skip_serializing_if = "String::is_empty")]
73    pub description: String,
74    pub environments: Vec<String>,
75    pub created_at: u64,
76}
77
78/// A project or environment name: `<project>-<env>` must be a stack name.
79pub fn validate_part(kind: &str, s: &str) -> Result<()> {
80    let ok = !s.is_empty()
81        && s.len() <= 24
82        && s.starts_with(|c: char| c.is_ascii_lowercase())
83        && !s.ends_with('-')
84        && s.chars()
85            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
86    if !ok {
87        return Err(Error::invalid(format!(
88            "{kind} name {s:?}: up to 24 characters of [a-z0-9-], starting with a letter"
89        )));
90    }
91    // `<project>-<env>-pr-<n>` is a preview's stack.
92    if kind == "environment" && preview::is_pr_suffix(s) {
93        return Err(Error::invalid(format!(
94            "environment name {s:?}: names ending in pr-<number> are kept for previews"
95        )));
96    }
97    Ok(())
98}
99
100/// The stack a project's environment renders to.
101pub fn stack_name(project: &str, environment: &str) -> Result<String> {
102    let n = format!("{project}-{environment}");
103    crate::stack::validate_stack_name(&n).map_err(|_| {
104        Error::invalid(format!(
105            "{project} + {environment}: the stack name {n:?} is over 30 characters; shorten one"
106        ))
107    })?;
108    Ok(n)
109}
110
111/// Where an app's code or image comes from.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "lowercase", deny_unknown_fields)]
114pub enum Source {
115    /// An image as a compose `image:` takes it (`docker:nginx:1.27`,
116    /// `ghcr:org/app:tag`, a local alias).
117    Image(String),
118    Git(GitSource),
119    /// A database engine's official image (docs/guides/databases.md).
120    Database(DatabaseSource),
121}
122
123/// How a git source becomes an image.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct BuildSettings {
127    pub builder: Builder,
128    /// Build-time variables (Dockerfile `ARG`s, buildpack env).
129    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
130    pub args: BTreeMap<String, String>,
131    /// Build in a VM (default) rather than a container.
132    #[serde(default = "yes")]
133    pub untrusted: bool,
134}
135
136fn yes() -> bool {
137    true
138}
139
140/// CPU and memory limits per replica.
141#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(deny_unknown_fields)]
143pub struct Resources {
144    /// `limits.cpu`: a count, e.g. `2`.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub cpus: Option<String>,
147    /// `512m`, `2g`, `2GiB`.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub memory: Option<String>,
150}
151
152/// What a user sets on an app.
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154#[serde(deny_unknown_fields)]
155pub struct AppSpec {
156    pub name: String,
157    pub project: String,
158    #[serde(default = "default_env")]
159    pub environment: String,
160    pub source: Source,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub build: Option<BuildSettings>,
163    /// `.env` text, or a `{KEY: value | {secret: NAME}}` map.
164    #[serde(default)]
165    pub env: EnvFile,
166    /// The ingress' `domains:` list (`{host, path?, port?, https?,
167    /// redirect?}`), passed to the rendered service as is. `port`
168    /// defaults to the app's `port`.
169    #[serde(default, skip_serializing_if = "Vec::is_empty")]
170    pub domains: Vec<serde_json::Map<String, Value>>,
171    /// Named volumes, `NAME:/path[:ro]`. Each is the app's own
172    /// (`<stack>_<app>_<name>`), shared by its replicas. Host paths are
173    /// not allowed.
174    #[serde(default, skip_serializing_if = "Vec::is_empty")]
175    pub volumes: Vec<String>,
176    /// Published host ports, compose syntax (`127.0.0.1:8080:80`),
177    /// load-balanced over healthy replicas.
178    #[serde(default, skip_serializing_if = "Vec::is_empty")]
179    pub ports: Vec<String>,
180    #[serde(default = "one")]
181    pub replicas: u32,
182    /// The port the app listens on inside its instances.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub port: Option<u16>,
185    /// A compose `healthcheck`.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub healthcheck: Option<Value>,
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub resources: Option<Resources>,
190    /// A compose `command`: argv, or a line split like a shell would.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub command: Option<Value>,
193    /// Preview deployments per pull request (git sources).
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub previews: Option<PreviewSettings>,
196    /// Files in the app's instances, each an org secret's value (config
197    /// files, certificates). Delivered like a stack's file secrets.
198    #[serde(default, skip_serializing_if = "Vec::is_empty")]
199    pub files: Vec<AppFile>,
200    /// The user the app runs as; numeric (`uid[:gid]`) on an OCI image.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub user: Option<String>,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub working_dir: Option<String>,
205    /// What a new version of a secret the app uses (in `env` or `files`)
206    /// does to its replicas: `roll` (default), `restart` in place, or
207    /// `none` (files updated, replicas stale until they next start).
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub secret_on_change: Option<crate::spec::OnChange>,
210}
211
212/// A file an app gets: the value of org secret `secret` at `path`.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(deny_unknown_fields)]
215pub struct AppFile {
216    /// Absolute path in the instance.
217    pub path: String,
218    /// The org secret holding the content.
219    pub secret: String,
220    /// Octal mode (default `0400`, owned by the app's numeric user or root).
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub mode: Option<String>,
223}
224
225fn default_env() -> String {
226    DEFAULT_ENVIRONMENT.into()
227}
228
229fn one() -> u32 {
230    1
231}
232
233/// An app as stored: what the user set, plus bookkeeping.
234#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
235pub struct App {
236    pub spec: AppSpec,
237    pub created_at: u64,
238    pub updated_at: u64,
239    /// The number the next deployment gets.
240    #[serde(default = "one_u64")]
241    pub next_deployment: u64,
242    /// The deployment running now (the last one that finished `done`).
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub current: Option<u64>,
245}
246
247fn one_u64() -> u64 {
248    1
249}
250
251impl AppSpec {
252    pub fn stack(&self) -> Result<String> {
253        stack_name(&self.project, &self.environment)
254    }
255
256    /// Check everything that does not need the host.
257    pub fn validate(&self) -> Result<()> {
258        validate_app_name(&self.name)?;
259        validate_part("project", &self.project)?;
260        validate_part("environment", &self.environment)?;
261        let stack = self.stack()?;
262        crate::stack::instance_name(&stack, &self.name, 100, "0000").map_err(|_| {
263            Error::invalid(format!(
264                "app {}: instance names in stack {stack} would be too long; shorten the app, project or environment name",
265                self.name
266            ))
267        })?;
268        match (&self.source, &self.build) {
269            (Source::Image(i), None) => {
270                crate::plan::ImageSource::parse(i)?;
271            }
272            (Source::Image(_), Some(_)) => {
273                return Err(Error::invalid("an image source is not built; drop `build`"));
274            }
275            (Source::Git(g), Some(_)) => {
276                g.validate()?;
277            }
278            (Source::Git(_), None) => {
279                return Err(Error::invalid(
280                    "a git source needs `build` (e.g. {builder: {type: railpack}})",
281                ));
282            }
283            (Source::Database(db), _) => database::validate(self, db)?,
284        }
285        if self.replicas > 100 {
286            return Err(Error::invalid("replicas: at most 100"));
287        }
288        for v in &self.volumes {
289            parse_volume(v)?;
290        }
291        if let Some(p) = &self.previews {
292            p.validate(self)?;
293        }
294        let mut paths = std::collections::BTreeSet::new();
295        for f in &self.files {
296            if !f.path.starts_with('/') || f.path.ends_with('/') || f.path.contains("/../") {
297                return Err(Error::invalid(format!(
298                    "file {:?}: the path must be an absolute file path",
299                    f.path
300                )));
301            }
302            if !paths.insert(f.path.as_str()) {
303                return Err(Error::invalid(format!("file {:?} is given twice", f.path)));
304            }
305            crate::secrets::validate_name(&f.secret)?;
306        }
307        for d in &self.domains {
308            let host = d.get("host").and_then(Value::as_str).unwrap_or("");
309            if host.is_empty() {
310                return Err(Error::invalid("every domain needs a host"));
311            }
312            if !d.contains_key("port") && self.port.is_none() {
313                return Err(Error::invalid(format!(
314                    "domain {host}: give it a port, or set the app's port"
315                )));
316            }
317        }
318        Ok(())
319    }
320
321    /// The webhook secret's name in the org's store.
322    pub fn webhook_secret(&self) -> String {
323        webhook_secret(&self.name)
324    }
325}
326
327pub fn webhook_secret(app: &str) -> String {
328    format!("app.{app}.webhook")
329}
330
331pub fn deploy_key_secret(app: &str) -> String {
332    format!("app.{app}.deploy-key")
333}
334
335/// An app name: a service name in its stack and a DNS label.
336pub fn validate_app_name(s: &str) -> Result<()> {
337    let ok = !s.is_empty()
338        && s.len() <= 30
339        && s.starts_with(|c: char| c.is_ascii_lowercase())
340        && !s.ends_with('-')
341        && s.chars()
342            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
343    if ok {
344        Ok(())
345    } else {
346        Err(Error::invalid(format!(
347            "app name {s:?}: up to 30 characters of [a-z0-9-], starting with a letter"
348        )))
349    }
350}
351
352/// `NAME:/path[:ro|rw]`: a named volume.
353pub(crate) fn parse_volume(v: &str) -> Result<(String, String, Option<String>)> {
354    let mut parts = v.splitn(3, ':');
355    let name = parts.next().unwrap_or("");
356    let target = parts.next().unwrap_or("");
357    let opts = parts.next().map(String::from);
358    let name_ok = !name.is_empty()
359        && name.len() <= 30
360        && name.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
361        && name
362            .chars()
363            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
364    if !name_ok {
365        return Err(Error::invalid(format!(
366            "volume {v:?}: NAME:/path with NAME of [a-z0-9-] (apps take named volumes only, never host paths)"
367        )));
368    }
369    if !target.starts_with('/') {
370        return Err(Error::invalid(format!(
371            "volume {v:?}: the target must be an absolute path"
372        )));
373    }
374    if let Some(o) = &opts {
375        if !matches!(o.as_str(), "ro" | "rw") {
376            return Err(Error::invalid(format!(
377                "volume {v:?}: options are ro or rw"
378            )));
379        }
380    }
381    Ok((name.to_string(), target.to_string(), opts))
382}
383
384/// One app rendered for its stack: the service plus the top-level secrets
385/// and volumes it uses. Stored with every deployment, so a rollback puts
386/// back exactly what ran.
387#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
388pub struct Rendered {
389    pub service: SandboxSpec,
390    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
391    pub secrets: BTreeMap<String, SecretDef>,
392    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
393    pub volumes: BTreeMap<String, NamedVolumeSpec>,
394}
395
396/// The top-level secret key an app's env reference renders to.
397fn secret_key(app: &str, name: &str) -> String {
398    format!("{app}.{name}")
399}
400
401fn volume_key(app: &str, name: &str) -> String {
402    format!("{app}_{name}")
403}
404
405/// Whether the compose parser takes a service's `domains:` (the ingress
406/// adds it). Until it does, an app's domains stay in the app record and
407/// out of the rendered service.
408pub fn compose_takes_domains() -> bool {
409    serde_json::from_value::<SandboxSpec>(json!({"image": "x", "domains": []})).is_ok()
410}
411
412/// Render `spec` running `image` as its stack service. `notes` gets what
413/// was left out and why.
414#[expect(
415    clippy::too_many_lines,
416    reason = "predates the lint ratchet; split it when next changed"
417)]
418pub fn render(spec: &AppSpec, image: &str, notes: &mut Vec<String>) -> Result<Rendered> {
419    let effective;
420    let spec = match &spec.source {
421        Source::Database(db) => {
422            effective = database::effective(spec, db);
423            &effective
424        }
425        _ => spec,
426    };
427    let mut environment = serde_json::Map::new();
428    let mut secrets = BTreeMap::new();
429    for (k, v) in spec.env.vars() {
430        match v {
431            EnvValue::Plain(s) => {
432                environment.insert(k.to_string(), json!(s));
433            }
434            EnvValue::Secret { secret } => {
435                let key = secret_key(&spec.name, secret);
436                environment.insert(k.to_string(), json!({"secret": key}));
437                secrets.insert(
438                    key,
439                    SecretDef {
440                        external: true,
441                        name: Some(secret.clone()),
442                        on_change: spec.secret_on_change,
443                        ..Default::default()
444                    },
445                );
446            }
447        }
448    }
449    // A database's passwords take effect inside it before its replicas
450    // get them; its engine reads them only when the data is first made.
451    if let Source::Database(db) = &spec.source {
452        let mut rotate = |name: String, root: bool| {
453            if let Some(d) = secrets.get_mut(&secret_key(&spec.name, &name)) {
454                d.rotate = Some(db.engine.rotate_command(root));
455            }
456        };
457        rotate(database::password_secret(&spec.name), false);
458        if db.engine.has_root_password() {
459            rotate(database::root_password_secret(&spec.name), true);
460        }
461    }
462    let mut volumes = BTreeMap::new();
463    let mut mounts = Vec::new();
464    for v in &spec.volumes {
465        let (name, target, opts) = parse_volume(v)?;
466        let key = volume_key(&spec.name, &name);
467        mounts.push(match opts {
468            Some(o) => format!("{key}:{target}:{o}"),
469            None => format!("{key}:{target}"),
470        });
471        volumes.insert(key, NamedVolumeSpec::default());
472    }
473    let mut labels = serde_json::Map::new();
474    labels.insert(LABEL_APP.into(), json!(spec.name));
475    // A shared volume and two live replicas of a database do not mix:
476    // apps with volumes replace stop-first, the rest start-first.
477    let order = if spec.volumes.is_empty() {
478        "start-first"
479    } else {
480        "stop-first"
481    };
482    let mut svc = json!({
483        "image": image,
484        "labels": labels,
485        "deploy": {"replicas": spec.replicas, "update_config": {"order": order}},
486    });
487    if !environment.is_empty() {
488        svc["environment"] = Value::Object(environment);
489    }
490    if !mounts.is_empty() {
491        svc["volumes"] = json!(mounts);
492    }
493    if !spec.ports.is_empty() {
494        svc["ports"] = json!(spec.ports);
495    }
496    if let Some(c) = &spec.command {
497        svc["command"] = c.clone();
498    }
499    if let Some(h) = &spec.healthcheck {
500        svc["healthcheck"] = h.clone();
501    }
502    if !spec.files.is_empty() {
503        let mut refs = Vec::new();
504        for f in &spec.files {
505            let key = secret_key(&spec.name, &f.secret);
506            let mut r = json!({"source": key, "target": f.path});
507            if let Some(m) = &f.mode {
508                r["mode"] = json!(m);
509            }
510            refs.push(r);
511            secrets.insert(
512                key,
513                SecretDef {
514                    external: true,
515                    name: Some(f.secret.clone()),
516                    on_change: spec.secret_on_change,
517                    ..Default::default()
518                },
519            );
520        }
521        svc["secrets"] = json!(refs);
522    }
523    if let Some(u) = &spec.user {
524        svc["user"] = json!(u);
525    }
526    if let Some(w) = &spec.working_dir {
527        svc["working_dir"] = json!(w);
528    }
529    if let Some(r) = &spec.resources {
530        if let Some(c) = &r.cpus {
531            svc["cpus"] = json!(c);
532        }
533        if let Some(m) = &r.memory {
534            svc["mem_limit"] = json!(m);
535        }
536    }
537    let parse = |v: Value| {
538        serde_json::from_value::<SandboxSpec>(v)
539            .map_err(|e| Error::invalid(format!("app {}: {e}", spec.name)))
540    };
541    let service = if spec.domains.is_empty() {
542        parse(svc)?
543    } else {
544        let domains: Vec<Value> = spec
545            .domains
546            .iter()
547            .map(|d| {
548                let mut d = d.clone();
549                if let (false, Some(p)) = (d.contains_key("port"), spec.port) {
550                    d.insert("port".into(), json!(p));
551                }
552                Value::Object(d)
553            })
554            .collect();
555        let mut with = svc.clone();
556        with["domains"] = json!(domains);
557        if compose_takes_domains() {
558            parse(with)?
559        } else {
560            notes.push(format!(
561                "domains ({}) are kept with the app; this isb has no ingress to serve them yet",
562                spec.domains
563                    .iter()
564                    .filter_map(|d| d.get("host").and_then(Value::as_str))
565                    .collect::<Vec<_>>()
566                    .join(", ")
567            ));
568            parse(svc)?
569        }
570    };
571    Ok(Rendered {
572        service,
573        secrets,
574        volumes,
575    })
576}
577
578/// The stack file with `app`'s service replaced by `r` (or removed, with
579/// `None`), the rest untouched, and top-level secrets and volumes no
580/// service uses any more dropped.
581pub fn splice(
582    current: Option<&crate::spec::ComposeFile>,
583    stack: &str,
584    app: &str,
585    r: Option<&Rendered>,
586) -> crate::spec::ComposeFile {
587    let mut f = current.cloned().unwrap_or_default();
588    f.name = Some(stack.to_string());
589    f.services.remove(app);
590    if let Some(r) = r {
591        f.services.insert(app.to_string(), r.service.clone());
592        for (k, v) in &r.secrets {
593            f.secrets.insert(k.clone(), v.clone());
594        }
595        for (k, v) in &r.volumes {
596            f.volumes.insert(k.clone(), v.clone());
597        }
598    }
599    let used_secrets = crate::stack::secrets::used_keys(&f);
600    f.secrets.retain(|k, _| used_secrets.contains(k));
601    let used_volumes: std::collections::BTreeSet<String> = f
602        .services
603        .values()
604        .flat_map(|s| s.volumes.iter().map(|v| v.source.clone()))
605        .collect();
606    f.volumes.retain(|k, _| used_volumes.contains(k));
607    f
608}
609
610/// Merge `patch` into `base` (RFC 7396): `null` removes a key.
611pub fn merge_patch(base: &mut Value, patch: &Value) {
612    match (base, patch) {
613        (Value::Object(b), Value::Object(p)) => {
614            for (k, v) in p {
615                if v.is_null() {
616                    b.remove(k);
617                } else {
618                    merge_patch(b.entry(k.clone()).or_insert(Value::Null), v);
619                }
620            }
621        }
622        (b, p) => *b = p.clone(),
623    }
624}
625
626/// The OCI reference `image` pinned to `digest`
627/// (`docker:traefik/whoami:v1` -> `docker:traefik/whoami@sha256:...`), or
628/// `None` for an image that is not from an OCI registry.
629pub fn pin(image: &str, digest: &str) -> Option<String> {
630    let (prefix, rest) = image.split_once(':')?;
631    if !matches!(prefix, "docker" | "ghcr" | "quay" | "oci") || !digest.starts_with("sha256:") {
632        return None;
633    }
634    let rest = rest.split('@').next().unwrap_or(rest);
635    let (dir, last) = match rest.rsplit_once('/') {
636        Some((d, l)) => (Some(d), l),
637        None => (None, rest),
638    };
639    let last = last.split(':').next().unwrap_or(last);
640    Some(match dir {
641        Some(d) => format!("{prefix}:{d}/{last}@{digest}"),
642        None => format!("{prefix}:{last}@{digest}"),
643    })
644}
645
646/// Atomic write (temp file, fsync, rename), 0600.
647#[doc(hidden)]
648pub fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
649    use std::io::Write;
650    use std::os::unix::fs::OpenOptionsExt;
651    if let Some(d) = path.parent() {
652        std::fs::create_dir_all(d)?;
653    }
654    let tmp = path.with_extension(format!("tmp-{}", git::random_hex(4)));
655    let mut f = std::fs::OpenOptions::new()
656        .write(true)
657        .create(true)
658        .truncate(true)
659        .mode(0o600)
660        .open(&tmp)?;
661    f.write_all(data)?;
662    f.sync_all()?;
663    std::fs::rename(&tmp, path)?;
664    Ok(())
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670
671    /// Tools take JSON; YAML here is only for brevity.
672    fn spec(y: &str) -> AppSpec {
673        try_spec(y).unwrap()
674    }
675
676    fn try_spec(y: &str) -> std::result::Result<AppSpec, serde_json::Error> {
677        serde_json::from_value(serde_yaml_ng::from_str::<Value>(y).unwrap())
678    }
679
680    #[test]
681    fn spec_forms_and_validation() {
682        let a = spec(
683            "name: web\nproject: shop\nsource: {image: 'docker:traefik/whoami'}\nenv: {A: '1', T: {secret: tok}}\n",
684        );
685        assert_eq!(a.environment, "production");
686        assert_eq!(a.replicas, 1);
687        assert_eq!(a.stack().unwrap(), "shop-production");
688        a.validate().unwrap();
689        let g = spec(
690            "name: api\nproject: shop\nsource: {git: {url: 'https://h/o/r', ref: dev}}\nbuild: {builder: {type: railpack}}\n",
691        );
692        g.validate().unwrap();
693        assert!(g.build.as_ref().unwrap().untrusted);
694        let mut bad = g.clone();
695        bad.build = None;
696        assert!(bad.validate().is_err());
697        let mut bad = a.clone();
698        bad.volumes = vec!["/etc:/x".into()];
699        assert!(bad.validate().is_err());
700        bad.volumes = vec!["data:/var/lib/x".into()];
701        bad.validate().unwrap();
702        bad.domains = vec![serde_json::from_str(r#"{"host":"a.example.com"}"#).unwrap()];
703        assert!(bad.validate().is_err(), "a domain needs a port");
704        bad.port = Some(80);
705        bad.validate().unwrap();
706        let mut bad = a.clone();
707        bad.project = "a-very-long-project-name".into();
708        bad.environment = "staging-environment".into();
709        assert!(bad.validate().is_err());
710        assert!(try_spec("name: x\nproject: p\nsource: {image: x}\nbogus: 1\n").is_err());
711        assert!(try_spec("name: x\nproject: p\nsource: {image: x, git: {url: u}}\n").is_err());
712    }
713
714    #[test]
715    fn renders_one_service() {
716        let a = spec(concat!(
717            "name: web\nproject: shop\nsource: {image: 'docker:traefik/whoami'}\n",
718            "env: \"# c\\nA=1\\nT=${{secret.tok}}\\n\"\n",
719            "volumes: ['data:/data']\nports: ['127.0.0.1:18080:80']\nreplicas: 2\nport: 80\n",
720            "command: [/whoami, --port, '80']\n",
721            "healthcheck: {test: [CMD, /whoami, --help], interval: 5s}\n",
722            "resources: {cpus: '1', memory: 256m}\n",
723        ));
724        let mut notes = vec![];
725        let r = render(&a, "docker:traefik/whoami@sha256:ab", &mut notes).unwrap();
726        let s = &r.service;
727        assert_eq!(s.image, "docker:traefik/whoami@sha256:ab");
728        assert_eq!(s.env["A"], "1");
729        assert_eq!(s.env.secrets["T"], "web.tok");
730        assert_eq!(r.secrets["web.tok"].name.as_deref(), Some("tok"));
731        assert!(r.secrets["web.tok"].external);
732        assert_eq!(s.volumes[0].source, "web_data");
733        assert!(r.volumes.contains_key("web_data"));
734        assert_eq!(s.replicas(), 2);
735        assert_eq!(s.labels[LABEL_APP], "web");
736        assert_eq!(s.cpus.as_deref(), Some("1"));
737        assert_eq!(s.memory.as_deref(), Some("256m"));
738        assert!(s.healthcheck.is_some());
739        assert_eq!(s.ports.len(), 1);
740        assert!(notes.is_empty());
741    }
742
743    #[test]
744    fn secret_on_change_reaches_every_secret_the_app_uses() {
745        let base = concat!(
746            "name: web\nproject: shop\nsource: {image: 'docker:traefik/whoami'}\n",
747            "env: \"T=${{secret.tok}}\\n\"\nfiles: [{path: /etc/app.conf, secret: conf}]\n",
748        );
749        let r = render(&spec(base), "x", &mut vec![]).unwrap();
750        assert!(r.secrets.values().all(|d| d.on_change.is_none()));
751        let a = spec(&format!("{base}secret_on_change: restart\n"));
752        let r = render(&a, "x", &mut vec![]).unwrap();
753        assert_eq!(r.secrets.len(), 2);
754        assert!(
755            r.secrets
756                .values()
757                .all(|d| d.on_change == Some(crate::spec::OnChange::Restart))
758        );
759        assert!(try_spec(&format!("{base}secret_on_change: sometimes\n")).is_err());
760    }
761
762    #[test]
763    fn domains_follow_the_parser() {
764        let mut a = spec("name: web\nproject: shop\nsource: {image: x}\nport: 8080\n");
765        a.domains = vec![serde_json::from_str(r#"{"host":"shop.example.com"}"#).unwrap()];
766        let mut notes = vec![];
767        let r = render(&a, "x", &mut notes).unwrap();
768        if compose_takes_domains() {
769            let v = serde_json::to_value(&r.service).unwrap();
770            assert_eq!(v["domains"][0]["port"], 8080);
771            assert!(notes.is_empty());
772        } else {
773            assert_eq!(notes.len(), 1, "{notes:?}");
774        }
775    }
776
777    #[test]
778    fn splice_touches_only_the_app() {
779        let mut notes = vec![];
780        let web = spec(
781            "name: web\nproject: shop\nsource: {image: x}\nenv: {T: {secret: tok}}\nvolumes: ['d:/d']\n",
782        );
783        let api = spec("name: api\nproject: shop\nsource: {image: y}\nenv: {T: {secret: tok}}\n");
784        let rw = render(&web, "x", &mut notes).unwrap();
785        let ra = render(&api, "y", &mut notes).unwrap();
786        let f1 = splice(None, "shop-production", "web", Some(&rw));
787        let f2 = splice(Some(&f1), "shop-production", "api", Some(&ra));
788        assert_eq!(f2.services.len(), 2);
789        assert_eq!(f2.services["web"], f1.services["web"]);
790        assert_eq!(
791            f2.secrets.keys().collect::<Vec<_>>(),
792            ["api.tok", "web.tok"]
793        );
794        // The revision of the app not deployed stays the same.
795        let def = |f: &crate::spec::ComposeFile| crate::stack::StackDef {
796            name: "shop-production".into(),
797            org: OrgId::default_org(),
798            file: f.clone(),
799            base_dir: "/".into(),
800            secrets: Default::default(),
801            force: Default::default(),
802            images: Default::default(),
803            deployed_at: 0,
804            deployed_by: String::new(),
805            previous: None,
806        };
807        let mut web2 = web.clone();
808        web2.env.set("B", EnvValue::Plain("2".into()));
809        let rw2 = render(&web2, "x", &mut notes).unwrap();
810        let f3 = splice(Some(&f2), "shop-production", "web", Some(&rw2));
811        assert_eq!(
812            def(&f2).revision("api").unwrap(),
813            def(&f3).revision("api").unwrap()
814        );
815        assert_ne!(
816            def(&f2).revision("web").unwrap(),
817            def(&f3).revision("web").unwrap()
818        );
819        // Removing web drops its secret key and volume, keeps api's.
820        let f4 = splice(Some(&f3), "shop-production", "web", None);
821        assert_eq!(f4.services.keys().collect::<Vec<_>>(), ["api"]);
822        assert_eq!(f4.secrets.keys().collect::<Vec<_>>(), ["api.tok"]);
823        assert!(f4.volumes.is_empty());
824    }
825
826    #[test]
827    fn pins_digests() {
828        let d = "sha256:abc";
829        assert_eq!(
830            pin("docker:traefik/whoami", d).unwrap(),
831            "docker:traefik/whoami@sha256:abc"
832        );
833        assert_eq!(
834            pin("docker:nginx:1.27", d).unwrap(),
835            "docker:nginx@sha256:abc"
836        );
837        assert_eq!(
838            pin("oci:reg.example.com:5000/team/app:v2", d).unwrap(),
839            "oci:reg.example.com:5000/team/app@sha256:abc"
840        );
841        assert_eq!(
842            pin("ghcr:o/a@sha256:old", d).unwrap(),
843            "ghcr:o/a@sha256:abc"
844        );
845        assert!(pin("dev-base", d).is_none());
846        assert!(pin("images:debian/12", d).is_none());
847    }
848
849    #[test]
850    fn merge_patch_rfc7396() {
851        let mut b = json!({"a": 1, "b": {"c": 2, "d": 3}});
852        merge_patch(&mut b, &json!({"a": null, "b": {"c": 9}, "e": [1]}));
853        assert_eq!(b, json!({"b": {"c": 9, "d": 3}, "e": [1]}));
854    }
855}