Skip to main content

isb_apps/app/
manifest.rs

1//! An app as a document, for `kubectl apply`-style editing: the YAML the
2//! web UI's editor shows and `app_export` returns, `app_apply` and the
3//! editor's Save take, and the diff between two of them.
4//!
5//! The document is the app's [`AppSpec`] and nothing else: the fields
6//! `app_create` takes. Secrets appear by name (`${{secret.NAME}}` in `env`,
7//! `secret:` in `files` and git auth), never as values. Applying a document
8//! is declarative: what it leaves out goes back to its default, unlike
9//! `app_update`, which merges.
10
11use std::collections::BTreeSet;
12
13use serde::Serialize;
14use serde_json::Value;
15
16use super::{App, AppSpec, Apps};
17use crate::error::{Error, Result};
18use crate::org::OrgId;
19
20/// Fields `app_get` adds to an app's settings (state, not settings). A
21/// document that carries them, as one pasted from `app_get` does, has them
22/// ignored.
23const READ_ONLY: &[&str] = &[
24    "stack",
25    "service_name",
26    "current_deployment",
27    "created_at",
28    "updated_at",
29    "webhook",
30    "domains_served",
31    "env_vars",
32    "ingress_enabled",
33    "connection",
34];
35
36/// Something wrong with a document, where it can be placed.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
38pub struct Problem {
39    /// 1-based.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub line: Option<usize>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub column: Option<usize>,
44    pub message: String,
45}
46
47impl Problem {
48    pub fn new(message: impl Into<String>) -> Problem {
49        Problem {
50            line: None,
51            column: None,
52            message: message.into(),
53        }
54    }
55
56    /// `line 3: message`, for an error string.
57    pub fn render(&self) -> String {
58        match self.line {
59            Some(l) => format!("line {l}: {}", self.message),
60            None => self.message.clone(),
61        }
62    }
63}
64
65/// What applying a document does.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
67#[serde(rename_all = "lowercase")]
68pub enum Action {
69    Created,
70    Updated,
71    Unchanged,
72}
73
74/// What applying a document would do, worked out without doing it.
75#[derive(Debug, Clone)]
76pub struct Plan {
77    pub action: Action,
78    /// The settings the document describes, normalized.
79    pub spec: AppSpec,
80    /// The app's current document; `None` when the app does not exist.
81    pub current: Option<String>,
82    /// The document as it would be stored.
83    pub proposed: String,
84    /// A unified diff from `current` to `proposed`.
85    pub diff: String,
86    /// Top-level fields that differ.
87    pub changes: Vec<String>,
88    /// What the document takes away from an existing app (see
89    /// `super::removals::removals`); empty for a new app.
90    pub removals: Vec<String>,
91    /// Why its image could not be checked, when it is new and could not.
92    pub warning: Option<String>,
93}
94
95/// The order an app's fields are written in.
96const ORDER: &[&str] = &[
97    "name",
98    "project",
99    "environment",
100    "source",
101    "build",
102    "env",
103    "domains",
104    "volumes",
105    "ports",
106    "replicas",
107    "port",
108    "healthcheck",
109    "resources",
110    "command",
111    "previews",
112    "files",
113    "user",
114    "working_dir",
115    "secret_on_change",
116];
117
118/// The YAML document of an app's settings. It goes through JSON so that
119/// `source: {image: ...}` is a plain mapping, as in the tools' arguments
120/// (the YAML serializer would write the enum as a `!image` tag), and the
121/// fields come in `ORDER`.
122pub fn export_yaml(spec: &AppSpec) -> Result<String> {
123    let err = |e: &dyn std::fmt::Display| Error::invalid(format!("app {}: {e}", spec.name));
124    let Value::Object(map) = serde_json::to_value(spec).map_err(|e| err(&e))? else {
125        return Err(err(&"not a mapping"));
126    };
127    let mut doc = serde_yaml_ng::Mapping::new();
128    let keys = ORDER
129        .iter()
130        .map(|k| k.to_string())
131        .chain(map.keys().filter(|k| !ORDER.contains(&k.as_str())).cloned());
132    for k in keys {
133        if let Some(v) = map.get(&k) {
134            doc.insert(
135                serde_yaml_ng::Value::String(k),
136                serde_yaml_ng::to_value(v).map_err(|e| err(&e))?,
137            );
138        }
139    }
140    serde_yaml_ng::to_string(&doc).map_err(|e| err(&e))
141}
142
143/// Parse a document (YAML, which includes JSON) into settings.
144pub fn parse(text: &str) -> std::result::Result<AppSpec, Problem> {
145    if text.trim().is_empty() {
146        return Err(Problem::new("the definition is empty"));
147    }
148    // The generic parse comes first: its errors are the YAML's own, and it
149    // is where read-only fields are dropped.
150    let mut v: Value = if text.trim_start().starts_with('{') {
151        serde_json::from_str(text).map_err(|e| Problem {
152            line: Some(e.line()),
153            column: Some(e.column()),
154            message: clean(&e.to_string()),
155        })?
156    } else {
157        serde_yaml_ng::from_str(text).map_err(|e| yaml_problem(&e))?
158    };
159    let Value::Object(map) = &mut v else {
160        return Err(Problem {
161            line: Some(1),
162            column: None,
163            message: "an app definition is a mapping of fields (name, project, source, ...)".into(),
164        });
165    };
166    for k in READ_ONLY {
167        map.remove(*k);
168    }
169    match serde_json::from_value::<AppSpec>(v.clone()) {
170        Ok(s) => Ok(s),
171        Err(e) => Err(place(text, &v, &clean(&e.to_string()))),
172    }
173}
174
175/// Find the line of the field a typed error is about, which serde's own
176/// message does not say: try each top-level field alone, among stand-ins
177/// for the required ones, and take the first that fails the same way.
178fn place(text: &str, v: &Value, message: &str) -> Problem {
179    let mut p = Problem::new(message);
180    let Value::Object(map) = v else {
181        return p;
182    };
183    let mut first_other = None;
184    for (k, val) in map {
185        let mut one = serde_json::json!({"name": "x", "project": "x", "source": {"image": "x"}});
186        one[k] = val.clone();
187        let Err(e) = serde_json::from_value::<AppSpec>(one) else {
188            continue;
189        };
190        let m = clean(&e.to_string());
191        if m == message {
192            p.line = line_of_key(text, k);
193            if !message.contains(&format!("`{k}`")) {
194                p.message = format!("{k}: {message}");
195            }
196            return p;
197        }
198        first_other.get_or_insert(k);
199    }
200    // `missing field` and the like are about no one field; an error that
201    // only some other wording reproduces still points at its field.
202    if !message.starts_with("missing field") {
203        p.line = first_other.and_then(|k| line_of_key(text, k));
204    }
205    if p.line.is_none() {
206        p.line = locate_any(text, message).line;
207    }
208    p
209}
210
211fn yaml_problem(e: &serde_yaml_ng::Error) -> Problem {
212    let loc = e.location();
213    Problem {
214        line: loc.as_ref().map(|l| l.line()),
215        column: loc.as_ref().map(|l| l.column()),
216        message: clean(&e.to_string()),
217    }
218}
219
220/// An error's text without its trailing ` at line N column M`.
221fn clean(s: &str) -> String {
222    match s.rfind(" at line ") {
223        Some(i)
224            if s[i + 9..]
225                .chars()
226                .all(|c| c.is_ascii_digit() || c == ' ' || c.is_ascii_alphabetic()) =>
227        {
228            s[..i].to_string()
229        }
230        _ => s.to_string(),
231    }
232}
233
234/// The top-level field a validation message is about, by how
235/// [`AppSpec::validate`] words them.
236fn key_of_message(m: &str) -> Option<&'static str> {
237    const PREFIXES: &[(&str, &str)] = &[
238        ("replicas", "replicas"),
239        ("volume", "volumes"),
240        ("file ", "files"),
241        ("every domain", "domains"),
242        ("domain ", "domains"),
243        ("a git source needs", "build"),
244        ("an image source", "build"),
245        ("app name", "name"),
246        ("project name", "project"),
247        ("environment name", "environment"),
248        ("environment ", "environment"),
249        ("project ", "project"),
250        ("preview", "previews"),
251        ("previews", "previews"),
252        ("git ", "source"),
253        ("image", "source"),
254        ("source", "source"),
255        ("a database", "source"),
256        ("database", "source"),
257        ("an app cannot become", "source"),
258        ("an app's name", "name"),
259    ];
260    PREFIXES
261        .iter()
262        .find(|(p, _)| m.starts_with(p))
263        .map(|(_, k)| *k)
264}
265
266/// The 1-based line of a top-level `key:` (or `"key":` in JSON).
267pub fn line_of_key(text: &str, key: &str) -> Option<usize> {
268    text.lines()
269        .position(|l| {
270            let t = l.trim_start_matches([' ', '\t', '{', ',']);
271            // Top level in YAML: no indent.
272            (l.starts_with(key) && l[key.len()..].starts_with(':'))
273                || t.strip_prefix('"')
274                    .and_then(|r| r.strip_prefix(key))
275                    .is_some_and(|r| r.starts_with("\":"))
276        })
277        .map(|i| i + 1)
278}
279
280/// The line a message about a secret or other name points at: the first
281/// line that holds `name`.
282fn line_of_text(text: &str, name: &str) -> Option<usize> {
283    text.lines().position(|l| l.contains(name)).map(|i| i + 1)
284}
285
286/// Place a validation `message` in `text`.
287pub fn locate(text: &str, message: &str) -> Problem {
288    let mut p = Problem::new(message);
289    // `secret NAME does not exist ...`
290    if let Some(rest) = message.strip_prefix("secret ") {
291        if let Some(name) = rest.split_whitespace().next() {
292            p.line = line_of_text(text, name);
293            return p;
294        }
295    }
296    p.line = key_of_message(message).and_then(|k| line_of_key(text, k));
297    if p.line.is_none() {
298        return locate_any(text, message);
299    }
300    p
301}
302
303/// Place an error `message` about a YAML `text` by what it quotes: a
304/// trailing ` at line N column M`, an ``unknown field `x` ``, or a quoted
305/// name (`service "web"`). Any YAML document, not only an app's.
306pub fn locate_any(text: &str, message: &str) -> Problem {
307    if let Some(i) = message.rfind(" at line ") {
308        let mut nums = message[i + 9..]
309            .split(|c: char| !c.is_ascii_digit())
310            .filter(|s| !s.is_empty())
311            .map(|s| s.parse::<usize>());
312        if let (Some(Ok(line)), column) = (nums.next(), nums.next()) {
313            return Problem {
314                line: Some(line),
315                column: column.and_then(Result::ok),
316                message: clean(message),
317            };
318        }
319    }
320    let message = clean(message);
321    let key_line = |name: &str| {
322        text.lines()
323            .position(|l| {
324                let t = l.trim_start().trim_start_matches("- ");
325                t.strip_prefix(name)
326                    .or_else(|| t.strip_prefix('"').and_then(|r| r.strip_prefix(name)))
327                    .is_some_and(|r| r.starts_with(':') || r.starts_with("\":"))
328            })
329            .map(|i| i + 1)
330    };
331    let tick = message
332        .split_once("unknown field `")
333        .and_then(|(_, r)| r.split('`').next());
334    let quoted = message.split('"').nth(1).filter(|s| !s.is_empty());
335    let line = tick
336        .and_then(key_line)
337        .or_else(|| quoted.and_then(key_line))
338        .or_else(|| quoted.and_then(|n| line_of_text(text, n)));
339    Problem {
340        line,
341        column: None,
342        message,
343    }
344}
345
346/// The RFC 7396 merge patch that turns `old` into `new`.
347pub fn merge_diff(old: &Value, new: &Value) -> Value {
348    match (old, new) {
349        (Value::Object(o), Value::Object(n)) => {
350            let mut out = serde_json::Map::new();
351            for k in o.keys().filter(|k| !n.contains_key(*k)) {
352                out.insert(k.clone(), Value::Null);
353            }
354            for (k, nv) in n {
355                match o.get(k) {
356                    Some(ov) if ov == nv => {}
357                    Some(ov) => {
358                        out.insert(k.clone(), merge_diff(ov, nv));
359                    }
360                    None => {
361                        out.insert(k.clone(), nv.clone());
362                    }
363                }
364            }
365            Value::Object(out)
366        }
367        (_, n) => n.clone(),
368    }
369}
370
371/// The top-level fields on which two settings differ, in document order.
372pub fn changed_fields(old: &AppSpec, new: &AppSpec) -> Vec<String> {
373    let (o, n) = (
374        serde_json::to_value(old).unwrap_or_default(),
375        serde_json::to_value(new).unwrap_or_default(),
376    );
377    let (Value::Object(o), Value::Object(n)) = (o, n) else {
378        return Vec::new();
379    };
380    let keys: BTreeSet<&String> = o.keys().chain(n.keys()).collect();
381    let mut out: Vec<String> = keys
382        .into_iter()
383        .filter(|k| o.get(*k) != n.get(*k))
384        .cloned()
385        .collect();
386    // Document order: the order the fields are written in.
387    out.sort_by_key(|k| ORDER.iter().position(|o| o == k).unwrap_or(usize::MAX));
388    out
389}
390
391/// A unified diff of two texts with three lines of context (`--- current`,
392/// `+++ proposed`); empty when they are equal.
393pub fn unified_diff(old: &str, new: &str) -> String {
394    let a: Vec<&str> = old.lines().collect();
395    let b: Vec<&str> = new.lines().collect();
396    if a == b {
397        return String::new();
398    }
399    // Trim the common head and tail so the table below is only as big as
400    // the edit.
401    let head = a.iter().zip(&b).take_while(|(x, y)| x == y).count();
402    let tail = a[head..]
403        .iter()
404        .rev()
405        .zip(b[head..].iter().rev())
406        .take_while(|(x, y)| x == y)
407        .count();
408    let (ma, mb) = (&a[head..a.len() - tail], &b[head..b.len() - tail]);
409    // Edit script over the middle: (kind, line) where kind is ' ', '-', '+'.
410    let mut ops: Vec<(char, &str)> = a[..head].iter().map(|l| (' ', *l)).collect();
411    if ma.len().saturating_mul(mb.len()) > 4_000_000 {
412        ops.extend(ma.iter().map(|l| ('-', *l)));
413        ops.extend(mb.iter().map(|l| ('+', *l)));
414    } else {
415        // Longest common subsequence table, filled from the end.
416        let (n, m) = (ma.len(), mb.len());
417        let mut t = vec![vec![0u32; m + 1]; n + 1];
418        for i in (0..n).rev() {
419            for j in (0..m).rev() {
420                t[i][j] = if ma[i] == mb[j] {
421                    t[i + 1][j + 1] + 1
422                } else {
423                    t[i + 1][j].max(t[i][j + 1])
424                };
425            }
426        }
427        let (mut i, mut j) = (0, 0);
428        while i < n && j < m {
429            if ma[i] == mb[j] {
430                ops.push((' ', ma[i]));
431                i += 1;
432                j += 1;
433            } else if t[i + 1][j] >= t[i][j + 1] {
434                ops.push(('-', ma[i]));
435                i += 1;
436            } else {
437                ops.push(('+', mb[j]));
438                j += 1;
439            }
440        }
441        ops.extend(ma[i..].iter().map(|l| ('-', *l)));
442        ops.extend(mb[j..].iter().map(|l| ('+', *l)));
443    }
444    ops.extend(a[a.len() - tail..].iter().map(|l| (' ', *l)));
445    hunks(&ops)
446}
447
448fn hunks(ops: &[(char, &str)]) -> String {
449    const CONTEXT: usize = 3;
450    // Positions (in old, new) before each op, 1-based line numbers.
451    let mut pos = Vec::with_capacity(ops.len());
452    let (mut ol, mut nl) = (1usize, 1usize);
453    for (k, _) in ops {
454        pos.push((ol, nl));
455        if *k != '+' {
456            ol += 1;
457        }
458        if *k != '-' {
459            nl += 1;
460        }
461    }
462    let changed: Vec<usize> = (0..ops.len()).filter(|&i| ops[i].0 != ' ').collect();
463    let mut out = String::from("--- current\n+++ proposed\n");
464    let mut idx = 0;
465    while idx < changed.len() {
466        let start = changed[idx].saturating_sub(CONTEXT);
467        let mut end = changed[idx];
468        // Extend the hunk while the next change is within reach.
469        while idx + 1 < changed.len() && changed[idx + 1] <= end + 2 * CONTEXT + 1 {
470            idx += 1;
471            end = changed[idx];
472        }
473        idx += 1;
474        let end = (end + CONTEXT + 1).min(ops.len());
475        let slice = &ops[start..end];
476        let old_n = slice.iter().filter(|(k, _)| *k != '+').count();
477        let new_n = slice.iter().filter(|(k, _)| *k != '-').count();
478        let (os, ns) = pos[start];
479        out.push_str(&format!(
480            "@@ -{},{} +{},{} @@\n",
481            if old_n == 0 { os - 1 } else { os },
482            old_n,
483            if new_n == 0 { ns - 1 } else { ns },
484            new_n
485        ));
486        for (k, l) in slice {
487            out.push(*k);
488            out.push_str(l);
489            out.push('\n');
490        }
491    }
492    out
493}
494
495/// The rules an update of `old` into `new` must meet, and the
496/// normalization it gets (shared with [`Apps::update`]).
497pub fn check_update(old: &AppSpec, new: &mut AppSpec) -> Result<()> {
498    use super::Source;
499    if new.name != old.name || new.project != old.project || new.environment != old.environment {
500        return Err(Error::invalid(
501            "an app's name, project and environment are fixed; create a new app instead",
502        ));
503    }
504    match (&old.source, &mut new.source) {
505        (Source::Database(o), Source::Database(n)) => {
506            n.normalize(&old.name);
507            if o.engine != n.engine || o.database != n.database || o.user != n.user {
508                return Err(Error::invalid(
509                    "a database's engine, database and user are fixed (they live in its data volume); restore a backup into a new database instead",
510                ));
511            }
512        }
513        (Source::Database(_), _) | (_, Source::Database(_)) => {
514            return Err(Error::invalid(
515                "an app cannot become a database or stop being one; create a new app",
516            ));
517        }
518        _ => {}
519    }
520    Ok(())
521}
522
523/// Work out what applying `text` to the org's apps would do, changing
524/// nothing. Every problem found that can be placed in the text carries its
525/// line.
526pub fn plan(apps: &Apps, org: &OrgId, text: &str) -> std::result::Result<Plan, Problem> {
527    let mut spec = parse(text)?;
528    if let super::Source::Database(db) = &mut spec.source {
529        db.normalize(&spec.name);
530    }
531    let fail = |e: Error| locate(text, &error_text(&e));
532    let existing = match apps.get(org, &spec.name) {
533        Ok(a) => Some(a),
534        Err(e) if e.is_not_found() => None,
535        Err(e) => return Err(fail(e)),
536    };
537    match &existing {
538        Some(a) => check_update(&a.spec, &mut spec).map_err(fail)?,
539        None => {
540            // What `create` checks before it writes.
541            spec.validate().map_err(fail)?;
542            let proj = apps
543                .project_get(org, &spec.project)
544                .map_err(|e| locate(text, &error_text(&e)))?;
545            if !proj.environments.contains(&spec.environment) {
546                return Err(Problem {
547                    line: line_of_key(text, "environment"),
548                    column: None,
549                    message: format!(
550                        "environment {} in project {} (it has {})",
551                        spec.environment,
552                        spec.project,
553                        proj.environments.join(", ")
554                    ),
555                });
556            }
557        }
558    }
559    spec.validate().map_err(fail)?;
560    apps.check_spec(org, &spec).map_err(fail)?;
561    let warning = apps
562        .check_image(existing.as_ref().map(|a| &a.spec), &spec)
563        .map_err(fail)?;
564    let proposed = export_yaml(&spec).map_err(fail)?;
565    let removals = existing
566        .as_ref()
567        .map(|a| super::removals::removals(&a.spec, &spec))
568        .unwrap_or_default();
569    let (action, current, changes) = match &existing {
570        None => (
571            Action::Created,
572            None,
573            serde_json::to_value(&spec)
574                .ok()
575                .and_then(|v| v.as_object().map(|m| m.keys().cloned().collect()))
576                .unwrap_or_default(),
577        ),
578        Some(a) => {
579            let changes = changed_fields(&a.spec, &spec);
580            let action = if changes.is_empty() {
581                Action::Unchanged
582            } else {
583                Action::Updated
584            };
585            (action, Some(export_yaml(&a.spec).map_err(fail)?), changes)
586        }
587    };
588    let diff = unified_diff(current.as_deref().unwrap_or(""), &proposed);
589    Ok(Plan {
590        action,
591        spec,
592        current,
593        proposed,
594        diff,
595        changes,
596        removals,
597        warning,
598    })
599}
600
601fn error_text(e: &Error) -> String {
602    match e {
603        Error::Invalid(m) => m.clone(),
604        e => e.to_string(),
605    }
606}
607
608/// Do what `plan` worked out. Returns the app and, for a created app, its
609/// webhook secret.
610pub fn apply(apps: &Apps, org: &OrgId, plan: &Plan) -> Result<(App, Option<String>)> {
611    match plan.action {
612        Action::Created => {
613            let (app, secret) = apps.create(org, plan.spec.clone())?;
614            Ok((app, Some(secret)))
615        }
616        Action::Unchanged => Ok((apps.get(org, &plan.spec.name)?, None)),
617        Action::Updated => {
618            let old = apps.get(org, &plan.spec.name)?;
619            let patch = merge_diff(
620                &serde_json::to_value(&old.spec)?,
621                &serde_json::to_value(&plan.spec)?,
622            );
623            Ok((apps.update(org, &plan.spec.name, &patch)?, None))
624        }
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631    use serde_json::json;
632
633    fn spec(extra: &str) -> String {
634        format!("name: web\nproject: shop\nsource:\n  image: docker:nginx:1.27\n{extra}")
635    }
636
637    #[test]
638    fn export_then_parse_is_the_same_app() {
639        let text = spec("env: |\n  A=1\n  # note\n  B=${{secret.db}}\nreplicas: 3\nport: 80\n");
640        let s = parse(&text).unwrap();
641        assert_eq!(s.replicas, 3);
642        let again = parse(&export_yaml(&s).unwrap()).unwrap();
643        assert_eq!(s, again);
644        // The env keeps its comment and its secret as a reference.
645        let y = export_yaml(&s).unwrap();
646        assert!(y.contains("# note"), "{y}");
647        assert!(y.contains("${{secret.db}}"), "{y}");
648    }
649
650    #[test]
651    fn json_is_a_document_too() {
652        let s = parse(r#"{"name": "web", "project": "shop", "source": {"image": "docker:nginx"}, "replicas": 2}"#)
653            .unwrap();
654        assert_eq!(s.replicas, 2);
655        let e = parse("{\"name\": \"web\",\n \"nope\": 1}").unwrap_err();
656        assert!(e.message.contains("unknown field `nope`"), "{e:?}");
657        assert_eq!(e.line, Some(2));
658    }
659
660    #[test]
661    fn errors_say_which_line() {
662        let e = parse(&spec("replicas: many\n")).unwrap_err();
663        assert_eq!(e.line, Some(5), "{e:?}");
664        assert!(e.message.contains("replicas"), "{e:?}");
665        assert!(!e.message.contains("at line"), "{e:?}");
666        let e = parse(&spec("bogus: 1\n")).unwrap_err();
667        assert_eq!(e.line, Some(5), "{e:?}");
668        assert!(e.message.contains("unknown field `bogus`"));
669        let e = parse("name: [web\n").unwrap_err();
670        assert!(e.line.is_some(), "{e:?}");
671        let e = parse("- a\n- b\n").unwrap_err();
672        assert!(e.message.contains("mapping"), "{e:?}");
673        assert!(parse("  \n").unwrap_err().message.contains("empty"));
674    }
675
676    #[test]
677    fn what_app_get_adds_is_ignored() {
678        let s = parse(&spec(
679            "stack: shop-production\nservice_name: web.shop-production\ncurrent_deployment: 4\nenv_vars: {A: '1'}\nwebhook: /x\n",
680        ))
681        .unwrap();
682        assert_eq!(s.name, "web");
683    }
684
685    #[test]
686    fn messages_are_placed_by_the_field_they_name() {
687        let t = spec("replicas: 500\nvolumes: [\"/abs:/x\"]\n");
688        assert_eq!(locate(&t, "replicas: at most 100").line, Some(5));
689        assert_eq!(locate(&t, "volume \"/abs:/x\": NAME").line, Some(6));
690        assert_eq!(locate(&t, "secret db does not exist in org x").line, None);
691        let t = spec("env: |\n  A=${{secret.db}}\n");
692        assert_eq!(
693            locate(&t, "secret db does not exist in org x").line,
694            Some(6)
695        );
696        assert_eq!(line_of_key(&t, "source"), Some(3));
697        assert_eq!(line_of_key("{\"a\": 1,\n\"b\": 2}", "b"), Some(2));
698    }
699
700    #[test]
701    fn any_yaml_error_is_placed_by_what_it_quotes() {
702        let t = "services:\n  web:\n    image: x\n    bogus: 1\nsecrets:\n  db: {}\n";
703        let p = locate_any(
704            t,
705            "services.web: unknown field `bogus`, expected one of `image`",
706        );
707        assert_eq!(p.line, Some(4), "{p:?}");
708        let p = locate_any(t, "secret \"db\": needs a source");
709        assert_eq!(p.line, Some(6), "{p:?}");
710        let p = locate_any(t, "did not find expected key at line 3 column 5");
711        assert_eq!((p.line, p.column), (Some(3), Some(5)));
712        assert_eq!(p.message, "did not find expected key");
713        assert_eq!(locate_any(t, "nothing to quote").line, None);
714    }
715
716    #[test]
717    fn a_merge_diff_applied_gives_the_new_settings() {
718        let old = json!({"a": 1, "b": {"c": 2, "d": 3}, "e": [1, 2], "f": "x"});
719        let new = json!({"a": 1, "b": {"c": 9}, "e": [1], "g": true});
720        let patch = merge_diff(&old, &new);
721        assert_eq!(
722            patch,
723            json!({"b": {"c": 9, "d": null}, "e": [1], "f": null, "g": true})
724        );
725        let mut applied = old.clone();
726        super::super::merge_patch(&mut applied, &patch);
727        assert_eq!(applied, new);
728        // Nothing to say when nothing differs.
729        assert_eq!(merge_diff(&new, &new), json!({}));
730    }
731
732    #[test]
733    fn changed_fields_in_document_order() {
734        let a = parse(&spec("replicas: 1\nport: 80\n")).unwrap();
735        let b = parse(&spec("replicas: 3\nport: 80\nenv: A=1\n")).unwrap();
736        assert_eq!(changed_fields(&a, &b), ["env", "replicas"]);
737        assert!(changed_fields(&a, &a).is_empty());
738    }
739
740    #[test]
741    fn unified_diff_shows_the_edit_with_context() {
742        let old = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\n";
743        let new = "a\nb\nc\nd\ne\nf\ng\nH\ni\nj\nk\nl\n";
744        let d = unified_diff(old, new);
745        assert_eq!(
746            d,
747            "--- current\n+++ proposed\n@@ -5,7 +5,7 @@\n e\n f\n g\n-h\n+H\n i\n j\n k\n"
748        );
749        assert_eq!(unified_diff(old, old), "");
750        // A pure addition at the start of an empty file.
751        assert_eq!(
752            unified_diff("", "x\n"),
753            "--- current\n+++ proposed\n@@ -0,0 +1,1 @@\n+x\n"
754        );
755        // Two distant edits are two hunks.
756        let long: String = (0..40).map(|i| format!("l{i}\n")).collect();
757        let edited = long.replace("l2\n", "L2\n").replace("l35\n", "L35\n");
758        assert_eq!(unified_diff(&long, &edited).matches("@@ -").count(), 2);
759    }
760
761    use std::sync::Arc;
762    use std::time::Duration;
763
764    use crate::client::Client;
765    use crate::org::OrgId;
766    use crate::secrets::Secrets;
767    use crate::stack::Controller;
768
769    /// An `Apps` with no incusd behind it: everything but a deploy works.
770    fn apps(dir: &std::path::Path) -> Apps {
771        let k = crate::secrets::Keyring::new(age::x25519::Identity::generate(), vec![]);
772        let secrets = Arc::new(Secrets::new(crate::secrets::LocalDriver::new(
773            dir,
774            Arc::new(k),
775        )));
776        let client = Client::with_socket("/nonexistent/isb-test/incus.sock");
777        let store = crate::stack::Store::open(dir).unwrap();
778        let ctl = Controller::start(
779            client.clone(),
780            store,
781            Duration::from_secs(60),
782            secrets.clone(),
783        )
784        .unwrap();
785        Apps::new(dir, client, ctl, secrets)
786    }
787
788    fn shop(ap: &Apps, org: &OrgId) {
789        ap.project_create(org, "shop", "", &["production".into(), "staging".into()])
790            .unwrap();
791    }
792
793    #[test]
794    fn apply_creates_then_updates_then_finds_nothing_to_do() {
795        let dir = tempfile::tempdir().unwrap();
796        let ap = apps(dir.path());
797        let org = OrgId::default_org();
798        shop(&ap, &org);
799
800        // A new name is a create; a dry run writes nothing.
801        let doc = spec("replicas: 2\nport: 80\n");
802        let p = plan(&ap, &org, &doc).unwrap();
803        assert_eq!(p.action, Action::Created);
804        assert!(p.current.is_none());
805        assert!(p.diff.contains("+replicas: 2"), "{}", p.diff);
806        assert!(ap.get(&org, "web").is_err(), "a plan stores nothing");
807
808        let (app, secret) = apply(&ap, &org, &p).unwrap();
809        assert_eq!(app.spec.replicas, 2);
810        assert!(secret.is_some_and(|s| !s.is_empty()));
811
812        // The same document again: nothing changes.
813        let p = plan(&ap, &org, &doc).unwrap();
814        assert_eq!(p.action, Action::Unchanged);
815        assert!(p.changes.is_empty() && p.diff.is_empty());
816
817        // The exported document is a fixed point too.
818        let exported = export_yaml(&ap.get(&org, "web").unwrap().spec).unwrap();
819        assert_eq!(
820            plan(&ap, &org, &exported).unwrap().action,
821            Action::Unchanged
822        );
823
824        // Declarative: leaving `port` and `replicas` out resets them.
825        let p = plan(&ap, &org, &spec("env: A=1\n")).unwrap();
826        assert_eq!(p.action, Action::Updated);
827        assert_eq!(p.changes, ["env", "replicas", "port"]);
828        assert!(
829            p.diff.contains("-replicas: 2") && p.diff.contains("-port: 80"),
830            "{}",
831            p.diff
832        );
833        let (app, secret) = apply(&ap, &org, &p).unwrap();
834        assert!(secret.is_none());
835        assert_eq!((app.spec.replicas, app.spec.port), (1, None));
836        assert_eq!(app.spec.env.render(), "A=1\n");
837        assert_eq!(ap.get(&org, "web").unwrap().spec, app.spec);
838    }
839
840    #[test]
841    fn apply_refuses_what_update_refuses_and_places_the_line() {
842        let dir = tempfile::tempdir().unwrap();
843        let ap = apps(dir.path());
844        let org = OrgId::default_org();
845        shop(&ap, &org);
846        apply(&ap, &org, &plan(&ap, &org, &spec("")).unwrap()).unwrap();
847
848        // Moving an app is not an update.
849        let moved =
850            "name: web\nproject: shop\nenvironment: staging\nsource: {image: docker:nginx}\n";
851        let e = plan(&ap, &org, moved).unwrap_err();
852        assert!(e.message.contains("fixed"), "{e:?}");
853
854        // A project that is not there, an environment that is not there.
855        let e = plan(
856            &ap,
857            &org,
858            &spec("").replace("shop", "nope").replace("web", "other"),
859        )
860        .unwrap_err();
861        assert!(e.message.contains("nope"), "{e:?}");
862        let e = plan(
863            &ap,
864            &org,
865            "name: db\nproject: shop\nenvironment: qa\nsource: {image: docker:x}\n",
866        )
867        .unwrap_err();
868        assert_eq!(e.line, Some(3), "{e:?}");
869
870        // A spec the validator refuses, a secret that is not there.
871        let e = plan(&ap, &org, &spec("replicas: 500\n")).unwrap_err();
872        assert_eq!(e.line, Some(5), "{e:?}");
873        let e = plan(&ap, &org, &spec("env: |\n  K=${{secret.ghost}}\n")).unwrap_err();
874        assert!(e.message.contains("ghost"), "{e:?}");
875        assert_eq!(e.line, Some(6), "{e:?}");
876        // Nothing of that was stored.
877        assert_eq!(ap.get(&org, "web").unwrap().spec.replicas, 1);
878    }
879
880    #[test]
881    fn plans_carry_removals_only_for_an_existing_app() {
882        // Files name org secrets, which must exist; the other fields suffice here.
883        let full = "env: |\n  A=1\n  B=2\ndomains:\n  - host: a.example.com\n    https: true\n  - host: b.example.com\nvolumes: [\"data:/data\"]\nports: [\"127.0.0.1:8080:80\"]\nport: 80\nreplicas: 2\nhealthcheck: {test: [\"CMD\", \"true\"]}\ncommand: [\"run\"]\nuser: \"1000\"\n";
884        let dir = tempfile::tempdir().unwrap();
885        let ap = apps(dir.path());
886        let org = OrgId::default_org();
887        shop(&ap, &org);
888
889        // Create: nothing to remove.
890        let p = plan(&ap, &org, &spec(full)).unwrap();
891        assert_eq!(p.action, Action::Created);
892        assert!(p.removals.is_empty());
893        apply(&ap, &org, &p).unwrap();
894
895        // Unchanged and a pure addition: none.
896        assert!(plan(&ap, &org, &spec(full)).unwrap().removals.is_empty());
897        let more = format!("{full}working_dir: /srv\n");
898        let p = plan(&ap, &org, &spec(&more)).unwrap();
899        assert_eq!(p.action, Action::Updated);
900        assert!(p.removals.is_empty());
901
902        // A short document: the plan says what it drops, and writes nothing.
903        let p = plan(&ap, &org, &spec("replicas: 2\n")).unwrap();
904        assert_eq!(p.action, Action::Updated);
905        assert!(p.removals.contains(&"domains: a.example.com".to_string()));
906        assert!(p.removals.contains(&"env: A".to_string()));
907        assert_eq!(ap.get(&org, "web").unwrap().spec.domains.len(), 2);
908    }
909}