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