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