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