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};
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}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(deny_unknown_fields)]
210pub struct AppFile {
211 pub path: String,
213 pub secret: String,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub mode: Option<String>,
218}
219
220fn default_env() -> String {
221 DEFAULT_ENVIRONMENT.into()
222}
223
224fn one() -> u32 {
225 1
226}
227
228#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
230pub struct App {
231 pub spec: AppSpec,
232 pub created_at: u64,
233 pub updated_at: u64,
234 #[serde(default = "one_u64")]
236 pub next_deployment: u64,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub current: Option<u64>,
240}
241
242fn one_u64() -> u64 {
243 1
244}
245
246impl AppSpec {
247 pub fn stack(&self) -> Result<String> {
248 stack_name(&self.project, &self.environment)
249 }
250
251 pub fn validate(&self) -> Result<()> {
253 validate_app_name(&self.name)?;
254 validate_part("project", &self.project)?;
255 validate_part("environment", &self.environment)?;
256 let stack = self.stack()?;
257 crate::stack::instance_name(&stack, &self.name, 100, "0000").map_err(|_| {
258 Error::invalid(format!(
259 "app {}: instance names in stack {stack} would be too long; shorten the app, project or environment name",
260 self.name
261 ))
262 })?;
263 match (&self.source, &self.build) {
264 (Source::Image(i), None) => {
265 crate::plan::ImageSource::parse(i)?;
266 }
267 (Source::Image(_), Some(_)) => {
268 return Err(Error::invalid("an image source is not built; drop `build`"));
269 }
270 (Source::Git(g), Some(_)) => {
271 g.validate()?;
272 }
273 (Source::Git(_), None) => {
274 return Err(Error::invalid(
275 "a git source needs `build` (e.g. {builder: {type: railpack}})",
276 ));
277 }
278 (Source::Database(db), _) => database::validate(self, db)?,
279 }
280 if self.replicas > 100 {
281 return Err(Error::invalid("replicas: at most 100"));
282 }
283 for v in &self.volumes {
284 parse_volume(v)?;
285 }
286 if let Some(p) = &self.previews {
287 p.validate(self)?;
288 }
289 let mut paths = std::collections::BTreeSet::new();
290 for f in &self.files {
291 if !f.path.starts_with('/') || f.path.ends_with('/') || f.path.contains("/../") {
292 return Err(Error::invalid(format!(
293 "file {:?}: the path must be an absolute file path",
294 f.path
295 )));
296 }
297 if !paths.insert(f.path.as_str()) {
298 return Err(Error::invalid(format!("file {:?} is given twice", f.path)));
299 }
300 crate::secrets::validate_name(&f.secret)?;
301 }
302 for d in &self.domains {
303 let host = d.get("host").and_then(Value::as_str).unwrap_or("");
304 if host.is_empty() {
305 return Err(Error::invalid("every domain needs a host"));
306 }
307 if !d.contains_key("port") && self.port.is_none() {
308 return Err(Error::invalid(format!(
309 "domain {host}: give it a port, or set the app's port"
310 )));
311 }
312 }
313 Ok(())
314 }
315
316 pub fn webhook_secret(&self) -> String {
318 webhook_secret(&self.name)
319 }
320}
321
322pub fn webhook_secret(app: &str) -> String {
323 format!("app.{app}.webhook")
324}
325
326pub fn deploy_key_secret(app: &str) -> String {
327 format!("app.{app}.deploy-key")
328}
329
330pub fn validate_app_name(s: &str) -> Result<()> {
332 let ok = !s.is_empty()
333 && s.len() <= 30
334 && s.starts_with(|c: char| c.is_ascii_lowercase())
335 && !s.ends_with('-')
336 && s.chars()
337 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
338 if ok {
339 Ok(())
340 } else {
341 Err(Error::invalid(format!(
342 "app name {s:?}: up to 30 characters of [a-z0-9-], starting with a letter"
343 )))
344 }
345}
346
347pub(crate) fn parse_volume(v: &str) -> Result<(String, String, Option<String>)> {
349 let mut parts = v.splitn(3, ':');
350 let name = parts.next().unwrap_or("");
351 let target = parts.next().unwrap_or("");
352 let opts = parts.next().map(String::from);
353 let name_ok = !name.is_empty()
354 && name.len() <= 30
355 && name.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
356 && name
357 .chars()
358 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
359 if !name_ok {
360 return Err(Error::invalid(format!(
361 "volume {v:?}: NAME:/path with NAME of [a-z0-9-] (apps take named volumes only, never host paths)"
362 )));
363 }
364 if !target.starts_with('/') {
365 return Err(Error::invalid(format!(
366 "volume {v:?}: the target must be an absolute path"
367 )));
368 }
369 if let Some(o) = &opts {
370 if !matches!(o.as_str(), "ro" | "rw") {
371 return Err(Error::invalid(format!(
372 "volume {v:?}: options are ro or rw"
373 )));
374 }
375 }
376 Ok((name.to_string(), target.to_string(), opts))
377}
378
379#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
383pub struct Rendered {
384 pub service: SandboxSpec,
385 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
386 pub secrets: BTreeMap<String, SecretDef>,
387 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
388 pub volumes: BTreeMap<String, NamedVolumeSpec>,
389}
390
391fn secret_key(app: &str, name: &str) -> String {
393 format!("{app}.{name}")
394}
395
396fn volume_key(app: &str, name: &str) -> String {
397 format!("{app}_{name}")
398}
399
400pub fn compose_takes_domains() -> bool {
404 serde_json::from_value::<SandboxSpec>(json!({"image": "x", "domains": []})).is_ok()
405}
406
407#[expect(
410 clippy::too_many_lines,
411 reason = "predates the lint ratchet; split it when next changed"
412)]
413pub fn render(spec: &AppSpec, image: &str, notes: &mut Vec<String>) -> Result<Rendered> {
414 let effective;
415 let spec = match &spec.source {
416 Source::Database(db) => {
417 effective = database::effective(spec, db);
418 &effective
419 }
420 _ => spec,
421 };
422 let mut environment = serde_json::Map::new();
423 let mut secrets = BTreeMap::new();
424 for (k, v) in spec.env.vars() {
425 match v {
426 EnvValue::Plain(s) => {
427 environment.insert(k.to_string(), json!(s));
428 }
429 EnvValue::Secret { secret } => {
430 let key = secret_key(&spec.name, secret);
431 environment.insert(k.to_string(), json!({"secret": key}));
432 secrets.insert(
433 key,
434 SecretDef {
435 external: true,
436 name: Some(secret.clone()),
437 ..Default::default()
438 },
439 );
440 }
441 }
442 }
443 let mut volumes = BTreeMap::new();
444 let mut mounts = Vec::new();
445 for v in &spec.volumes {
446 let (name, target, opts) = parse_volume(v)?;
447 let key = volume_key(&spec.name, &name);
448 mounts.push(match opts {
449 Some(o) => format!("{key}:{target}:{o}"),
450 None => format!("{key}:{target}"),
451 });
452 volumes.insert(key, NamedVolumeSpec::default());
453 }
454 let mut labels = serde_json::Map::new();
455 labels.insert(LABEL_APP.into(), json!(spec.name));
456 let order = if spec.volumes.is_empty() {
459 "start-first"
460 } else {
461 "stop-first"
462 };
463 let mut svc = json!({
464 "image": image,
465 "labels": labels,
466 "deploy": {"replicas": spec.replicas, "update_config": {"order": order}},
467 });
468 if !environment.is_empty() {
469 svc["environment"] = Value::Object(environment);
470 }
471 if !mounts.is_empty() {
472 svc["volumes"] = json!(mounts);
473 }
474 if !spec.ports.is_empty() {
475 svc["ports"] = json!(spec.ports);
476 }
477 if let Some(c) = &spec.command {
478 svc["command"] = c.clone();
479 }
480 if let Some(h) = &spec.healthcheck {
481 svc["healthcheck"] = h.clone();
482 }
483 if !spec.files.is_empty() {
484 let mut refs = Vec::new();
485 for f in &spec.files {
486 let key = secret_key(&spec.name, &f.secret);
487 let mut r = json!({"source": key, "target": f.path});
488 if let Some(m) = &f.mode {
489 r["mode"] = json!(m);
490 }
491 refs.push(r);
492 secrets.insert(
493 key,
494 SecretDef {
495 external: true,
496 name: Some(f.secret.clone()),
497 ..Default::default()
498 },
499 );
500 }
501 svc["secrets"] = json!(refs);
502 }
503 if let Some(u) = &spec.user {
504 svc["user"] = json!(u);
505 }
506 if let Some(w) = &spec.working_dir {
507 svc["working_dir"] = json!(w);
508 }
509 if let Some(r) = &spec.resources {
510 if let Some(c) = &r.cpus {
511 svc["cpus"] = json!(c);
512 }
513 if let Some(m) = &r.memory {
514 svc["mem_limit"] = json!(m);
515 }
516 }
517 let parse = |v: Value| {
518 serde_json::from_value::<SandboxSpec>(v)
519 .map_err(|e| Error::invalid(format!("app {}: {e}", spec.name)))
520 };
521 let service = if spec.domains.is_empty() {
522 parse(svc)?
523 } else {
524 let domains: Vec<Value> = spec
525 .domains
526 .iter()
527 .map(|d| {
528 let mut d = d.clone();
529 if let (false, Some(p)) = (d.contains_key("port"), spec.port) {
530 d.insert("port".into(), json!(p));
531 }
532 Value::Object(d)
533 })
534 .collect();
535 let mut with = svc.clone();
536 with["domains"] = json!(domains);
537 if compose_takes_domains() {
538 parse(with)?
539 } else {
540 notes.push(format!(
541 "domains ({}) are kept with the app; this isb has no ingress to serve them yet",
542 spec.domains
543 .iter()
544 .filter_map(|d| d.get("host").and_then(Value::as_str))
545 .collect::<Vec<_>>()
546 .join(", ")
547 ));
548 parse(svc)?
549 }
550 };
551 Ok(Rendered {
552 service,
553 secrets,
554 volumes,
555 })
556}
557
558pub fn splice(
562 current: Option<&crate::spec::ComposeFile>,
563 stack: &str,
564 app: &str,
565 r: Option<&Rendered>,
566) -> crate::spec::ComposeFile {
567 let mut f = current.cloned().unwrap_or_default();
568 f.name = Some(stack.to_string());
569 f.services.remove(app);
570 if let Some(r) = r {
571 f.services.insert(app.to_string(), r.service.clone());
572 for (k, v) in &r.secrets {
573 f.secrets.insert(k.clone(), v.clone());
574 }
575 for (k, v) in &r.volumes {
576 f.volumes.insert(k.clone(), v.clone());
577 }
578 }
579 let used_secrets = crate::stack::secrets::used_keys(&f);
580 f.secrets.retain(|k, _| used_secrets.contains(k));
581 let used_volumes: std::collections::BTreeSet<String> = f
582 .services
583 .values()
584 .flat_map(|s| s.volumes.iter().map(|v| v.source.clone()))
585 .collect();
586 f.volumes.retain(|k, _| used_volumes.contains(k));
587 f
588}
589
590pub fn merge_patch(base: &mut Value, patch: &Value) {
592 match (base, patch) {
593 (Value::Object(b), Value::Object(p)) => {
594 for (k, v) in p {
595 if v.is_null() {
596 b.remove(k);
597 } else {
598 merge_patch(b.entry(k.clone()).or_insert(Value::Null), v);
599 }
600 }
601 }
602 (b, p) => *b = p.clone(),
603 }
604}
605
606pub fn pin(image: &str, digest: &str) -> Option<String> {
610 let (prefix, rest) = image.split_once(':')?;
611 if !matches!(prefix, "docker" | "ghcr" | "quay" | "oci") || !digest.starts_with("sha256:") {
612 return None;
613 }
614 let rest = rest.split('@').next().unwrap_or(rest);
615 let (dir, last) = match rest.rsplit_once('/') {
616 Some((d, l)) => (Some(d), l),
617 None => (None, rest),
618 };
619 let last = last.split(':').next().unwrap_or(last);
620 Some(match dir {
621 Some(d) => format!("{prefix}:{d}/{last}@{digest}"),
622 None => format!("{prefix}:{last}@{digest}"),
623 })
624}
625
626#[doc(hidden)]
628pub fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
629 use std::io::Write;
630 use std::os::unix::fs::OpenOptionsExt;
631 if let Some(d) = path.parent() {
632 std::fs::create_dir_all(d)?;
633 }
634 let tmp = path.with_extension(format!("tmp-{}", git::random_hex(4)));
635 let mut f = std::fs::OpenOptions::new()
636 .write(true)
637 .create(true)
638 .truncate(true)
639 .mode(0o600)
640 .open(&tmp)?;
641 f.write_all(data)?;
642 f.sync_all()?;
643 std::fs::rename(&tmp, path)?;
644 Ok(())
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650
651 fn spec(y: &str) -> AppSpec {
653 try_spec(y).unwrap()
654 }
655
656 fn try_spec(y: &str) -> std::result::Result<AppSpec, serde_json::Error> {
657 serde_json::from_value(serde_yaml_ng::from_str::<Value>(y).unwrap())
658 }
659
660 #[test]
661 fn spec_forms_and_validation() {
662 let a = spec(
663 "name: web\nproject: shop\nsource: {image: 'docker:traefik/whoami'}\nenv: {A: '1', T: {secret: tok}}\n",
664 );
665 assert_eq!(a.environment, "production");
666 assert_eq!(a.replicas, 1);
667 assert_eq!(a.stack().unwrap(), "shop-production");
668 a.validate().unwrap();
669 let g = spec(
670 "name: api\nproject: shop\nsource: {git: {url: 'https://h/o/r', ref: dev}}\nbuild: {builder: {type: railpack}}\n",
671 );
672 g.validate().unwrap();
673 assert!(g.build.as_ref().unwrap().untrusted);
674 let mut bad = g.clone();
675 bad.build = None;
676 assert!(bad.validate().is_err());
677 let mut bad = a.clone();
678 bad.volumes = vec!["/etc:/x".into()];
679 assert!(bad.validate().is_err());
680 bad.volumes = vec!["data:/var/lib/x".into()];
681 bad.validate().unwrap();
682 bad.domains = vec![serde_json::from_str(r#"{"host":"a.example.com"}"#).unwrap()];
683 assert!(bad.validate().is_err(), "a domain needs a port");
684 bad.port = Some(80);
685 bad.validate().unwrap();
686 let mut bad = a.clone();
687 bad.project = "a-very-long-project-name".into();
688 bad.environment = "staging-environment".into();
689 assert!(bad.validate().is_err());
690 assert!(try_spec("name: x\nproject: p\nsource: {image: x}\nbogus: 1\n").is_err());
691 assert!(try_spec("name: x\nproject: p\nsource: {image: x, git: {url: u}}\n").is_err());
692 }
693
694 #[test]
695 fn renders_one_service() {
696 let a = spec(concat!(
697 "name: web\nproject: shop\nsource: {image: 'docker:traefik/whoami'}\n",
698 "env: \"# c\\nA=1\\nT=${{secret.tok}}\\n\"\n",
699 "volumes: ['data:/data']\nports: ['127.0.0.1:18080:80']\nreplicas: 2\nport: 80\n",
700 "command: [/whoami, --port, '80']\n",
701 "healthcheck: {test: [CMD, /whoami, --help], interval: 5s}\n",
702 "resources: {cpus: '1', memory: 256m}\n",
703 ));
704 let mut notes = vec![];
705 let r = render(&a, "docker:traefik/whoami@sha256:ab", &mut notes).unwrap();
706 let s = &r.service;
707 assert_eq!(s.image, "docker:traefik/whoami@sha256:ab");
708 assert_eq!(s.env["A"], "1");
709 assert_eq!(s.env.secrets["T"], "web.tok");
710 assert_eq!(r.secrets["web.tok"].name.as_deref(), Some("tok"));
711 assert!(r.secrets["web.tok"].external);
712 assert_eq!(s.volumes[0].source, "web_data");
713 assert!(r.volumes.contains_key("web_data"));
714 assert_eq!(s.replicas(), 2);
715 assert_eq!(s.labels[LABEL_APP], "web");
716 assert_eq!(s.cpus.as_deref(), Some("1"));
717 assert_eq!(s.memory.as_deref(), Some("256m"));
718 assert!(s.healthcheck.is_some());
719 assert_eq!(s.ports.len(), 1);
720 assert!(notes.is_empty());
721 }
722
723 #[test]
724 fn domains_follow_the_parser() {
725 let mut a = spec("name: web\nproject: shop\nsource: {image: x}\nport: 8080\n");
726 a.domains = vec![serde_json::from_str(r#"{"host":"shop.example.com"}"#).unwrap()];
727 let mut notes = vec![];
728 let r = render(&a, "x", &mut notes).unwrap();
729 if compose_takes_domains() {
730 let v = serde_json::to_value(&r.service).unwrap();
731 assert_eq!(v["domains"][0]["port"], 8080);
732 assert!(notes.is_empty());
733 } else {
734 assert_eq!(notes.len(), 1, "{notes:?}");
735 }
736 }
737
738 #[test]
739 fn splice_touches_only_the_app() {
740 let mut notes = vec![];
741 let web = spec(
742 "name: web\nproject: shop\nsource: {image: x}\nenv: {T: {secret: tok}}\nvolumes: ['d:/d']\n",
743 );
744 let api = spec("name: api\nproject: shop\nsource: {image: y}\nenv: {T: {secret: tok}}\n");
745 let rw = render(&web, "x", &mut notes).unwrap();
746 let ra = render(&api, "y", &mut notes).unwrap();
747 let f1 = splice(None, "shop-production", "web", Some(&rw));
748 let f2 = splice(Some(&f1), "shop-production", "api", Some(&ra));
749 assert_eq!(f2.services.len(), 2);
750 assert_eq!(f2.services["web"], f1.services["web"]);
751 assert_eq!(
752 f2.secrets.keys().collect::<Vec<_>>(),
753 ["api.tok", "web.tok"]
754 );
755 let def = |f: &crate::spec::ComposeFile| crate::stack::StackDef {
757 name: "shop-production".into(),
758 org: OrgId::default_org(),
759 file: f.clone(),
760 base_dir: "/".into(),
761 secrets: Default::default(),
762 force: Default::default(),
763 images: Default::default(),
764 deployed_at: 0,
765 deployed_by: String::new(),
766 previous: None,
767 };
768 let mut web2 = web.clone();
769 web2.env.set("B", EnvValue::Plain("2".into()));
770 let rw2 = render(&web2, "x", &mut notes).unwrap();
771 let f3 = splice(Some(&f2), "shop-production", "web", Some(&rw2));
772 assert_eq!(
773 def(&f2).revision("api").unwrap(),
774 def(&f3).revision("api").unwrap()
775 );
776 assert_ne!(
777 def(&f2).revision("web").unwrap(),
778 def(&f3).revision("web").unwrap()
779 );
780 let f4 = splice(Some(&f3), "shop-production", "web", None);
782 assert_eq!(f4.services.keys().collect::<Vec<_>>(), ["api"]);
783 assert_eq!(f4.secrets.keys().collect::<Vec<_>>(), ["api.tok"]);
784 assert!(f4.volumes.is_empty());
785 }
786
787 #[test]
788 fn pins_digests() {
789 let d = "sha256:abc";
790 assert_eq!(
791 pin("docker:traefik/whoami", d).unwrap(),
792 "docker:traefik/whoami@sha256:abc"
793 );
794 assert_eq!(
795 pin("docker:nginx:1.27", d).unwrap(),
796 "docker:nginx@sha256:abc"
797 );
798 assert_eq!(
799 pin("oci:reg.example.com:5000/team/app:v2", d).unwrap(),
800 "oci:reg.example.com:5000/team/app@sha256:abc"
801 );
802 assert_eq!(
803 pin("ghcr:o/a@sha256:old", d).unwrap(),
804 "ghcr:o/a@sha256:abc"
805 );
806 assert!(pin("dev-base", d).is_none());
807 assert!(pin("images:debian/12", d).is_none());
808 }
809
810 #[test]
811 fn merge_patch_rfc7396() {
812 let mut b = json!({"a": 1, "b": {"c": 2, "d": 3}});
813 merge_patch(&mut b, &json!({"a": null, "b": {"c": 9}, "e": [1]}));
814 assert_eq!(b, json!({"b": {"c": 9, "d": 3}, "e": [1]}));
815 }
816}