1mod close;
26pub mod database;
27pub mod deploy;
28pub mod env;
29pub mod forge;
30pub mod git;
31pub mod manifest;
32pub mod preview;
33mod removals;
34pub mod webhook;
35
36use std::collections::BTreeMap;
37use std::path::{Path, PathBuf};
38
39use serde::{Deserialize, Serialize};
40use serde_json::{Value, json};
41
42use crate::build::Builder;
43use crate::error::{Error, Result};
44use crate::org::OrgId;
45use crate::spec::{NamedVolumeSpec, SandboxSpec, SecretDef};
46
47pub use database::{DatabaseSource, Engine};
48pub use deploy::{Apps, BuildFn, DigestFn, SecretHook};
49pub use env::{EnvFile, EnvValue};
50pub use git::{GitAuth, GitSource};
51pub use preview::{Preview, PreviewSettings};
52
53pub const DEFAULT_ENVIRONMENT: &str = "production";
55
56pub const LABEL_APP: &str = "isb.app";
58
59pub fn org_root(state: &Path, org: &OrgId) -> PathBuf {
61 if org.is_default() {
62 state.to_path_buf()
63 } else {
64 org.dir(state)
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct Project {
71 pub name: String,
72 #[serde(default, skip_serializing_if = "String::is_empty")]
73 pub description: String,
74 pub environments: Vec<String>,
75 pub created_at: u64,
76}
77
78pub fn validate_part(kind: &str, s: &str) -> Result<()> {
80 let ok = !s.is_empty()
81 && s.len() <= 24
82 && s.starts_with(|c: char| c.is_ascii_lowercase())
83 && !s.ends_with('-')
84 && s.chars()
85 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
86 if !ok {
87 return Err(Error::invalid(format!(
88 "{kind} name {s:?}: up to 24 characters of [a-z0-9-], starting with a letter"
89 )));
90 }
91 if kind == "environment" && preview::is_pr_suffix(s) {
93 return Err(Error::invalid(format!(
94 "environment name {s:?}: names ending in pr-<number> are kept for previews"
95 )));
96 }
97 Ok(())
98}
99
100pub fn stack_name(project: &str, environment: &str) -> Result<String> {
102 let n = format!("{project}-{environment}");
103 crate::stack::validate_stack_name(&n).map_err(|_| {
104 Error::invalid(format!(
105 "{project} + {environment}: the stack name {n:?} is over 30 characters; shorten one"
106 ))
107 })?;
108 Ok(n)
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "lowercase", deny_unknown_fields)]
114pub enum Source {
115 Image(String),
118 Git(GitSource),
119 Database(DatabaseSource),
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct BuildSettings {
127 pub builder: Builder,
128 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
130 pub args: BTreeMap<String, String>,
131 #[serde(default = "yes")]
133 pub untrusted: bool,
134}
135
136fn yes() -> bool {
137 true
138}
139
140#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(deny_unknown_fields)]
143pub struct Resources {
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub cpus: Option<String>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub memory: Option<String>,
150}
151
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154#[serde(deny_unknown_fields)]
155pub struct AppSpec {
156 pub name: String,
157 pub project: String,
158 #[serde(default = "default_env")]
159 pub environment: String,
160 pub source: Source,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub build: Option<BuildSettings>,
163 #[serde(default)]
165 pub env: EnvFile,
166 #[serde(default, skip_serializing_if = "Vec::is_empty")]
170 pub domains: Vec<serde_json::Map<String, Value>>,
171 #[serde(default, skip_serializing_if = "Vec::is_empty")]
175 pub volumes: Vec<String>,
176 #[serde(default, skip_serializing_if = "Vec::is_empty")]
179 pub ports: Vec<String>,
180 #[serde(default = "one")]
181 pub replicas: u32,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub port: Option<u16>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub healthcheck: Option<Value>,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub resources: Option<Resources>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub command: Option<Value>,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub previews: Option<PreviewSettings>,
196 #[serde(default, skip_serializing_if = "Vec::is_empty")]
199 pub files: Vec<AppFile>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub user: Option<String>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub working_dir: Option<String>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub secret_on_change: Option<crate::spec::OnChange>,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(deny_unknown_fields)]
215pub struct AppFile {
216 pub path: String,
218 pub secret: String,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub mode: Option<String>,
223}
224
225fn default_env() -> String {
226 DEFAULT_ENVIRONMENT.into()
227}
228
229fn one() -> u32 {
230 1
231}
232
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
235pub struct App {
236 pub spec: AppSpec,
237 pub created_at: u64,
238 pub updated_at: u64,
239 #[serde(default = "one_u64")]
241 pub next_deployment: u64,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub current: Option<u64>,
245}
246
247fn one_u64() -> u64 {
248 1
249}
250
251impl AppSpec {
252 pub fn stack(&self) -> Result<String> {
253 stack_name(&self.project, &self.environment)
254 }
255
256 pub fn validate(&self) -> Result<()> {
258 validate_app_name(&self.name)?;
259 validate_part("project", &self.project)?;
260 validate_part("environment", &self.environment)?;
261 let stack = self.stack()?;
262 crate::stack::instance_name(&stack, &self.name, 100, "0000").map_err(|_| {
263 Error::invalid(format!(
264 "app {}: instance names in stack {stack} would be too long; shorten the app, project or environment name",
265 self.name
266 ))
267 })?;
268 match (&self.source, &self.build) {
269 (Source::Image(i), None) => {
270 crate::plan::ImageSource::parse(i)?;
271 }
272 (Source::Image(_), Some(_)) => {
273 return Err(Error::invalid("an image source is not built; drop `build`"));
274 }
275 (Source::Git(g), Some(_)) => {
276 g.validate()?;
277 }
278 (Source::Git(_), None) => {
279 return Err(Error::invalid(
280 "a git source needs `build` (e.g. {builder: {type: railpack}})",
281 ));
282 }
283 (Source::Database(db), _) => database::validate(self, db)?,
284 }
285 if self.replicas > 100 {
286 return Err(Error::invalid("replicas: at most 100"));
287 }
288 for v in &self.volumes {
289 parse_volume(v)?;
290 }
291 if let Some(p) = &self.previews {
292 p.validate(self)?;
293 }
294 let mut paths = std::collections::BTreeSet::new();
295 for f in &self.files {
296 if !f.path.starts_with('/') || f.path.ends_with('/') || f.path.contains("/../") {
297 return Err(Error::invalid(format!(
298 "file {:?}: the path must be an absolute file path",
299 f.path
300 )));
301 }
302 if !paths.insert(f.path.as_str()) {
303 return Err(Error::invalid(format!("file {:?} is given twice", f.path)));
304 }
305 crate::secrets::validate_name(&f.secret)?;
306 }
307 for d in &self.domains {
308 let host = d.get("host").and_then(Value::as_str).unwrap_or("");
309 if host.is_empty() {
310 return Err(Error::invalid("every domain needs a host"));
311 }
312 if !d.contains_key("port") && self.port.is_none() {
313 return Err(Error::invalid(format!(
314 "domain {host}: give it a port, or set the app's port"
315 )));
316 }
317 }
318 Ok(())
319 }
320
321 pub fn webhook_secret(&self) -> String {
323 webhook_secret(&self.name)
324 }
325}
326
327pub fn webhook_secret(app: &str) -> String {
328 format!("app.{app}.webhook")
329}
330
331pub fn deploy_key_secret(app: &str) -> String {
332 format!("app.{app}.deploy-key")
333}
334
335pub fn validate_app_name(s: &str) -> Result<()> {
337 let ok = !s.is_empty()
338 && s.len() <= 30
339 && s.starts_with(|c: char| c.is_ascii_lowercase())
340 && !s.ends_with('-')
341 && s.chars()
342 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
343 if ok {
344 Ok(())
345 } else {
346 Err(Error::invalid(format!(
347 "app name {s:?}: up to 30 characters of [a-z0-9-], starting with a letter"
348 )))
349 }
350}
351
352pub(crate) fn parse_volume(v: &str) -> Result<(String, String, Option<String>)> {
354 let mut parts = v.splitn(3, ':');
355 let name = parts.next().unwrap_or("");
356 let target = parts.next().unwrap_or("");
357 let opts = parts.next().map(String::from);
358 let name_ok = !name.is_empty()
359 && name.len() <= 30
360 && name.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
361 && name
362 .chars()
363 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
364 if !name_ok {
365 return Err(Error::invalid(format!(
366 "volume {v:?}: NAME:/path with NAME of [a-z0-9-] (apps take named volumes only, never host paths)"
367 )));
368 }
369 if !target.starts_with('/') {
370 return Err(Error::invalid(format!(
371 "volume {v:?}: the target must be an absolute path"
372 )));
373 }
374 if let Some(o) = &opts {
375 if !matches!(o.as_str(), "ro" | "rw") {
376 return Err(Error::invalid(format!(
377 "volume {v:?}: options are ro or rw"
378 )));
379 }
380 }
381 Ok((name.to_string(), target.to_string(), opts))
382}
383
384#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
388pub struct Rendered {
389 pub service: SandboxSpec,
390 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
391 pub secrets: BTreeMap<String, SecretDef>,
392 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
393 pub volumes: BTreeMap<String, NamedVolumeSpec>,
394}
395
396fn secret_key(app: &str, name: &str) -> String {
398 format!("{app}.{name}")
399}
400
401fn volume_key(app: &str, name: &str) -> String {
402 format!("{app}_{name}")
403}
404
405pub fn compose_takes_domains() -> bool {
409 serde_json::from_value::<SandboxSpec>(json!({"image": "x", "domains": []})).is_ok()
410}
411
412#[expect(
415 clippy::too_many_lines,
416 reason = "predates the lint ratchet; split it when next changed"
417)]
418pub fn render(spec: &AppSpec, image: &str, notes: &mut Vec<String>) -> Result<Rendered> {
419 let effective;
420 let spec = match &spec.source {
421 Source::Database(db) => {
422 effective = database::effective(spec, db);
423 &effective
424 }
425 _ => spec,
426 };
427 let mut environment = serde_json::Map::new();
428 let mut secrets = BTreeMap::new();
429 for (k, v) in spec.env.vars() {
430 match v {
431 EnvValue::Plain(s) => {
432 environment.insert(k.to_string(), json!(s));
433 }
434 EnvValue::Secret { secret } => {
435 let key = secret_key(&spec.name, secret);
436 environment.insert(k.to_string(), json!({"secret": key}));
437 secrets.insert(
438 key,
439 SecretDef {
440 external: true,
441 name: Some(secret.clone()),
442 on_change: spec.secret_on_change,
443 ..Default::default()
444 },
445 );
446 }
447 }
448 }
449 if let Source::Database(db) = &spec.source {
452 let mut rotate = |name: String, root: bool| {
453 if let Some(d) = secrets.get_mut(&secret_key(&spec.name, &name)) {
454 d.rotate = Some(db.engine.rotate_command(root));
455 }
456 };
457 rotate(database::password_secret(&spec.name), false);
458 if db.engine.has_root_password() {
459 rotate(database::root_password_secret(&spec.name), true);
460 }
461 }
462 let mut volumes = BTreeMap::new();
463 let mut mounts = Vec::new();
464 for v in &spec.volumes {
465 let (name, target, opts) = parse_volume(v)?;
466 let key = volume_key(&spec.name, &name);
467 mounts.push(match opts {
468 Some(o) => format!("{key}:{target}:{o}"),
469 None => format!("{key}:{target}"),
470 });
471 volumes.insert(key, NamedVolumeSpec::default());
472 }
473 let mut labels = serde_json::Map::new();
474 labels.insert(LABEL_APP.into(), json!(spec.name));
475 let order = if spec.volumes.is_empty() {
478 "start-first"
479 } else {
480 "stop-first"
481 };
482 let mut svc = json!({
483 "image": image,
484 "labels": labels,
485 "deploy": {"replicas": spec.replicas, "update_config": {"order": order}},
486 });
487 if !environment.is_empty() {
488 svc["environment"] = Value::Object(environment);
489 }
490 if !mounts.is_empty() {
491 svc["volumes"] = json!(mounts);
492 }
493 if !spec.ports.is_empty() {
494 svc["ports"] = json!(spec.ports);
495 }
496 if let Some(c) = &spec.command {
497 svc["command"] = c.clone();
498 }
499 if let Some(h) = &spec.healthcheck {
500 svc["healthcheck"] = h.clone();
501 }
502 if !spec.files.is_empty() {
503 let mut refs = Vec::new();
504 for f in &spec.files {
505 let key = secret_key(&spec.name, &f.secret);
506 let mut r = json!({"source": key, "target": f.path});
507 if let Some(m) = &f.mode {
508 r["mode"] = json!(m);
509 }
510 refs.push(r);
511 secrets.insert(
512 key,
513 SecretDef {
514 external: true,
515 name: Some(f.secret.clone()),
516 on_change: spec.secret_on_change,
517 ..Default::default()
518 },
519 );
520 }
521 svc["secrets"] = json!(refs);
522 }
523 if let Some(u) = &spec.user {
524 svc["user"] = json!(u);
525 }
526 if let Some(w) = &spec.working_dir {
527 svc["working_dir"] = json!(w);
528 }
529 if let Some(r) = &spec.resources {
530 if let Some(c) = &r.cpus {
531 svc["cpus"] = json!(c);
532 }
533 if let Some(m) = &r.memory {
534 svc["mem_limit"] = json!(m);
535 }
536 }
537 let parse = |v: Value| {
538 serde_json::from_value::<SandboxSpec>(v)
539 .map_err(|e| Error::invalid(format!("app {}: {e}", spec.name)))
540 };
541 let service = if spec.domains.is_empty() {
542 parse(svc)?
543 } else {
544 let domains: Vec<Value> = spec
545 .domains
546 .iter()
547 .map(|d| {
548 let mut d = d.clone();
549 if let (false, Some(p)) = (d.contains_key("port"), spec.port) {
550 d.insert("port".into(), json!(p));
551 }
552 Value::Object(d)
553 })
554 .collect();
555 let mut with = svc.clone();
556 with["domains"] = json!(domains);
557 if compose_takes_domains() {
558 parse(with)?
559 } else {
560 notes.push(format!(
561 "domains ({}) are kept with the app; this isb has no ingress to serve them yet",
562 spec.domains
563 .iter()
564 .filter_map(|d| d.get("host").and_then(Value::as_str))
565 .collect::<Vec<_>>()
566 .join(", ")
567 ));
568 parse(svc)?
569 }
570 };
571 Ok(Rendered {
572 service,
573 secrets,
574 volumes,
575 })
576}
577
578pub fn splice(
582 current: Option<&crate::spec::ComposeFile>,
583 stack: &str,
584 app: &str,
585 r: Option<&Rendered>,
586) -> crate::spec::ComposeFile {
587 let mut f = current.cloned().unwrap_or_default();
588 f.name = Some(stack.to_string());
589 f.services.remove(app);
590 if let Some(r) = r {
591 f.services.insert(app.to_string(), r.service.clone());
592 for (k, v) in &r.secrets {
593 f.secrets.insert(k.clone(), v.clone());
594 }
595 for (k, v) in &r.volumes {
596 f.volumes.insert(k.clone(), v.clone());
597 }
598 }
599 let used_secrets = crate::stack::secrets::used_keys(&f);
600 f.secrets.retain(|k, _| used_secrets.contains(k));
601 let used_volumes: std::collections::BTreeSet<String> = f
602 .services
603 .values()
604 .flat_map(|s| s.volumes.iter().map(|v| v.source.clone()))
605 .collect();
606 f.volumes.retain(|k, _| used_volumes.contains(k));
607 f
608}
609
610pub fn merge_patch(base: &mut Value, patch: &Value) {
612 match (base, patch) {
613 (Value::Object(b), Value::Object(p)) => {
614 for (k, v) in p {
615 if v.is_null() {
616 b.remove(k);
617 } else {
618 merge_patch(b.entry(k.clone()).or_insert(Value::Null), v);
619 }
620 }
621 }
622 (b, p) => *b = p.clone(),
623 }
624}
625
626pub fn pin(image: &str, digest: &str) -> Option<String> {
630 let (prefix, rest) = image.split_once(':')?;
631 if !matches!(prefix, "docker" | "ghcr" | "quay" | "oci") || !digest.starts_with("sha256:") {
632 return None;
633 }
634 let rest = rest.split('@').next().unwrap_or(rest);
635 let (dir, last) = match rest.rsplit_once('/') {
636 Some((d, l)) => (Some(d), l),
637 None => (None, rest),
638 };
639 let last = last.split(':').next().unwrap_or(last);
640 Some(match dir {
641 Some(d) => format!("{prefix}:{d}/{last}@{digest}"),
642 None => format!("{prefix}:{last}@{digest}"),
643 })
644}
645
646#[doc(hidden)]
648pub fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
649 use std::io::Write;
650 use std::os::unix::fs::OpenOptionsExt;
651 if let Some(d) = path.parent() {
652 std::fs::create_dir_all(d)?;
653 }
654 let tmp = path.with_extension(format!("tmp-{}", git::random_hex(4)));
655 let mut f = std::fs::OpenOptions::new()
656 .write(true)
657 .create(true)
658 .truncate(true)
659 .mode(0o600)
660 .open(&tmp)?;
661 f.write_all(data)?;
662 f.sync_all()?;
663 std::fs::rename(&tmp, path)?;
664 Ok(())
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670
671 fn spec(y: &str) -> AppSpec {
673 try_spec(y).unwrap()
674 }
675
676 fn try_spec(y: &str) -> std::result::Result<AppSpec, serde_json::Error> {
677 serde_json::from_value(serde_yaml_ng::from_str::<Value>(y).unwrap())
678 }
679
680 #[test]
681 fn spec_forms_and_validation() {
682 let a = spec(
683 "name: web\nproject: shop\nsource: {image: 'docker:traefik/whoami'}\nenv: {A: '1', T: {secret: tok}}\n",
684 );
685 assert_eq!(a.environment, "production");
686 assert_eq!(a.replicas, 1);
687 assert_eq!(a.stack().unwrap(), "shop-production");
688 a.validate().unwrap();
689 let g = spec(
690 "name: api\nproject: shop\nsource: {git: {url: 'https://h/o/r', ref: dev}}\nbuild: {builder: {type: railpack}}\n",
691 );
692 g.validate().unwrap();
693 assert!(g.build.as_ref().unwrap().untrusted);
694 let mut bad = g.clone();
695 bad.build = None;
696 assert!(bad.validate().is_err());
697 let mut bad = a.clone();
698 bad.volumes = vec!["/etc:/x".into()];
699 assert!(bad.validate().is_err());
700 bad.volumes = vec!["data:/var/lib/x".into()];
701 bad.validate().unwrap();
702 bad.domains = vec![serde_json::from_str(r#"{"host":"a.example.com"}"#).unwrap()];
703 assert!(bad.validate().is_err(), "a domain needs a port");
704 bad.port = Some(80);
705 bad.validate().unwrap();
706 let mut bad = a.clone();
707 bad.project = "a-very-long-project-name".into();
708 bad.environment = "staging-environment".into();
709 assert!(bad.validate().is_err());
710 assert!(try_spec("name: x\nproject: p\nsource: {image: x}\nbogus: 1\n").is_err());
711 assert!(try_spec("name: x\nproject: p\nsource: {image: x, git: {url: u}}\n").is_err());
712 }
713
714 #[test]
715 fn renders_one_service() {
716 let a = spec(concat!(
717 "name: web\nproject: shop\nsource: {image: 'docker:traefik/whoami'}\n",
718 "env: \"# c\\nA=1\\nT=${{secret.tok}}\\n\"\n",
719 "volumes: ['data:/data']\nports: ['127.0.0.1:18080:80']\nreplicas: 2\nport: 80\n",
720 "command: [/whoami, --port, '80']\n",
721 "healthcheck: {test: [CMD, /whoami, --help], interval: 5s}\n",
722 "resources: {cpus: '1', memory: 256m}\n",
723 ));
724 let mut notes = vec![];
725 let r = render(&a, "docker:traefik/whoami@sha256:ab", &mut notes).unwrap();
726 let s = &r.service;
727 assert_eq!(s.image, "docker:traefik/whoami@sha256:ab");
728 assert_eq!(s.env["A"], "1");
729 assert_eq!(s.env.secrets["T"], "web.tok");
730 assert_eq!(r.secrets["web.tok"].name.as_deref(), Some("tok"));
731 assert!(r.secrets["web.tok"].external);
732 assert_eq!(s.volumes[0].source, "web_data");
733 assert!(r.volumes.contains_key("web_data"));
734 assert_eq!(s.replicas(), 2);
735 assert_eq!(s.labels[LABEL_APP], "web");
736 assert_eq!(s.cpus.as_deref(), Some("1"));
737 assert_eq!(s.memory.as_deref(), Some("256m"));
738 assert!(s.healthcheck.is_some());
739 assert_eq!(s.ports.len(), 1);
740 assert!(notes.is_empty());
741 }
742
743 #[test]
744 fn secret_on_change_reaches_every_secret_the_app_uses() {
745 let base = concat!(
746 "name: web\nproject: shop\nsource: {image: 'docker:traefik/whoami'}\n",
747 "env: \"T=${{secret.tok}}\\n\"\nfiles: [{path: /etc/app.conf, secret: conf}]\n",
748 );
749 let r = render(&spec(base), "x", &mut vec![]).unwrap();
750 assert!(r.secrets.values().all(|d| d.on_change.is_none()));
751 let a = spec(&format!("{base}secret_on_change: restart\n"));
752 let r = render(&a, "x", &mut vec![]).unwrap();
753 assert_eq!(r.secrets.len(), 2);
754 assert!(
755 r.secrets
756 .values()
757 .all(|d| d.on_change == Some(crate::spec::OnChange::Restart))
758 );
759 assert!(try_spec(&format!("{base}secret_on_change: sometimes\n")).is_err());
760 }
761
762 #[test]
763 fn domains_follow_the_parser() {
764 let mut a = spec("name: web\nproject: shop\nsource: {image: x}\nport: 8080\n");
765 a.domains = vec![serde_json::from_str(r#"{"host":"shop.example.com"}"#).unwrap()];
766 let mut notes = vec![];
767 let r = render(&a, "x", &mut notes).unwrap();
768 if compose_takes_domains() {
769 let v = serde_json::to_value(&r.service).unwrap();
770 assert_eq!(v["domains"][0]["port"], 8080);
771 assert!(notes.is_empty());
772 } else {
773 assert_eq!(notes.len(), 1, "{notes:?}");
774 }
775 }
776
777 #[test]
778 fn splice_touches_only_the_app() {
779 let mut notes = vec![];
780 let web = spec(
781 "name: web\nproject: shop\nsource: {image: x}\nenv: {T: {secret: tok}}\nvolumes: ['d:/d']\n",
782 );
783 let api = spec("name: api\nproject: shop\nsource: {image: y}\nenv: {T: {secret: tok}}\n");
784 let rw = render(&web, "x", &mut notes).unwrap();
785 let ra = render(&api, "y", &mut notes).unwrap();
786 let f1 = splice(None, "shop-production", "web", Some(&rw));
787 let f2 = splice(Some(&f1), "shop-production", "api", Some(&ra));
788 assert_eq!(f2.services.len(), 2);
789 assert_eq!(f2.services["web"], f1.services["web"]);
790 assert_eq!(
791 f2.secrets.keys().collect::<Vec<_>>(),
792 ["api.tok", "web.tok"]
793 );
794 let def = |f: &crate::spec::ComposeFile| crate::stack::StackDef {
796 name: "shop-production".into(),
797 org: OrgId::default_org(),
798 file: f.clone(),
799 base_dir: "/".into(),
800 secrets: Default::default(),
801 force: Default::default(),
802 images: Default::default(),
803 deployed_at: 0,
804 deployed_by: String::new(),
805 previous: None,
806 };
807 let mut web2 = web.clone();
808 web2.env.set("B", EnvValue::Plain("2".into()));
809 let rw2 = render(&web2, "x", &mut notes).unwrap();
810 let f3 = splice(Some(&f2), "shop-production", "web", Some(&rw2));
811 assert_eq!(
812 def(&f2).revision("api").unwrap(),
813 def(&f3).revision("api").unwrap()
814 );
815 assert_ne!(
816 def(&f2).revision("web").unwrap(),
817 def(&f3).revision("web").unwrap()
818 );
819 let f4 = splice(Some(&f3), "shop-production", "web", None);
821 assert_eq!(f4.services.keys().collect::<Vec<_>>(), ["api"]);
822 assert_eq!(f4.secrets.keys().collect::<Vec<_>>(), ["api.tok"]);
823 assert!(f4.volumes.is_empty());
824 }
825
826 #[test]
827 fn pins_digests() {
828 let d = "sha256:abc";
829 assert_eq!(
830 pin("docker:traefik/whoami", d).unwrap(),
831 "docker:traefik/whoami@sha256:abc"
832 );
833 assert_eq!(
834 pin("docker:nginx:1.27", d).unwrap(),
835 "docker:nginx@sha256:abc"
836 );
837 assert_eq!(
838 pin("oci:reg.example.com:5000/team/app:v2", d).unwrap(),
839 "oci:reg.example.com:5000/team/app@sha256:abc"
840 );
841 assert_eq!(
842 pin("ghcr:o/a@sha256:old", d).unwrap(),
843 "ghcr:o/a@sha256:abc"
844 );
845 assert!(pin("dev-base", d).is_none());
846 assert!(pin("images:debian/12", d).is_none());
847 }
848
849 #[test]
850 fn merge_patch_rfc7396() {
851 let mut b = json!({"a": 1, "b": {"c": 2, "d": 3}});
852 merge_patch(&mut b, &json!({"a": null, "b": {"c": 9}, "e": [1]}));
853 assert_eq!(b, json!({"b": {"c": 9, "d": 3}, "e": [1]}));
854 }
855}