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