1use 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
20const 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
38pub struct Problem {
39 #[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
67#[serde(rename_all = "lowercase")]
68pub enum Action {
69 Created,
70 Updated,
71 Unchanged,
72}
73
74#[derive(Debug, Clone)]
76pub struct Plan {
77 pub action: Action,
78 pub spec: AppSpec,
80 pub current: Option<String>,
82 pub proposed: String,
84 pub diff: String,
86 pub changes: Vec<String>,
88 pub removals: Vec<String>,
91 pub warning: Option<String>,
93}
94
95const 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
118pub 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
143pub 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 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
175fn 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 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
220fn 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
234fn 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
266pub 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 (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
280fn line_of_text(text: &str, name: &str) -> Option<usize> {
283 text.lines().position(|l| l.contains(name)).map(|i| i + 1)
284}
285
286pub fn locate(text: &str, message: &str) -> Problem {
288 let mut p = Problem::new(message);
289 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
303pub 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
346pub 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
371pub 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 out.sort_by_key(|k| ORDER.iter().position(|o| o == k).unwrap_or(usize::MAX));
388 out
389}
390
391pub 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 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 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 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 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 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
495pub 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
523pub 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 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
608pub 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 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 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 assert_eq!(
752 unified_diff("", "x\n"),
753 "--- current\n+++ proposed\n@@ -0,0 +1,1 @@\n+x\n"
754 );
755 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 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 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 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 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 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 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 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 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 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 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 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 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 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}