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}
92
93const 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
115pub 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
140pub 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 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
172fn 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 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
217fn 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
231fn 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
263pub 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 (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
277fn line_of_text(text: &str, name: &str) -> Option<usize> {
280 text.lines().position(|l| l.contains(name)).map(|i| i + 1)
281}
282
283pub fn locate(text: &str, message: &str) -> Problem {
285 let mut p = Problem::new(message);
286 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
300pub 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
343pub 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
368pub 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 out.sort_by_key(|k| ORDER.iter().position(|o| o == k).unwrap_or(usize::MAX));
385 out
386}
387
388pub 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 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 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 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 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 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
492pub 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
520pub 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 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
601pub 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 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 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 assert_eq!(
745 unified_diff("", "x\n"),
746 "--- current\n+++ proposed\n@@ -0,0 +1,1 @@\n+x\n"
747 );
748 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 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 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 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 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 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 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 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 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 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 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 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 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 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}