Skip to main content

isb_apps/app/
mod.rs

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