1mod changes;
21pub mod controller;
22pub mod deployments;
23pub mod failure;
24pub mod migrate;
25mod ports;
26pub mod secrets;
27pub mod source;
28
29use std::collections::BTreeMap;
30use std::path::{Path, PathBuf};
31
32use serde::{Deserialize, Serialize};
33
34use crate::error::{Error, Result};
35use crate::org::OrgId;
36use crate::spec::{ComposeFile, OnChange, SandboxSpec};
37
38pub use controller::Controller;
39pub use secrets::SecretBinding;
40
41pub const LABEL_STACK: &str = "isb.stack";
43pub const LABEL_SERVICE: &str = "isb.service";
44pub const LABEL_SLOT: &str = "isb.slot";
45pub const LABEL_REV: &str = "isb.rev";
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct StackDef {
50 pub name: String,
51 #[serde(default = "OrgId::default_org")]
53 pub org: OrgId,
54 pub file: ComposeFile,
56 pub base_dir: PathBuf,
58 #[serde(default)]
61 pub secrets: BTreeMap<String, SecretBinding>,
62 #[serde(default)]
65 pub force: BTreeMap<String, u64>,
66 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
70 pub images: BTreeMap<String, String>,
71 pub deployed_at: u64,
73 #[serde(default)]
75 pub deployed_by: String,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub source: Option<String>,
81 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
84 pub domains: BTreeMap<String, Vec<crate::spec::DomainSpec>>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub previous: Option<Box<StackDef>>,
88}
89
90pub fn qualified(org: &OrgId, name: &str) -> String {
93 if org.is_default() {
94 name.to_string()
95 } else {
96 format!("{org}/{name}")
97 }
98}
99
100pub fn split_qualified(q: &str) -> Result<(OrgId, String)> {
102 match q.split_once('/') {
103 Some((o, n)) => Ok((OrgId::new(o)?, n.to_string())),
104 None => Ok((OrgId::default_org(), q.to_string())),
105 }
106}
107
108impl StackDef {
109 pub fn qualified(&self) -> String {
110 qualified(&self.org, &self.name)
111 }
112
113 pub fn revision(&self, service: &str) -> Result<String> {
121 self.revision_with(service, &|key| {
122 self.secrets
123 .get(key)
124 .map(|b| match self.on_change(service, key) {
125 OnChange::Roll => format!("{}\0{}\0{}", b.name, b.driver, b.version),
126 _ => format!("{}\0{}\0live", b.name, b.driver),
127 })
128 .map(String::into_bytes)
129 .unwrap_or_default()
130 })
131 }
132
133 pub fn on_change(&self, service: &str, key: &str) -> OnChange {
137 self.file
138 .services
139 .get(service)
140 .and_then(|s| s.secret_on_change(key))
141 .or_else(|| self.file.secrets.get(key).and_then(|d| d.on_change))
142 .unwrap_or_default()
143 }
144
145 pub fn live_secrets(&self, service: &str) -> BTreeMap<String, (OnChange, u64)> {
149 let Ok(spec) = self.service(service) else {
150 return BTreeMap::new();
151 };
152 spec.secret_keys()
153 .into_iter()
154 .filter_map(|k| {
155 let mode = self.on_change(service, k);
156 let b = self.secrets.get(k)?;
157 (mode != OnChange::Roll).then(|| (k.to_string(), (mode, b.version)))
158 })
159 .collect()
160 }
161
162 pub fn services_using(&self, key: &str) -> Vec<String> {
164 self.file
165 .services
166 .iter()
167 .filter(|(_, s)| s.secret_keys().contains(key))
168 .map(|(n, _)| n.clone())
169 .collect()
170 }
171
172 pub(crate) fn revision_with(
174 &self,
175 service: &str,
176 secret: &dyn Fn(&str) -> Vec<u8>,
177 ) -> Result<String> {
178 let spec = self.service(service)?;
179 let mut s = spec.clone();
180 s.name = None;
181 s.depends_on.clear();
182 for r in &mut s.secrets {
185 r.on_change = None;
186 }
187 s.env.on_change.clear();
188 s.domains.clear();
190 s.ports.retain(|p| p.bind == crate::spec::PortBind::Guest);
192 if let Some(d) = &mut s.deploy {
193 d.replicas = None;
194 d.update_config = None;
195 d.rollback_config = None;
196 }
197 if s.deploy.as_ref().is_some_and(|d| *d == Default::default()) {
198 s.deploy = None;
199 }
200 let mut h = Fnv64::new();
201 h.write(serde_json::to_string(&s)?.as_bytes());
202 for r in &spec.secrets {
203 h.write(r.source.as_bytes());
204 h.write(&secret(&r.source));
205 }
206 for (var, key) in &spec.env.secrets {
207 h.write(b"env");
208 h.write(var.as_bytes());
209 h.write(&secret(key));
210 }
211 for (var, key) in &spec.env.files {
212 h.write(b"envfile");
213 h.write(var.as_bytes());
214 h.write(&secret(key));
215 }
216 for v in &spec.volumes {
219 if let Some(d) = self.file.volumes.get(&v.source) {
220 h.write(serde_json::to_string(d)?.as_bytes());
221 }
222 }
223 h.write(&self.force.get(service).copied().unwrap_or(0).to_le_bytes());
224 if let Some(d) = self.images.get(service) {
227 h.write(b"image");
228 h.write(d.as_bytes());
229 }
230 for p in ports::published(spec).unwrap_or_default() {
233 if p.udp {
234 h.write(format!("udp {} {}", p.listen, p.target).as_bytes());
235 }
236 }
237 Ok(format!("{:08x}", h.finish() as u32))
238 }
239
240 pub fn store_secrets(&self) -> std::collections::BTreeSet<String> {
244 let used = secrets::used_keys(&self.file);
245 self.secrets
246 .iter()
247 .filter(|(k, _)| used.contains(*k))
248 .map(|(_, b)| b.name.clone())
249 .collect()
250 }
251
252 pub fn instance_image(&self, service: &str, image: &str) -> String {
255 let (Some(r), Some(d)) = (image.strip_prefix("registry:"), self.images.get(service)) else {
256 return image.to_string();
257 };
258 match crate::registry::ImageRef::parse(r) {
259 Ok(r) if r.digest.is_none() => format!("registry:{}", r.pinned(d).render()),
260 _ => image.to_string(),
261 }
262 }
263
264 pub fn service(&self, service: &str) -> Result<&SandboxSpec> {
265 self.file
266 .services
267 .get(service)
268 .ok_or_else(|| Error::NotFound(format!("service {service} in stack {}", self.name)))
269 }
270}
271
272struct Fnv64(u64);
274
275impl Fnv64 {
276 fn new() -> Self {
277 Fnv64(0xcbf29ce484222325)
278 }
279 fn write(&mut self, b: &[u8]) {
280 for x in b {
281 self.0 ^= *x as u64;
282 self.0 = self.0.wrapping_mul(0x100000001b3);
283 }
284 self.0 ^= 0xff;
286 self.0 = self.0.wrapping_mul(0x100000001b3);
287 }
288 fn finish(&self) -> u64 {
289 self.0
290 }
291}
292
293pub fn validate_stack_name(name: &str) -> Result<()> {
295 let ok = !name.is_empty()
296 && name.len() <= 30
297 && name.starts_with(|c: char| c.is_ascii_lowercase())
298 && !name.ends_with('-')
299 && name
300 .chars()
301 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
302 if ok {
303 Ok(())
304 } else {
305 Err(Error::invalid(format!(
306 "stack name {name:?}: up to 30 characters of [a-z0-9-], starting with a letter"
307 )))
308 }
309}
310
311pub const INSTANCE_NAME_MAX: usize = 63;
313
314pub fn instance_name(stack: &str, service: &str, slot: u32, id: &str) -> Result<String> {
320 let svc = crate::compose::sanitize_name(service);
321 let n = format!("{stack}-{svc}-{slot}-{id}");
322 if n.len() <= INSTANCE_NAME_MAX {
323 crate::plan::validate_instance_name(&n)?;
324 return Ok(n);
325 }
326 let mut h = Fnv64::new();
327 h.write(service.as_bytes());
328 let hash = format!("{:06x}", h.finish() & 0xff_ffff);
329 let tail = format!("-{hash}-{slot}-{id}");
330 let room = INSTANCE_NAME_MAX.saturating_sub(stack.len() + 1 + tail.len());
332 if room < 1 {
333 return Err(Error::invalid(format!(
334 "instance names of service {service:?} in stack {stack:?} (slot {slot}) cannot fit incus' {INSTANCE_NAME_MAX} characters even with the service name shortened; shorten the stack name ({} characters) or use fewer replicas",
335 stack.len()
336 )));
337 }
338 let prefix: String = svc.chars().take(room).collect();
339 let n = format!("{stack}-{}{tail}", prefix.trim_end_matches('-'));
340 crate::plan::validate_instance_name(&n)?;
341 Ok(n)
342}
343
344pub fn instance_name_fits(stack: &str, service: &str, slot: u32) -> bool {
347 let n = format!(
348 "{stack}-{}-{slot}-0000",
349 crate::compose::sanitize_name(service)
350 );
351 n.len() <= INSTANCE_NAME_MAX && crate::plan::validate_instance_name(&n).is_ok()
352}
353
354pub fn pick_stack_name(candidates: &[String], service: &str, slot: u32) -> Option<String> {
359 let valid = candidates.iter().filter(|s| validate_stack_name(s).is_ok());
360 valid
361 .clone()
362 .find(|s| instance_name_fits(s, service, slot))
363 .or_else(|| {
364 valid
365 .clone()
366 .find(|s| instance_name(s, service, slot, "0000").is_ok())
367 })
368 .cloned()
369}
370
371pub fn new_id() -> String {
373 let mut b = [0u8; 2];
374 if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
375 use std::io::Read;
376 let _ = f.read_exact(&mut b);
377 }
378 format!("{:02x}{:02x}", b[0], b[1])
379}
380
381#[derive(Debug, Clone)]
384pub struct Store {
385 dir: PathBuf,
386}
387
388impl Store {
389 pub fn default_dir() -> PathBuf {
390 std::env::var_os("XDG_STATE_HOME")
391 .filter(|s| !s.is_empty())
392 .map(PathBuf::from)
393 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
394 .unwrap_or_else(|| PathBuf::from("/var/lib"))
395 .join("isb")
396 }
397
398 pub fn open(dir: impl Into<PathBuf>) -> Result<Store> {
399 let dir = dir.into();
400 let stacks = dir.join("stacks");
401 std::fs::create_dir_all(&stacks)?;
402 set_mode(&dir, 0o700)?;
403 set_mode(&stacks, 0o700)?;
404 Ok(Store { dir })
405 }
406
407 pub fn dir(&self) -> &Path {
408 &self.dir
409 }
410
411 fn path(&self, org: &OrgId, name: &str) -> PathBuf {
412 self.stacks_dir(org).join(format!("{name}.json"))
413 }
414
415 fn stacks_dir(&self, org: &OrgId) -> PathBuf {
417 if org.is_default() {
418 self.dir.join("stacks")
419 } else {
420 org.dir(&self.dir).join("stacks")
421 }
422 }
423
424 pub fn files(&self) -> Result<Vec<PathBuf>> {
426 let mut dirs = vec![self.dir.join("stacks")];
427 if let Ok(rd) = std::fs::read_dir(self.dir.join("orgs")) {
428 for e in rd.flatten() {
429 dirs.push(e.path().join("stacks"));
430 }
431 }
432 let mut out = Vec::new();
433 for d in dirs {
434 let Ok(rd) = std::fs::read_dir(&d) else {
435 continue;
436 };
437 for e in rd {
438 let p = e?.path();
439 if p.extension().is_some_and(|x| x == "json") {
440 out.push(p);
441 }
442 }
443 }
444 out.sort();
445 Ok(out)
446 }
447
448 pub fn load_all(&self) -> Result<Vec<StackDef>> {
449 let mut out = Vec::new();
450 for p in self.files()? {
451 let text = std::fs::read_to_string(&p)?;
452 match serde_json::from_str::<StackDef>(&text) {
453 Ok(d) => out.push(d),
454 Err(e) => eprintln!("isb serve: skipping {}: {e}", p.display()),
455 }
456 }
457 out.sort_by_key(|d| d.qualified());
458 Ok(out)
459 }
460
461 pub fn save(&self, def: &StackDef) -> Result<()> {
464 use std::io::Write;
465 use std::os::unix::fs::OpenOptionsExt;
466 let dir = self.stacks_dir(&def.org);
467 std::fs::create_dir_all(&dir)?;
468 set_mode(&dir, 0o700)?;
469 let path = self.path(&def.org, &def.name);
470 let tmp = path.with_extension("json.tmp");
471 let mut f = std::fs::OpenOptions::new()
472 .write(true)
473 .create(true)
474 .truncate(true)
475 .mode(0o600)
476 .open(&tmp)?;
477 f.write_all(serde_json::to_string_pretty(def)?.as_bytes())?;
478 f.sync_all()?;
479 std::fs::rename(&tmp, &path)?;
480 Ok(())
481 }
482
483 pub fn remove(&self, org: &OrgId, name: &str) -> Result<()> {
484 match std::fs::remove_file(self.path(org, name)) {
485 Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
486 _ => Ok(()),
487 }
488 }
489}
490
491fn set_mode(p: &Path, mode: u32) -> Result<()> {
492 use std::os::unix::fs::PermissionsExt;
493 std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode))?;
494 Ok(())
495}
496
497pub fn now_secs() -> u64 {
498 std::time::SystemTime::now()
499 .duration_since(std::time::UNIX_EPOCH)
500 .map(|d| d.as_secs())
501 .unwrap_or(0)
502}
503
504pub fn local_deploy_args(
507 project: &crate::compose::Project,
508 name: &str,
509 wait: bool,
510 timeout: Option<&str>,
511) -> crate::Result<serde_json::Value> {
512 let secrets: std::collections::BTreeMap<String, String> =
515 crate::supervise::resolve_secret_values(&project.file, &project.base_dir, &|k| {
516 project.lookup(k)
517 })?
518 .into_iter()
519 .map(|(k, v)| {
520 String::from_utf8(v)
521 .map(|s| (k.clone(), s))
522 .map_err(|_| crate::Error::invalid(format!("secret {k:?} is not UTF-8 text")))
523 })
524 .collect::<crate::Result<_>>()?;
525 let mut v = serde_json::json!({
526 "name": name,
527 "file": project.file,
528 "base_dir": project.base_dir,
529 "secrets": secrets,
530 "wait": wait,
531 });
532 if let Some(t) = timeout {
533 v["timeout"] = serde_json::json!(t);
534 }
535 Ok(v)
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541
542 fn def(y: &str) -> StackDef {
543 StackDef {
544 source: None,
545 domains: Default::default(),
546 name: "app".into(),
547 org: OrgId::default_org(),
548 file: serde_yaml_ng::from_str(y).unwrap(),
549 base_dir: "/".into(),
550 secrets: BTreeMap::new(),
551 force: BTreeMap::new(),
552 images: BTreeMap::new(),
553 deployed_at: 0,
554 deployed_by: String::new(),
555 previous: None,
556 }
557 }
558
559 #[test]
560 fn revision_ignores_replicas_and_rollout_settings() {
561 let a = def("services:\n web: {image: x, deploy: {replicas: 1}}\n");
562 let b = def(
563 "services:\n web: {image: x, deploy: {replicas: 5, update_config: {order: start-first}}}\n",
564 );
565 assert_eq!(a.revision("web").unwrap(), b.revision("web").unwrap());
566 let c = def("services:\n web: {image: y}\n");
567 assert_ne!(a.revision("web").unwrap(), c.revision("web").unwrap());
568 let mut d = a.clone();
569 d.force.insert("web".into(), 1);
570 assert_ne!(a.revision("web").unwrap(), d.revision("web").unwrap());
571 }
572
573 #[test]
574 fn revision_of_an_existing_spec_is_pinned() {
575 let a = def(concat!(
578 "volumes: {data: {}}\n",
579 "services:\n",
580 " web:\n",
581 " image: docker:busybox\n",
582 " user: '1000:1000'\n",
583 " entrypoint: [sh, -c]\n",
584 " command: ['echo $$HOME; exec sleep 1d']\n",
585 " volumes:\n",
586 " - data:/data\n",
587 " - {source: data, target: /home/x, owner: '1000:1000'}\n",
588 ));
589 assert_eq!(a.revision("web").unwrap(), "d59025b7");
590 }
591
592 #[test]
593 fn an_org_disk_limit_sizes_the_request_not_the_revision() {
594 use crate::client::fake::{Route, serve};
595 let a = def("services:\n web:\n image: docker:busybox\n command: [sleep, 1d]\n");
596 let rev = a.revision("web").unwrap();
597 assert_eq!(rev, "9e74ea35");
598 let body = || {
599 serde_json::json!({
600 "name": "app-web-1-x", "config": {LABEL_REV: rev},
601 "devices": {"root": {"type": "disk", "path": "/", "pool": "default"}},
602 })
603 };
604 for limited in [false, true] {
605 let config = if limited {
606 serde_json::json!({"limits.disk": "100GiB"})
607 } else {
608 serde_json::json!({})
609 };
610 let (_d, c) = serve(vec![Route {
611 prefix: "GET /1.0/projects/isb-default",
612 status: 200,
613 body: serde_json::json!({"config": config}),
614 }]);
615 let c = c.project("isb-default");
616 let sent = crate::org::disk::sized_root(&c, body());
617 assert_eq!(a.revision("web").unwrap(), rev);
619 assert_eq!(sent["config"], body()["config"]);
620 let size = sent["devices"]["root"].get("size");
621 assert_eq!(size.is_some(), limited, "{sent}");
622 }
623 }
624
625 fn binding(name: &str, version: u64) -> SecretBinding {
626 SecretBinding {
627 name: name.into(),
628 driver: "local".into(),
629 version,
630 owned: false,
631 }
632 }
633
634 #[test]
635 fn revision_follows_secret_versions() {
636 let y = "secrets: {k: {external: true}, e: {external: true}}\nservices:\n web: {image: x, secrets: [k]}\n api: {image: docker:busybox, environment: {TOKEN: {secret: e}}}\n";
637 let mut a = def(y);
638 a.secrets.insert("k".into(), binding("k", 1));
639 a.secrets.insert("e".into(), binding("e", 1));
640 let (web, api) = (a.revision("web").unwrap(), a.revision("api").unwrap());
641 let mut b = a.clone();
643 b.secrets.get_mut("k").unwrap().version = 2;
644 assert_ne!(b.revision("web").unwrap(), web);
645 assert_eq!(b.revision("api").unwrap(), api);
646 let mut c = a.clone();
648 c.secrets.get_mut("e").unwrap().version = 2;
649 assert_eq!(c.revision("web").unwrap(), web);
650 assert_ne!(c.revision("api").unwrap(), api);
651 let mut d = a.clone();
653 d.secrets.get_mut("e").unwrap().name = "other".into();
654 assert_ne!(d.revision("api").unwrap(), api);
655 let mut e = a.clone();
657 let env = &mut e.file.services.get_mut("api").unwrap().env.secrets;
658 env.clear();
659 env.insert("TOKEN2".into(), "e".into());
660 assert_ne!(e.revision("api").unwrap(), api);
661 let mut f = a.clone();
663 f.secrets.get_mut("k").unwrap().owned = true;
664 f.deployed_at = 99;
665 assert_eq!(f.revision("web").unwrap(), web);
666 }
667
668 #[test]
673 fn existing_specs_keep_their_revisions() {
674 let y = "secrets: {k: {external: true}, e: {external: true}}\nservices:\n web: {image: x, secrets: [k], healthcheck: {test: [CMD, true], interval: 5s}}\n api: {image: docker:busybox, environment: {PLAIN: '1', TOKEN: {secret: e}, OTHER: {secret: k, on_change: none}}}\n";
675 let mut a = def(y);
676 a.secrets.insert("k".into(), binding("k", 1));
677 a.secrets.insert("e".into(), binding("e", 1));
678 assert_eq!(a.revision("web").unwrap(), "76486ba4");
679 assert_eq!(a.revision("api").unwrap(), "8d6c644f");
680 let file = y.replace("TOKEN: {secret: e}", "TOKEN: {secret: e, as: file}");
681 let mut b = def(&file);
682 b.secrets = a.secrets.clone();
683 assert_ne!(b.revision("api").unwrap(), a.revision("api").unwrap());
684 let env = y.replace("TOKEN: {secret: e}", "TOKEN: {secret: e, as: env}");
685 let mut c = def(&env);
686 c.secrets = a.secrets.clone();
687 assert_eq!(c.revision("api").unwrap(), a.revision("api").unwrap());
688 }
689
690 #[test]
691 fn udp_ports_are_part_of_the_revision_tcp_ports_are_not() {
692 let rev = |ports: &str| {
693 def(&format!("services:\n m: {{image: x, ports: {ports}}}\n"))
694 .revision("m")
695 .unwrap()
696 };
697 let none = rev("[]");
698 assert_eq!(rev("['8080:80']"), none);
699 let udp = rev("['203.0.113.7:10000:10000/udp']");
700 assert_ne!(udp, none);
701 assert_ne!(rev("['203.0.113.7:10001:10000/udp']"), udp);
702 }
703
704 #[test]
705 fn on_change_decides_what_a_new_version_rolls() {
706 let y = concat!(
707 "secrets:\n",
708 " k: {external: true, on_change: restart}\n",
709 " e: {external: true}\n",
710 " n: {external: true, on_change: restart}\n",
711 "services:\n",
712 " web: {image: x, secrets: [k, {source: n, on_change: none}]}\n",
713 " api: {image: docker:busybox, secrets: [k], environment: {T: {secret: e, on_change: none}, U: {secret: k, on_change: roll}}}\n",
714 );
715 let mut a = def(y);
716 for k in ["k", "e", "n"] {
717 a.secrets.insert(k.into(), binding(k, 1));
718 }
719 assert_eq!(a.on_change("web", "k"), OnChange::Restart);
722 assert_eq!(a.on_change("web", "n"), OnChange::None);
723 assert_eq!(a.on_change("api", "k"), OnChange::Roll);
724 assert_eq!(a.on_change("api", "e"), OnChange::None);
725 assert_eq!(
726 a.live_secrets("web"),
727 BTreeMap::from([
728 ("k".to_string(), (OnChange::Restart, 1)),
729 ("n".to_string(), (OnChange::None, 1)),
730 ])
731 );
732 assert_eq!(a.services_using("k"), ["api", "web"]);
733 let (web, api) = (a.revision("web").unwrap(), a.revision("api").unwrap());
734 let mut b = a.clone();
736 b.secrets.get_mut("k").unwrap().version = 2;
737 assert_eq!(b.revision("web").unwrap(), web);
738 assert_ne!(b.revision("api").unwrap(), api);
739 let mut c = a.clone();
741 c.secrets.get_mut("e").unwrap().version = 2;
742 c.secrets.get_mut("n").unwrap().version = 2;
743 assert_eq!(c.revision("web").unwrap(), web);
744 assert_eq!(c.revision("api").unwrap(), api);
745 let mut d = a.clone();
747 d.file.secrets.get_mut("k").unwrap().on_change = Some(OnChange::None);
748 assert_eq!(d.revision("web").unwrap(), web);
749 let plain = def(&y
751 .replace(", on_change: restart}", "}")
752 .replace(", on_change: none}", "}")
753 .replace(", on_change: roll}", "}"));
754 let mut p = plain.clone();
755 p.secrets = a.secrets.clone();
756 let mut r = p.clone();
757 for s in r.file.secrets.values_mut() {
758 s.on_change = Some(OnChange::Roll);
759 }
760 assert_eq!(p.revision("api").unwrap(), r.revision("api").unwrap());
761 assert_eq!(p.revision("web").unwrap(), r.revision("web").unwrap());
762 let y2 = serde_yaml_ng::to_string(&a.file).unwrap();
764 assert!(y2.contains("on_change: none"), "{y2}");
765 let back: ComposeFile = serde_yaml_ng::from_str(&y2).unwrap();
766 assert_eq!(back, a.file);
767 }
768
769 #[test]
770 fn revision_without_secrets_is_unchanged_by_bindings() {
771 let a = def("services:\n web: {image: x, environment: {A: '1'}}\n");
774 assert_eq!(
775 a.revision("web").unwrap(),
776 a.revision_with("web", &|_| b"ignored".to_vec()).unwrap()
777 );
778 }
779
780 #[test]
781 fn store_secrets_are_the_bound_names() {
782 let mut d = def(concat!(
783 "secrets:\n",
784 " a: {external: true}\n",
785 " b: {external: true, name: db.password}\n",
786 " c: {environment: C}\n",
787 " e: {external: true}\n",
788 " unused: {external: true}\n",
789 "services:\n",
790 " web: {image: x, secrets: [a, c]}\n",
791 " db: {image: x, secrets: [{source: b, target: pw}], command: [x], environment: {E: {secret: e}}}\n",
792 ));
793 d.secrets.insert("a".into(), binding("a", 1));
794 d.secrets.insert("b".into(), binding("db.password", 1));
795 d.secrets.insert("c".into(), binding("app_c", 1));
796 d.secrets.insert("e".into(), binding("e", 1));
797 d.secrets.insert("unused".into(), binding("unused", 1));
799 let s: Vec<String> = d.store_secrets().into_iter().collect();
800 assert_eq!(s, ["a", "app_c", "db.password", "e"]);
801 }
802
803 #[test]
804 fn environment_round_trips_with_secrets() {
805 let d = def(
806 "services:\n web: {image: x, environment: {A: 1, T: {secret: tok}}}\nsecrets: {tok: {external: true}}\n",
807 );
808 let env = &d.file.services["web"].env;
809 assert_eq!(env["A"], "1");
810 assert_eq!(env.secrets["T"], "tok");
811 let json = serde_json::to_string(&d.file).unwrap();
812 assert!(
813 json.contains(r#""environment":{"A":"1","T":{"secret":"tok"}}"#),
814 "{json}"
815 );
816 let back: crate::spec::ComposeFile = serde_json::from_str(&json).unwrap();
817 assert_eq!(back, d.file);
818 assert!(
819 serde_yaml_ng::from_str::<crate::spec::SandboxSpec>(
820 "image: x\nenvironment: {T: {secret: tok, extra: 1}}\n"
821 )
822 .is_err()
823 );
824 assert!(
825 serde_yaml_ng::from_str::<crate::spec::SandboxSpec>(
826 "image: x\nenvironment: {T: {secret: ''}}\n"
827 )
828 .is_err()
829 );
830 }
831
832 #[test]
833 fn names() {
834 assert_eq!(
835 instance_name("app", "web", 2, "ab12").unwrap(),
836 "app-web-2-ab12"
837 );
838 assert!(validate_stack_name("my-app").is_ok());
839 assert!(validate_stack_name("My_App").is_err());
840 assert!(validate_stack_name("1app").is_err());
841 }
842
843 #[test]
844 fn names_that_fit_are_unchanged() {
845 let stack = "project-management-production";
847 let svc = "a".repeat(63 - stack.len() - 1 - "-1-ab12".len());
848 let n = instance_name(stack, &svc, 1, "ab12").unwrap();
849 assert_eq!(n, format!("{stack}-{svc}-1-ab12"));
850 assert_eq!(n.len(), 63);
851 assert!(instance_name_fits(stack, &svc, 1));
852 assert_eq!(
853 instance_name("shop-production", "My_Web", 12, "ab12").unwrap(),
854 "shop-production-my-web-12-ab12"
855 );
856 }
857
858 #[test]
859 fn long_names_are_shortened_with_a_hash() {
860 let stack = "project-management-production";
861 let svc = "project-management-postgres";
862 assert!(!instance_name_fits(stack, svc, 1));
863 let n = instance_name(stack, svc, 1, "ab12").unwrap();
864 assert!(n.len() <= 63, "{n}");
865 assert!(n.starts_with(&format!("{stack}-project-")), "{n}");
866 assert!(n.ends_with("-1-ab12"), "{n}");
867 crate::plan::validate_instance_name(&n).unwrap();
868 assert_eq!(n, instance_name(stack, svc, 1, "ab12").unwrap());
870 let other = instance_name(stack, "project-management-postgres-replica", 1, "ab12").unwrap();
872 assert_ne!(n, other);
873 let wide = instance_name(&"a".repeat(30), &"b".repeat(40), 4_000_000_000, "ab12").unwrap();
875 assert!(
876 wide.len() <= 63 && wide.ends_with("-4000000000-ab12"),
877 "{wide}"
878 );
879 let e = instance_name(&"s".repeat(50), "web-frontend-x", 4_000_000_000, "ab12")
881 .unwrap_err()
882 .to_string();
883 assert!(e.contains("shorten the stack name"), "{e}");
884 }
885
886 #[test]
887 fn stack_names_prefer_unshortened_instances() {
888 let c = ["aaaa-production-pr-1".to_string(), "aaaa-pr-1".to_string()];
889 assert_eq!(pick_stack_name(&c, "web", 100).unwrap(), c[0]);
890 assert_eq!(pick_stack_name(&c, &"w".repeat(35), 100).unwrap(), c[1]);
892 assert_eq!(pick_stack_name(&c, &"w".repeat(60), 100).unwrap(), c[0]);
894 assert_eq!(pick_stack_name(&["Bad".to_string()], "web", 1), None);
895 }
896
897 #[test]
898 fn store_round_trip() {
899 let dir = tempfile::tempdir().unwrap();
900 let s = Store::open(dir.path()).unwrap();
901 let d = def("services:\n web: {image: x}\n");
902 s.save(&d).unwrap();
903 let all = s.load_all().unwrap();
904 assert_eq!(all.len(), 1);
905 assert_eq!(all[0].name, "app");
906 use std::os::unix::fs::PermissionsExt;
907 let mode = std::fs::metadata(dir.path().join("stacks/app.json"))
908 .unwrap()
909 .permissions()
910 .mode();
911 assert_eq!(mode & 0o777, 0o600);
912 let mut other = d.clone();
913 other.org = OrgId::new("alpha").unwrap();
914 s.save(&other).unwrap();
915 assert!(dir.path().join("orgs/alpha/stacks/app.json").is_file());
916 let all = s.load_all().unwrap();
917 assert_eq!(
918 all.iter().map(|d| d.qualified()).collect::<Vec<_>>(),
919 vec!["alpha/app", "app"]
920 );
921 s.remove(&OrgId::default_org(), "app").unwrap();
922 s.remove(&other.org, "app").unwrap();
923 assert!(s.load_all().unwrap().is_empty());
924 }
925}