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