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)]
164#[serde(deny_unknown_fields)]
165pub struct Resources {
166 #[serde(
168 default,
169 deserialize_with = "crate::flex::opt_string_or_null",
170 skip_serializing_if = "Option::is_none"
171 )]
172 pub cpus: Option<String>,
173 #[serde(
175 default,
176 deserialize_with = "crate::flex::opt_string_or_null",
177 skip_serializing_if = "Option::is_none"
178 )]
179 pub memory: Option<String>,
180}
181
182pub fn resources_schema() -> Value {
184 serde_json::json!({
185 "type": "object",
186 "additionalProperties": false,
187 "properties": {
188 "cpus": {"type": ["string", "integer", "null"], "description": "CPUs per replica (limits.cpu), e.g. 2 or \"2\". null (app_update) removes the limit."},
189 "memory": {"type": ["string", "integer", "null"], "description": "Memory per replica: 512m, 2g, 2GiB, or a number of bytes. null (app_update) removes the limit."}
190 }
191 })
192}
193
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
196#[serde(deny_unknown_fields)]
197pub struct AppSpec {
198 pub name: String,
199 pub project: String,
200 #[serde(default = "default_env")]
201 pub environment: String,
202 pub source: Source,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub build: Option<BuildSettings>,
205 #[serde(default)]
207 pub env: EnvFile,
208 #[serde(default, skip_serializing_if = "Vec::is_empty")]
212 pub domains: Vec<serde_json::Map<String, Value>>,
213 #[serde(default, skip_serializing_if = "Vec::is_empty")]
217 pub volumes: Vec<String>,
218 #[serde(default, skip_serializing_if = "Vec::is_empty")]
221 pub ports: Vec<String>,
222 #[serde(default = "one")]
223 pub replicas: u32,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub port: Option<u16>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub healthcheck: Option<Value>,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub resources: Option<Resources>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub command: Option<Value>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub previews: Option<PreviewSettings>,
238 #[serde(default, skip_serializing_if = "Vec::is_empty")]
241 pub files: Vec<AppFile>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub user: Option<String>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub working_dir: Option<String>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub secret_on_change: Option<crate::spec::OnChange>,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(deny_unknown_fields)]
257pub struct AppFile {
258 pub path: String,
260 pub secret: String,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub mode: Option<String>,
265}
266
267fn default_env() -> String {
268 DEFAULT_ENVIRONMENT.into()
269}
270
271fn one() -> u32 {
272 1
273}
274
275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
277pub struct App {
278 pub spec: AppSpec,
279 pub created_at: u64,
280 pub updated_at: u64,
281 #[serde(default = "one_u64")]
283 pub next_deployment: u64,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub current: Option<u64>,
287}
288
289fn one_u64() -> u64 {
290 1
291}
292
293impl AppSpec {
294 pub fn stack(&self) -> Result<String> {
295 stack_name(&self.project, &self.environment)
296 }
297
298 pub fn validate(&self) -> Result<()> {
300 validate_app_name(&self.name)?;
301 validate_part("project", &self.project)?;
302 validate_part("environment", &self.environment)?;
303 let stack = self.stack()?;
304 crate::stack::instance_name(&stack, &self.name, self.replicas.max(1), "0000")
306 .map_err(|e| Error::invalid(format!("app {}: {e}", self.name)))?;
307 match (&self.source, &self.build) {
308 (Source::Image(i), None) => {
309 crate::plan::ImageSource::parse(i)?;
310 }
311 (Source::Image(_), Some(_)) => {
312 return Err(Error::invalid("an image source is not built; drop `build`"));
313 }
314 (Source::Git(g), Some(_)) => {
315 g.validate()?;
316 }
317 (Source::Git(_), None) => {
318 return Err(Error::invalid(
319 "a git source needs `build` (e.g. {builder: {type: railpack}})",
320 ));
321 }
322 (Source::Database(db), _) => database::validate(self, db)?,
323 }
324 if self.replicas > 100 {
325 return Err(Error::invalid("replicas: at most 100"));
326 }
327 for v in &self.volumes {
328 parse_volume(v)?;
329 }
330 if let Some(p) = &self.previews {
331 p.validate(self)?;
332 }
333 let mut paths = std::collections::BTreeSet::new();
334 for f in &self.files {
335 if !f.path.starts_with('/') || f.path.ends_with('/') || f.path.contains("/../") {
336 return Err(Error::invalid(format!(
337 "file {:?}: the path must be an absolute file path",
338 f.path
339 )));
340 }
341 if !paths.insert(f.path.as_str()) {
342 return Err(Error::invalid(format!("file {:?} is given twice", f.path)));
343 }
344 crate::secrets::validate_name(&f.secret)?;
345 }
346 for d in &self.domains {
347 let host = d.get("host").and_then(Value::as_str).unwrap_or("");
348 if host.is_empty() {
349 return Err(Error::invalid("every domain needs a host"));
350 }
351 if !d.contains_key("port") && self.port.is_none() {
352 return Err(Error::invalid(format!(
353 "domain {host}: give it a port, or set the app's port"
354 )));
355 }
356 }
357 Ok(())
358 }
359
360 pub fn webhook_secret(&self) -> String {
362 webhook_secret(&self.name)
363 }
364}
365
366pub fn webhook_secret(app: &str) -> String {
367 format!("app.{app}.webhook")
368}
369
370pub fn deploy_key_secret(app: &str) -> String {
371 format!("app.{app}.deploy-key")
372}
373
374pub fn validate_app_name(s: &str) -> Result<()> {
376 let ok = !s.is_empty()
377 && s.len() <= 30
378 && s.starts_with(|c: char| c.is_ascii_lowercase())
379 && !s.ends_with('-')
380 && s.chars()
381 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
382 if ok {
383 Ok(())
384 } else {
385 Err(Error::invalid(format!(
386 "app name {s:?}: up to 30 characters of [a-z0-9-], starting with a letter"
387 )))
388 }
389}
390
391pub(crate) fn parse_volume(v: &str) -> Result<(String, String, Option<String>)> {
393 let mut parts = v.splitn(3, ':');
394 let name = parts.next().unwrap_or("");
395 let target = parts.next().unwrap_or("");
396 let opts = parts.next().map(String::from);
397 let name_ok = !name.is_empty()
398 && name.len() <= 30
399 && name.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
400 && name
401 .chars()
402 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
403 if !name_ok {
404 return Err(Error::invalid(format!(
405 "volume {v:?}: NAME:/path with NAME of [a-z0-9-] (apps take named volumes only, never host paths)"
406 )));
407 }
408 if !target.starts_with('/') {
409 return Err(Error::invalid(format!(
410 "volume {v:?}: the target must be an absolute path"
411 )));
412 }
413 if let Some(o) = &opts {
414 if !matches!(o.as_str(), "ro" | "rw") {
415 return Err(Error::invalid(format!(
416 "volume {v:?}: options are ro or rw"
417 )));
418 }
419 }
420 Ok((name.to_string(), target.to_string(), opts))
421}
422
423#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
427pub struct Rendered {
428 pub service: SandboxSpec,
429 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
430 pub secrets: BTreeMap<String, SecretDef>,
431 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
432 pub volumes: BTreeMap<String, NamedVolumeSpec>,
433}
434
435fn secret_key(app: &str, name: &str) -> String {
437 format!("{app}.{name}")
438}
439
440fn volume_key(app: &str, name: &str) -> String {
441 format!("{app}_{name}")
442}
443
444pub fn compose_takes_domains() -> bool {
448 serde_json::from_value::<SandboxSpec>(json!({"image": "x", "domains": []})).is_ok()
449}
450
451#[expect(
454 clippy::too_many_lines,
455 reason = "predates the lint ratchet; split it when next changed"
456)]
457pub fn render(spec: &AppSpec, image: &str, notes: &mut Vec<String>) -> Result<Rendered> {
458 let effective;
459 let spec = match &spec.source {
460 Source::Database(db) => {
461 effective = database::effective(spec, db);
462 &effective
463 }
464 _ => spec,
465 };
466 let mut environment = serde_json::Map::new();
467 let mut secrets = BTreeMap::new();
468 for (k, v) in spec.env.vars() {
469 match v {
470 EnvValue::Plain(s) => {
471 environment.insert(k.to_string(), json!(s));
472 }
473 EnvValue::Secret { secret } => {
474 let key = secret_key(&spec.name, secret);
475 environment.insert(k.to_string(), json!({"secret": key}));
476 secrets.insert(
477 key,
478 SecretDef {
479 external: true,
480 name: Some(secret.clone()),
481 on_change: spec.secret_on_change,
482 ..Default::default()
483 },
484 );
485 }
486 }
487 }
488 if let Source::Database(db) = &spec.source {
491 let mut rotate = |name: String, root: bool| {
492 if let Some(d) = secrets.get_mut(&secret_key(&spec.name, &name)) {
493 d.rotate = Some(db.engine.rotate_command(root));
494 }
495 };
496 rotate(database::password_secret(&spec.name), false);
497 if db.engine.has_root_password() {
498 rotate(database::root_password_secret(&spec.name), true);
499 }
500 }
501 let mut volumes = BTreeMap::new();
502 let mut mounts = Vec::new();
503 for v in &spec.volumes {
504 let (name, target, opts) = parse_volume(v)?;
505 let key = volume_key(&spec.name, &name);
506 mounts.push(match opts {
507 Some(o) => format!("{key}:{target}:{o}"),
508 None => format!("{key}:{target}"),
509 });
510 volumes.insert(key, NamedVolumeSpec::default());
511 }
512 let mut labels = serde_json::Map::new();
513 labels.insert(LABEL_APP.into(), json!(spec.name));
514 let order = if spec.volumes.is_empty() {
517 "start-first"
518 } else {
519 "stop-first"
520 };
521 let mut svc = json!({
522 "image": image,
523 "labels": labels,
524 "deploy": {"replicas": spec.replicas, "update_config": {"order": order}},
525 });
526 if !environment.is_empty() {
527 svc["environment"] = Value::Object(environment);
528 }
529 if !mounts.is_empty() {
530 svc["volumes"] = json!(mounts);
531 }
532 if !spec.ports.is_empty() {
533 svc["ports"] = json!(spec.ports);
534 }
535 if let Some(c) = &spec.command {
536 svc["command"] = c.clone();
537 }
538 if let Some(h) = &spec.healthcheck {
539 svc["healthcheck"] = h.clone();
540 }
541 if !spec.files.is_empty() {
542 let mut refs = Vec::new();
543 for f in &spec.files {
544 let key = secret_key(&spec.name, &f.secret);
545 let mut r = json!({"source": key, "target": f.path});
546 if let Some(m) = &f.mode {
547 r["mode"] = json!(m);
548 }
549 refs.push(r);
550 secrets.insert(
551 key,
552 SecretDef {
553 external: true,
554 name: Some(f.secret.clone()),
555 on_change: spec.secret_on_change,
556 ..Default::default()
557 },
558 );
559 }
560 svc["secrets"] = json!(refs);
561 }
562 if let Some(u) = &spec.user {
563 svc["user"] = json!(u);
564 }
565 if let Some(w) = &spec.working_dir {
566 svc["working_dir"] = json!(w);
567 }
568 if let Some(r) = &spec.resources {
569 if let Some(c) = &r.cpus {
570 svc["cpus"] = json!(c);
571 }
572 if let Some(m) = &r.memory {
573 svc["mem_limit"] = json!(m);
574 }
575 }
576 let parse = |v: Value| {
577 serde_json::from_value::<SandboxSpec>(v)
578 .map_err(|e| Error::invalid(format!("app {}: {e}", spec.name)))
579 };
580 let service = if spec.domains.is_empty() {
581 parse(svc)?
582 } else {
583 let domains: Vec<Value> = spec
584 .domains
585 .iter()
586 .map(|d| {
587 let mut d = d.clone();
588 if let (false, Some(p)) = (d.contains_key("port"), spec.port) {
589 d.insert("port".into(), json!(p));
590 }
591 Value::Object(d)
592 })
593 .collect();
594 let mut with = svc.clone();
595 with["domains"] = json!(domains);
596 if compose_takes_domains() {
597 parse(with)?
598 } else {
599 notes.push(format!(
600 "domains ({}) are kept with the app; this isb has no ingress to serve them yet",
601 spec.domains
602 .iter()
603 .filter_map(|d| d.get("host").and_then(Value::as_str))
604 .collect::<Vec<_>>()
605 .join(", ")
606 ));
607 parse(svc)?
608 }
609 };
610 Ok(Rendered {
611 service,
612 secrets,
613 volumes,
614 })
615}
616
617pub fn splice(
621 current: Option<&crate::spec::ComposeFile>,
622 stack: &str,
623 app: &str,
624 r: Option<&Rendered>,
625) -> crate::spec::ComposeFile {
626 let mut f = current.cloned().unwrap_or_default();
627 f.name = Some(stack.to_string());
628 f.services.remove(app);
629 if let Some(r) = r {
630 f.services.insert(app.to_string(), r.service.clone());
631 for (k, v) in &r.secrets {
632 f.secrets.insert(k.clone(), v.clone());
633 }
634 for (k, v) in &r.volumes {
635 f.volumes.insert(k.clone(), v.clone());
636 }
637 }
638 let used_secrets = crate::stack::secrets::used_keys(&f);
639 f.secrets.retain(|k, _| used_secrets.contains(k));
640 let used_volumes: std::collections::BTreeSet<String> = f
641 .services
642 .values()
643 .flat_map(|s| s.volumes.iter().map(|v| v.source.clone()))
644 .collect();
645 f.volumes.retain(|k, _| used_volumes.contains(k));
646 f
647}
648
649pub fn merge_patch(base: &mut Value, patch: &Value) {
651 match (base, patch) {
652 (Value::Object(b), Value::Object(p)) => {
653 for (k, v) in p {
654 if v.is_null() {
655 b.remove(k);
656 } else {
657 merge_patch(b.entry(k.clone()).or_insert(Value::Null), v);
658 }
659 }
660 }
661 (b, p) => *b = p.clone(),
662 }
663}
664
665pub fn pin(image: &str, digest: &str) -> Option<String> {
669 let (prefix, rest) = image.split_once(':')?;
670 if !matches!(prefix, "docker" | "ghcr" | "quay" | "oci") || !digest.starts_with("sha256:") {
671 return None;
672 }
673 let rest = rest.split('@').next().unwrap_or(rest);
674 let (dir, last) = match rest.rsplit_once('/') {
675 Some((d, l)) => (Some(d), l),
676 None => (None, rest),
677 };
678 let last = last.split(':').next().unwrap_or(last);
679 Some(match dir {
680 Some(d) => format!("{prefix}:{d}/{last}@{digest}"),
681 None => format!("{prefix}:{last}@{digest}"),
682 })
683}
684
685#[doc(hidden)]
687pub fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
688 use std::io::Write;
689 use std::os::unix::fs::OpenOptionsExt;
690 if let Some(d) = path.parent() {
691 std::fs::create_dir_all(d)?;
692 }
693 let tmp = path.with_extension(format!("tmp-{}", git::random_hex(4)));
694 let mut f = std::fs::OpenOptions::new()
695 .write(true)
696 .create(true)
697 .truncate(true)
698 .mode(0o600)
699 .open(&tmp)?;
700 f.write_all(data)?;
701 f.sync_all()?;
702 std::fs::rename(&tmp, path)?;
703 Ok(())
704}
705
706#[cfg(test)]
707mod tests {
708 use super::*;
709
710 fn spec(y: &str) -> AppSpec {
712 try_spec(y).unwrap()
713 }
714
715 fn try_spec(y: &str) -> std::result::Result<AppSpec, serde_json::Error> {
716 serde_json::from_value(serde_yaml_ng::from_str::<Value>(y).unwrap())
717 }
718
719 #[test]
720 fn spec_forms_and_validation() {
721 let a = spec(
722 "name: web\nproject: shop\nsource: {image: 'docker:traefik/whoami'}\nenv: {A: '1', T: {secret: tok}}\n",
723 );
724 assert_eq!(a.environment, "production");
725 assert_eq!(a.replicas, 1);
726 assert_eq!(a.stack().unwrap(), "shop-production");
727 a.validate().unwrap();
728 let g = spec(
729 "name: api\nproject: shop\nsource: {git: {url: 'https://h/o/r', ref: dev}}\nbuild: {builder: {type: railpack}}\n",
730 );
731 g.validate().unwrap();
732 assert!(g.build.as_ref().unwrap().untrusted);
733 let mut bad = g.clone();
734 bad.build = None;
735 assert!(bad.validate().is_err());
736 let mut bad = a.clone();
737 bad.volumes = vec!["/etc:/x".into()];
738 assert!(bad.validate().is_err());
739 bad.volumes = vec!["data:/var/lib/x".into()];
740 bad.validate().unwrap();
741 bad.domains = vec![serde_json::from_str(r#"{"host":"a.example.com"}"#).unwrap()];
742 assert!(bad.validate().is_err(), "a domain needs a port");
743 bad.port = Some(80);
744 bad.validate().unwrap();
745 let mut bad = a.clone();
746 bad.project = "a-very-long-project-name".into();
747 bad.environment = "staging-environment".into();
748 assert!(bad.validate().is_err());
749 assert!(try_spec("name: x\nproject: p\nsource: {image: x}\nbogus: 1\n").is_err());
750 assert!(try_spec("name: x\nproject: p\nsource: {image: x, git: {url: u}}\n").is_err());
751 }
752
753 #[test]
754 fn long_names_validate_and_resources_take_numbers() {
755 let a = spec(
757 "name: project-management-postgres\nproject: project-management\nsource: {image: x}\n",
758 );
759 a.validate().unwrap();
760 let r = spec(
761 "name: x\nproject: p\nsource: {image: x}\nresources: {cpus: 2, memory: 536870912}\n",
762 )
763 .resources
764 .unwrap();
765 assert_eq!(
766 (r.cpus.as_deref(), r.memory.as_deref()),
767 (Some("2"), Some("536870912"))
768 );
769 let s =
771 spec("name: x\nproject: p\nsource: {image: x}\nresources: {cpus: '2', memory: 1g}\n");
772 assert_eq!(
773 serde_json::to_value(&s.resources).unwrap(),
774 serde_json::json!({"cpus": "2", "memory": "1g"})
775 );
776 let n =
777 spec("name: x\nproject: p\nsource: {image: x}\nresources: {cpus: null, memory: 1g}\n");
778 assert_eq!(n.resources.unwrap().cpus, None);
779 assert!(
780 try_spec("name: x\nproject: p\nsource: {image: x}\nresources: {cpus: 1.5}\n").is_err()
781 );
782 }
783
784 #[test]
785 fn renders_one_service() {
786 let a = spec(concat!(
787 "name: web\nproject: shop\nsource: {image: 'docker:traefik/whoami'}\n",
788 "env: \"# c\\nA=1\\nT=${{secret.tok}}\\n\"\n",
789 "volumes: ['data:/data']\nports: ['127.0.0.1:18080:80']\nreplicas: 2\nport: 80\n",
790 "command: [/whoami, --port, '80']\n",
791 "healthcheck: {test: [CMD, /whoami, --help], interval: 5s}\n",
792 "resources: {cpus: '1', memory: 256m}\n",
793 ));
794 let mut notes = vec![];
795 let r = render(&a, "docker:traefik/whoami@sha256:ab", &mut notes).unwrap();
796 let s = &r.service;
797 assert_eq!(s.image, "docker:traefik/whoami@sha256:ab");
798 assert_eq!(s.env["A"], "1");
799 assert_eq!(s.env.secrets["T"], "web.tok");
800 assert_eq!(r.secrets["web.tok"].name.as_deref(), Some("tok"));
801 assert!(r.secrets["web.tok"].external);
802 assert_eq!(s.volumes[0].source, "web_data");
803 assert!(r.volumes.contains_key("web_data"));
804 assert_eq!(s.replicas(), 2);
805 assert_eq!(s.labels[LABEL_APP], "web");
806 assert_eq!(s.cpus.as_deref(), Some("1"));
807 assert_eq!(s.memory.as_deref(), Some("256m"));
808 assert!(s.healthcheck.is_some());
809 assert_eq!(s.ports.len(), 1);
810 assert!(notes.is_empty());
811 }
812
813 #[test]
814 fn secret_on_change_reaches_every_secret_the_app_uses() {
815 let base = concat!(
816 "name: web\nproject: shop\nsource: {image: 'docker:traefik/whoami'}\n",
817 "env: \"T=${{secret.tok}}\\n\"\nfiles: [{path: /etc/app.conf, secret: conf}]\n",
818 );
819 let r = render(&spec(base), "x", &mut vec![]).unwrap();
820 assert!(r.secrets.values().all(|d| d.on_change.is_none()));
821 let a = spec(&format!("{base}secret_on_change: restart\n"));
822 let r = render(&a, "x", &mut vec![]).unwrap();
823 assert_eq!(r.secrets.len(), 2);
824 assert!(
825 r.secrets
826 .values()
827 .all(|d| d.on_change == Some(crate::spec::OnChange::Restart))
828 );
829 assert!(try_spec(&format!("{base}secret_on_change: sometimes\n")).is_err());
830 }
831
832 #[test]
833 fn domains_follow_the_parser() {
834 let mut a = spec("name: web\nproject: shop\nsource: {image: x}\nport: 8080\n");
835 a.domains = vec![serde_json::from_str(r#"{"host":"shop.example.com"}"#).unwrap()];
836 let mut notes = vec![];
837 let r = render(&a, "x", &mut notes).unwrap();
838 if compose_takes_domains() {
839 let v = serde_json::to_value(&r.service).unwrap();
840 assert_eq!(v["domains"][0]["port"], 8080);
841 assert!(notes.is_empty());
842 } else {
843 assert_eq!(notes.len(), 1, "{notes:?}");
844 }
845 }
846
847 #[test]
848 fn splice_touches_only_the_app() {
849 let mut notes = vec![];
850 let web = spec(
851 "name: web\nproject: shop\nsource: {image: x}\nenv: {T: {secret: tok}}\nvolumes: ['d:/d']\n",
852 );
853 let api = spec("name: api\nproject: shop\nsource: {image: y}\nenv: {T: {secret: tok}}\n");
854 let rw = render(&web, "x", &mut notes).unwrap();
855 let ra = render(&api, "y", &mut notes).unwrap();
856 let f1 = splice(None, "shop-production", "web", Some(&rw));
857 let f2 = splice(Some(&f1), "shop-production", "api", Some(&ra));
858 assert_eq!(f2.services.len(), 2);
859 assert_eq!(f2.services["web"], f1.services["web"]);
860 assert_eq!(
861 f2.secrets.keys().collect::<Vec<_>>(),
862 ["api.tok", "web.tok"]
863 );
864 let def = |f: &crate::spec::ComposeFile| crate::stack::StackDef {
866 source: None,
867 domains: Default::default(),
868 name: "shop-production".into(),
869 org: OrgId::default_org(),
870 file: f.clone(),
871 base_dir: "/".into(),
872 secrets: Default::default(),
873 force: Default::default(),
874 images: Default::default(),
875 deployed_at: 0,
876 deployed_by: String::new(),
877 previous: None,
878 };
879 let mut web2 = web.clone();
880 web2.env.set("B", EnvValue::Plain("2".into()));
881 let rw2 = render(&web2, "x", &mut notes).unwrap();
882 let f3 = splice(Some(&f2), "shop-production", "web", Some(&rw2));
883 assert_eq!(
884 def(&f2).revision("api").unwrap(),
885 def(&f3).revision("api").unwrap()
886 );
887 assert_ne!(
888 def(&f2).revision("web").unwrap(),
889 def(&f3).revision("web").unwrap()
890 );
891 let f4 = splice(Some(&f3), "shop-production", "web", None);
893 assert_eq!(f4.services.keys().collect::<Vec<_>>(), ["api"]);
894 assert_eq!(f4.secrets.keys().collect::<Vec<_>>(), ["api.tok"]);
895 assert!(f4.volumes.is_empty());
896 }
897
898 #[test]
899 fn pins_digests() {
900 let d = "sha256:abc";
901 assert_eq!(
902 pin("docker:traefik/whoami", d).unwrap(),
903 "docker:traefik/whoami@sha256:abc"
904 );
905 assert_eq!(
906 pin("docker:nginx:1.27", d).unwrap(),
907 "docker:nginx@sha256:abc"
908 );
909 assert_eq!(
910 pin("oci:reg.example.com:5000/team/app:v2", d).unwrap(),
911 "oci:reg.example.com:5000/team/app@sha256:abc"
912 );
913 assert_eq!(
914 pin("ghcr:o/a@sha256:old", d).unwrap(),
915 "ghcr:o/a@sha256:abc"
916 );
917 assert!(pin("dev-base", d).is_none());
918 assert!(pin("images:debian/12", d).is_none());
919 }
920
921 #[test]
922 fn merge_patch_rfc7396() {
923 let mut b = json!({"a": 1, "b": {"c": 2, "d": 3}});
924 merge_patch(&mut b, &json!({"a": null, "b": {"c": 9}, "e": [1]}));
925 assert_eq!(b, json!({"b": {"c": 9, "d": 3}, "e": [1]}));
926 }
927}