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