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