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 "secret_on_change",
114];
115
116pub 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
141pub 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 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
173fn 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 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
218fn 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
232fn 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
264pub 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 (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
278fn line_of_text(text: &str, name: &str) -> Option<usize> {
281 text.lines().position(|l| l.contains(name)).map(|i| i + 1)
282}
283
284pub fn locate(text: &str, message: &str) -> Problem {
286 let mut p = Problem::new(message);
287 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
301pub 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
344pub 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
369pub 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 out.sort_by_key(|k| ORDER.iter().position(|o| o == k).unwrap_or(usize::MAX));
386 out
387}
388
389pub 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 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 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 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 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 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
493pub 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
521pub 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 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
602pub 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 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 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 assert_eq!(
746 unified_diff("", "x\n"),
747 "--- current\n+++ proposed\n@@ -0,0 +1,1 @@\n+x\n"
748 );
749 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 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 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 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 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 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 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 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 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 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 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 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 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 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}