1use std::collections::BTreeMap;
7
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use crate::flex;
12
13mod secret;
14pub use secret::{DEFAULT_SECRET_REFRESH, OnChange, SecretAs, SecretDef};
15
16mod env;
17pub use env::Environment;
18mod mount;
19pub(crate) use mount::is_host_path;
20pub use mount::{MountType, VolumeOptions, VolumeSpec};
21
22#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
25#[serde(deny_unknown_fields)]
26pub struct ComposeFile {
27 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub name: Option<String>,
32
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub incus_project: Option<String>,
36
37 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
40 pub volumes: BTreeMap<String, NamedVolumeSpec>,
41
42 #[serde(default)]
44 pub services: BTreeMap<String, SandboxSpec>,
45
46 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
49 pub secrets: BTreeMap<String, SecretDef>,
50}
51
52#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
54#[serde(deny_unknown_fields)]
55pub struct NamedVolumeSpec {
56 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub name: Option<String>,
60
61 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub pool: Option<String>,
65
66 #[serde(
68 default,
69 deserialize_with = "flex::string_map",
70 skip_serializing_if = "BTreeMap::is_empty"
71 )]
72 #[schemars(with = "BTreeMap<String, flex::Scalar>")]
73 pub config: BTreeMap<String, String>,
74
75 #[serde(
77 default,
78 deserialize_with = "flex::bool",
79 skip_serializing_if = "std::ops::Not::not"
80 )]
81 #[schemars(with = "flex::BoolOrString")]
82 pub external: bool,
83}
84
85#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "kebab-case")]
88pub enum InstanceType {
89 #[default]
92 Container,
93 #[serde(alias = "vm")]
96 VirtualMachine,
97}
98
99impl JsonSchema for InstanceType {
103 fn schema_name() -> std::borrow::Cow<'static, str> {
104 "InstanceType".into()
105 }
106
107 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
108 schemars::json_schema!({
109 "description": "Instance type.",
110 "oneOf": [
111 {
112 "type": "string",
113 "const": "container",
114 "description": "A system container (lxc): shares the host kernel, near-zero overhead, idmapped bind mounts, proxies in both directions."
115 },
116 {
117 "type": "string",
118 "const": "virtual-machine",
119 "description": "A virtual machine (qemu): its own kernel. Needs a VM image and the incus agent in the guest for exec."
120 },
121 {
122 "type": "string",
123 "const": "vm",
124 "description": "Shorthand for virtual-machine."
125 }
126 ]
127 })
128 }
129}
130
131impl InstanceType {
132 pub fn as_api(&self) -> &'static str {
133 match self {
134 InstanceType::Container => "container",
135 InstanceType::VirtualMachine => "virtual-machine",
136 }
137 }
138}
139
140#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
142#[serde(deny_unknown_fields)]
143pub struct SandboxSpec {
144 #[serde(
147 default,
148 rename = "container_name",
149 skip_serializing_if = "Option::is_none"
150 )]
151 pub name: Option<String>,
152
153 #[serde(default)]
157 pub image: String,
158
159 #[serde(default, rename = "type", skip_serializing_if = "is_default")]
169 pub instance_type: InstanceType,
170
171 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub storage: Option<String>,
175
176 #[serde(
178 default,
179 deserialize_with = "flex::opt_string",
180 skip_serializing_if = "Option::is_none"
181 )]
182 #[schemars(with = "Option<flex::IntOrString>")]
183 pub cpus: Option<String>,
184
185 #[serde(
187 default,
188 deserialize_with = "flex::opt_string",
189 skip_serializing_if = "Option::is_none"
190 )]
191 #[schemars(with = "Option<flex::IntOrString>")]
192 pub cpuset: Option<String>,
193
194 #[serde(
197 default,
198 rename = "mem_limit",
199 deserialize_with = "flex::opt_string",
200 skip_serializing_if = "Option::is_none"
201 )]
202 #[schemars(with = "Option<flex::IntOrString>")]
203 pub memory: Option<String>,
204
205 #[serde(
208 default,
209 deserialize_with = "flex::opt_bool",
210 skip_serializing_if = "Option::is_none"
211 )]
212 #[schemars(with = "Option<flex::BoolOrString>")]
213 pub privileged: Option<bool>,
214
215 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub idmap: Option<IdmapSpec>,
218
219 #[serde(
221 default,
222 rename = "incus_profiles",
223 skip_serializing_if = "Option::is_none"
224 )]
225 pub profiles: Option<Vec<String>>,
226
227 #[serde(
231 default,
232 deserialize_with = "flex::string_map_or_list",
233 skip_serializing_if = "BTreeMap::is_empty"
234 )]
235 #[schemars(with = "flex::MapOrList")]
236 pub labels: BTreeMap<String, String>,
237
238 #[serde(
243 default,
244 rename = "environment",
245 skip_serializing_if = "Environment::is_empty"
246 )]
247 #[schemars(with = "flex::EnvMapOrList")]
248 pub env: Environment,
249
250 #[serde(default, skip_serializing_if = "Vec::is_empty")]
253 pub volumes: Vec<VolumeSpec>,
254
255 #[serde(default, skip_serializing_if = "Vec::is_empty")]
258 pub ports: Vec<PortSpec>,
259
260 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub ready: Option<Vec<ReadyCheck>>,
264
265 #[serde(
268 default,
269 deserialize_with = "flex::opt_string",
270 skip_serializing_if = "Option::is_none"
271 )]
272 #[schemars(with = "Option<flex::IntOrString>")]
273 pub ready_timeout: Option<String>,
274
275 #[serde(
278 default,
279 deserialize_with = "flex::opt_string",
280 skip_serializing_if = "Option::is_none"
281 )]
282 #[schemars(with = "Option<flex::IntOrString>")]
283 pub user: Option<String>,
284
285 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub working_dir: Option<String>,
288
289 #[serde(default, skip_serializing_if = "ExecSpec::is_empty")]
291 pub exec: ExecSpec,
292
293 #[serde(
299 default,
300 deserialize_with = "flex::opt_command",
301 skip_serializing_if = "Option::is_none"
302 )]
303 #[schemars(with = "Option<flex::Command>")]
304 pub command: Option<Vec<String>>,
305
306 #[serde(
310 default,
311 deserialize_with = "flex::opt_command",
312 skip_serializing_if = "Option::is_none"
313 )]
314 #[schemars(with = "Option<flex::Command>")]
315 pub entrypoint: Option<Vec<String>>,
316
317 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub restart: Option<RestartMode>,
324
325 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub healthcheck: Option<Healthcheck>,
330
331 #[serde(
334 default,
335 deserialize_with = "depends_on",
336 skip_serializing_if = "BTreeMap::is_empty"
337 )]
338 #[schemars(with = "DependsOnRepr")]
339 pub depends_on: BTreeMap<String, Dependency>,
340
341 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub deploy: Option<Deploy>,
344
345 #[serde(default, skip_serializing_if = "Vec::is_empty")]
349 pub domains: Vec<DomainSpec>,
350
351 #[serde(default, skip_serializing_if = "Vec::is_empty")]
354 pub secrets: Vec<SecretRef>,
355
356 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub egress: Option<crate::egress::EgressSpec>,
365
366 #[serde(
368 default,
369 deserialize_with = "flex::string_map",
370 skip_serializing_if = "BTreeMap::is_empty"
371 )]
372 #[schemars(with = "BTreeMap<String, flex::Scalar>")]
373 pub raw_config: BTreeMap<String, String>,
374
375 #[serde(
377 default,
378 deserialize_with = "flex::string_map_map",
379 skip_serializing_if = "BTreeMap::is_empty"
380 )]
381 #[schemars(with = "BTreeMap<String, BTreeMap<String, flex::Scalar>>")]
382 pub raw_devices: BTreeMap<String, BTreeMap<String, String>>,
383 #[serde(skip)]
385 pub workspace_nesting: bool,
386 #[serde(skip)]
389 pub stack_udp: bool,
390}
391
392impl SandboxSpec {
393 pub fn exec_defaults(&self) -> ExecDefaults {
395 ExecDefaults {
396 user: self.user.clone(),
397 cwd: self.working_dir.clone(),
398 env: self.exec.env.clone(),
399 login: self.exec.login,
400 }
401 }
402}
403
404fn is_default<T: Default + PartialEq>(v: &T) -> bool {
405 *v == T::default()
406}
407
408#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
410#[serde(deny_unknown_fields)]
411pub struct DomainSpec {
412 pub host: String,
416
417 #[serde(default, skip_serializing_if = "Option::is_none")]
419 pub path: Option<String>,
420
421 #[serde(default, skip_serializing_if = "Option::is_none")]
424 pub port: Option<u16>,
425
426 #[serde(
429 default,
430 deserialize_with = "flex::opt_bool",
431 skip_serializing_if = "Option::is_none"
432 )]
433 #[schemars(with = "Option<flex::BoolOrString>")]
434 pub https: Option<bool>,
435
436 #[serde(default, skip_serializing_if = "Option::is_none")]
440 pub redirect: Option<String>,
441
442 #[serde(
444 default,
445 deserialize_with = "flex::bool",
446 skip_serializing_if = "std::ops::Not::not"
447 )]
448 #[schemars(with = "flex::BoolOrString")]
449 pub strip_prefix: bool,
450
451 #[serde(
453 default,
454 deserialize_with = "flex::bool",
455 skip_serializing_if = "std::ops::Not::not"
456 )]
457 #[schemars(with = "flex::BoolOrString")]
458 pub www_redirect: bool,
459}
460
461#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
479#[serde(untagged)]
480pub enum IdmapSpec {
481 Mode(IdmapMode),
482 Map(IdmapMap),
483 Raw(IdmapRaw),
484}
485
486#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
487#[serde(rename_all = "snake_case")]
488pub enum IdmapMode {
489 #[default]
490 Auto,
491 None,
492 Always,
493}
494
495#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
496#[serde(deny_unknown_fields)]
497pub struct IdmapMap {
498 #[serde(default)]
499 pub mode: IdmapMode,
500 #[serde(default = "default_id")]
501 pub host_uid: u32,
502 #[serde(default = "default_id")]
503 pub host_gid: u32,
504 #[serde(default = "default_id")]
505 pub guest_uid: u32,
506 #[serde(default = "default_id")]
507 pub guest_gid: u32,
508}
509
510#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
511#[serde(deny_unknown_fields)]
512pub struct IdmapRaw {
513 pub raw: String,
515}
516
517fn default_id() -> u32 {
518 1000
519}
520
521#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
523#[serde(rename_all = "snake_case")]
524pub enum PortBind {
525 #[default]
527 Host,
528 Guest,
530}
531
532impl PortBind {
533 pub fn as_str(&self) -> &'static str {
534 match self {
535 PortBind::Host => "host",
536 PortBind::Guest => "guest",
537 }
538 }
539}
540
541#[derive(Debug, Clone, Default, PartialEq)]
544pub struct PortSpec {
545 pub name: Option<String>,
547 pub bind: PortBind,
548 pub listen: String,
551 pub connect: String,
553 pub search: Option<u16>,
557 pub options: BTreeMap<String, String>,
559}
560
561#[derive(Serialize, Deserialize, JsonSchema)]
563#[serde(deny_unknown_fields)]
564pub(crate) struct PortMapping {
565 #[serde(default, skip_serializing_if = "Option::is_none")]
567 name: Option<String>,
568
569 #[serde(deserialize_with = "flex::string", serialize_with = "port_number")]
571 #[schemars(with = "flex::IntOrString")]
572 target: String,
573
574 #[serde(deserialize_with = "flex::string", serialize_with = "port_number")]
577 #[schemars(with = "flex::IntOrString")]
578 published: String,
579
580 #[serde(default, skip_serializing_if = "Option::is_none")]
582 host_ip: Option<String>,
583
584 #[serde(default, skip_serializing_if = "Option::is_none")]
586 protocol: Option<String>,
587
588 #[serde(
590 default,
591 deserialize_with = "flex::string_map",
592 skip_serializing_if = "BTreeMap::is_empty"
593 )]
594 #[schemars(with = "BTreeMap<String, flex::Scalar>")]
595 options: BTreeMap<String, String>,
596}
597
598fn port_number<S: serde::Serializer>(p: &str, s: S) -> Result<S::Ok, S::Error> {
600 match p.parse::<u16>() {
601 Ok(n) => s.serialize_u16(n),
602 Err(_) => s.serialize_str(p),
603 }
604}
605
606#[derive(Serialize, Deserialize, JsonSchema)]
608#[serde(deny_unknown_fields)]
609pub(crate) struct ProxyPort {
610 #[serde(default, skip_serializing_if = "Option::is_none")]
612 name: Option<String>,
613
614 #[serde(default, skip_serializing_if = "is_default")]
617 bind: PortBind,
618
619 #[serde(deserialize_with = "flex::string")]
623 #[schemars(with = "flex::IntOrString")]
624 listen: String,
625
626 #[serde(deserialize_with = "flex::string")]
629 #[schemars(with = "flex::IntOrString")]
630 connect: String,
631
632 #[serde(
634 default,
635 deserialize_with = "flex::string_map",
636 skip_serializing_if = "BTreeMap::is_empty"
637 )]
638 #[schemars(with = "BTreeMap<String, flex::Scalar>")]
639 options: BTreeMap<String, String>,
640}
641
642impl PortMapping {
643 fn into_spec(self) -> crate::error::Result<PortSpec> {
644 let proto = self.protocol.as_deref().unwrap_or("tcp");
645 let mut p = crate::shorthand::docker_port(
646 self.host_ip.as_deref(),
647 &self.published,
648 &self.target,
649 proto,
650 )?;
651 p.name = self.name;
652 p.options = self.options;
653 Ok(p)
654 }
655}
656
657fn connect_port(connect: &str) -> Option<(&str, &str)> {
660 let (proto, rest) = match connect.split_once(':') {
661 Some((p @ ("tcp" | "udp"), rest)) => (p, rest),
662 _ => match connect.rsplit_once('/') {
663 Some((rest, p @ ("tcp" | "udp"))) => (p, rest),
664 _ => ("tcp", connect),
665 },
666 };
667 let port = match rest.rsplit_once(':') {
668 Some(("127.0.0.1" | "0.0.0.0", port)) => port,
669 Some(_) => return None,
670 None => rest,
671 };
672 port.parse::<u16>().ok().map(|_| (proto, port))
673}
674
675impl PortSpec {
676 fn as_mapping(&self) -> Option<PortMapping> {
680 if self.bind != PortBind::Host {
681 return None;
682 }
683 let listen = crate::plan::normalize_addr(&self.listen, "127.0.0.1").ok()?;
684 let (lproto, host, lport) = crate::plan::split_addr(&listen)?;
685 let (cproto, cport) = connect_port(&self.connect)?;
686 if lproto != cproto {
687 return None;
688 }
689 let published = match self.search.filter(|n| *n > 0) {
690 Some(n) => format!("{lport}-{}", lport.checked_add(n)?),
691 None => lport.to_string(),
692 };
693 Some(PortMapping {
694 name: self.name.clone(),
695 target: cport.to_string(),
696 published,
697 host_ip: (host != "127.0.0.1").then(|| host.trim_matches(['[', ']']).to_string()),
698 protocol: (lproto != "tcp").then(|| lproto.to_string()),
699 options: self.options.clone(),
700 })
701 }
702}
703
704impl Serialize for PortSpec {
705 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
706 if let Some(m) = self.as_mapping() {
707 return m.serialize(s);
708 }
709 ProxyPort {
710 name: self.name.clone(),
711 bind: self.bind,
712 listen: self.listen.clone(),
713 connect: self.connect.clone(),
714 options: self.options.clone(),
715 }
716 .serialize(s)
717 }
718}
719
720impl<'de> Deserialize<'de> for PortSpec {
721 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
722 use serde::de::Error as _;
723 let v = serde_json::Value::deserialize(d)?;
724 let custom = |e: String| D::Error::custom(format!("port: {e}"));
725 match v {
726 serde_json::Value::String(s) => {
727 crate::shorthand::docker_short_port(&s).map_err(|e| custom(e.to_string()))
728 }
729 serde_json::Value::Number(n) => crate::shorthand::docker_short_port(&n.to_string())
730 .map_err(|e| custom(e.to_string())),
731 serde_json::Value::Object(ref m) if m.contains_key("search") => Err(custom(
732 "search is not an isb key: publish a range instead, e.g. \"5173-5223:5173\" or published: 5173-5223".into(),
733 )),
734 serde_json::Value::Object(ref m)
735 if ["listen", "connect", "bind"].iter().any(|k| m.contains_key(*k)) =>
736 {
737 let r: ProxyPort = serde_json::from_value(v).map_err(|e| custom(e.to_string()))?;
738 Ok(PortSpec {
739 name: r.name,
740 bind: r.bind,
741 listen: r.listen,
742 connect: r.connect,
743 search: None,
744 options: r.options,
745 })
746 }
747 v @ serde_json::Value::Object(_) => serde_json::from_value::<PortMapping>(v)
748 .map_err(|e| custom(e.to_string()))?
749 .into_spec()
750 .map_err(|e| custom(e.to_string())),
751 other => Err(custom(format!(
752 "expected [HOST_IP:]PUBLISHED:TARGET[/PROTOCOL], {{target, published, ...}} or {{listen, connect, ...}}, got {other}"
753 ))),
754 }
755 }
756}
757
758impl JsonSchema for PortSpec {
759 fn schema_name() -> std::borrow::Cow<'static, str> {
760 "PortSpec".into()
761 }
762
763 fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
764 let mapping = g.subschema_for::<PortMapping>();
765 let proxy = g.subschema_for::<ProxyPort>();
766 schemars::json_schema!({
767 "description": "A published port, docker style, or an incus proxy in either direction.",
768 "oneOf": [
769 {
770 "type": "string",
771 "description": "[HOST_IP:]PUBLISHED:TARGET[/PROTOCOL]. HOST_IP defaults to 127.0.0.1. PUBLISHED may be a range (5173-5223) to take the first free port."
772 },
773 mapping,
774 proxy
775 ]
776 })
777 }
778}
779
780#[derive(Debug, Clone, PartialEq, JsonSchema)]
783#[serde(rename_all = "snake_case")]
784pub enum ReadyCheck {
785 Running,
787 Agent,
789 DefaultRoute,
791 UserExists(String),
793 PathWritable(String),
795 Command(Vec<String>),
797}
798
799#[derive(Serialize, Deserialize)]
802#[serde(untagged)]
803enum ReadyRepr {
804 Name(String),
805 UserExists { user_exists: String },
806 PathWritable { path_writable: String },
807 Command { command: Vec<flex::Scalar> },
808}
809
810impl Serialize for ReadyCheck {
811 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
812 match self {
813 ReadyCheck::Running => ReadyRepr::Name("running".into()),
814 ReadyCheck::DefaultRoute => ReadyRepr::Name("default_route".into()),
815 ReadyCheck::Agent => ReadyRepr::Name("agent".into()),
816 ReadyCheck::UserExists(u) => ReadyRepr::UserExists {
817 user_exists: u.clone(),
818 },
819 ReadyCheck::PathWritable(p) => ReadyRepr::PathWritable {
820 path_writable: p.clone(),
821 },
822 ReadyCheck::Command(c) => ReadyRepr::Command {
823 command: c.iter().cloned().map(flex::Scalar::String).collect(),
824 },
825 }
826 .serialize(s)
827 }
828}
829
830impl<'de> Deserialize<'de> for ReadyCheck {
831 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
832 use serde::de::Error as _;
833 let r = ReadyRepr::deserialize(d).map_err(|_| {
834 D::Error::custom(
835 "expected running, agent, default_route, {user_exists: USER}, {path_writable: PATH} or {command: [ARGV...]}",
836 )
837 })?;
838 Ok(match r {
839 ReadyRepr::Name(n) => match n.as_str() {
840 "running" => ReadyCheck::Running,
841 "default_route" => ReadyCheck::DefaultRoute,
842 "agent" => ReadyCheck::Agent,
843 other => {
844 return Err(D::Error::custom(format!(
845 "unknown readiness check {other:?} (running, agent, default_route, user_exists, path_writable, command)"
846 )));
847 }
848 },
849 ReadyRepr::UserExists { user_exists } => ReadyCheck::UserExists(user_exists),
850 ReadyRepr::PathWritable { path_writable } => ReadyCheck::PathWritable(path_writable),
851 ReadyRepr::Command { command } => {
852 ReadyCheck::Command(command.into_iter().map(flex::Scalar::into_string).collect())
853 }
854 })
855 }
856}
857
858impl std::fmt::Display for ReadyCheck {
859 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
860 match self {
861 ReadyCheck::Running => write!(f, "running"),
862 ReadyCheck::DefaultRoute => write!(f, "default_route"),
863 ReadyCheck::Agent => write!(f, "agent"),
864 ReadyCheck::UserExists(u) => write!(f, "user_exists({u})"),
865 ReadyCheck::PathWritable(p) => write!(f, "path_writable({p})"),
866 ReadyCheck::Command(c) => write!(f, "command({})", c.join(" ")),
867 }
868 }
869}
870
871#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
873#[serde(deny_unknown_fields)]
874pub struct ExecSpec {
875 #[serde(
878 default,
879 deserialize_with = "flex::env_map_or_list",
880 skip_serializing_if = "BTreeMap::is_empty"
881 )]
882 #[schemars(with = "flex::MapOrList")]
883 pub env: BTreeMap<String, String>,
884
885 #[serde(
888 default,
889 deserialize_with = "flex::bool",
890 skip_serializing_if = "std::ops::Not::not"
891 )]
892 #[schemars(with = "flex::BoolOrString")]
893 pub login: bool,
894}
895
896impl ExecSpec {
897 pub fn is_empty(&self) -> bool {
898 self == &ExecSpec::default()
899 }
900}
901
902#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
904#[serde(deny_unknown_fields)]
905pub struct ExecDefaults {
906 #[serde(
909 default,
910 deserialize_with = "flex::opt_string",
911 skip_serializing_if = "Option::is_none"
912 )]
913 #[schemars(with = "Option<flex::IntOrString>")]
914 pub user: Option<String>,
915
916 #[serde(default, skip_serializing_if = "Option::is_none")]
918 pub cwd: Option<String>,
919
920 #[serde(
922 default,
923 deserialize_with = "flex::string_map",
924 skip_serializing_if = "BTreeMap::is_empty"
925 )]
926 #[schemars(with = "BTreeMap<String, flex::Scalar>")]
927 pub env: BTreeMap<String, String>,
928
929 #[serde(
932 default,
933 deserialize_with = "flex::bool",
934 skip_serializing_if = "std::ops::Not::not"
935 )]
936 #[schemars(with = "flex::BoolOrString")]
937 pub login: bool,
938}
939
940impl ExecDefaults {
941 pub fn is_empty(&self) -> bool {
942 self == &ExecDefaults::default()
943 }
944}
945
946#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, JsonSchema)]
952#[serde(rename_all = "kebab-case")]
953pub enum RestartMode {
954 #[default]
955 No,
956 Always,
957 OnFailure,
958 UnlessStopped,
959}
960
961impl<'de> Deserialize<'de> for RestartMode {
963 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
964 use serde::de::Error as _;
965 let s = flex::Scalar::deserialize(d)?.into_string();
966 Ok(match s.as_str() {
967 "no" | "false" | "" => RestartMode::No,
968 "always" => RestartMode::Always,
969 "on-failure" => RestartMode::OnFailure,
970 "unless-stopped" => RestartMode::UnlessStopped,
971 other => {
972 return Err(D::Error::custom(format!(
973 "unknown restart {other:?} (no, always, on-failure, unless-stopped)"
974 )));
975 }
976 })
977 }
978}
979
980impl RestartMode {
981 pub fn is_long_running(&self) -> bool {
982 *self != RestartMode::No
983 }
984}
985
986#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
988#[serde(deny_unknown_fields)]
989pub struct Healthcheck {
990 #[serde(
993 default,
994 deserialize_with = "health_test",
995 skip_serializing_if = "Vec::is_empty"
996 )]
997 #[schemars(with = "Option<flex::Command>")]
998 pub test: Vec<String>,
999
1000 #[serde(
1002 default,
1003 deserialize_with = "flex::opt_string",
1004 skip_serializing_if = "Option::is_none"
1005 )]
1006 #[schemars(with = "Option<flex::IntOrString>")]
1007 pub interval: Option<String>,
1008
1009 #[serde(
1011 default,
1012 deserialize_with = "flex::opt_string",
1013 skip_serializing_if = "Option::is_none"
1014 )]
1015 #[schemars(with = "Option<flex::IntOrString>")]
1016 pub timeout: Option<String>,
1017
1018 #[serde(default, skip_serializing_if = "Option::is_none")]
1020 pub retries: Option<u32>,
1021
1022 #[serde(
1026 default,
1027 deserialize_with = "flex::opt_string",
1028 skip_serializing_if = "Option::is_none"
1029 )]
1030 #[schemars(with = "Option<flex::IntOrString>")]
1031 pub start_period: Option<String>,
1032
1033 #[serde(
1035 default,
1036 deserialize_with = "flex::opt_string",
1037 skip_serializing_if = "Option::is_none"
1038 )]
1039 #[schemars(with = "Option<flex::IntOrString>")]
1040 pub start_interval: Option<String>,
1041
1042 #[serde(
1044 default,
1045 deserialize_with = "flex::bool",
1046 skip_serializing_if = "std::ops::Not::not"
1047 )]
1048 #[schemars(with = "flex::BoolOrString")]
1049 pub disable: bool,
1050}
1051
1052fn health_test<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
1053 match flex::Command::deserialize(d)? {
1054 flex::Command::String(s) => Ok(vec!["CMD-SHELL".into(), s]),
1055 flex::Command::Argv(v) => Ok(v.into_iter().map(flex::Scalar::into_string).collect()),
1056 }
1057}
1058
1059#[derive(Debug, Clone, PartialEq)]
1061pub struct HealthProbe {
1062 pub argv: Vec<String>,
1063 pub interval: std::time::Duration,
1064 pub timeout: std::time::Duration,
1065 pub retries: u32,
1066 pub start_period: std::time::Duration,
1067 pub start_interval: std::time::Duration,
1068 pub startup_grace: std::time::Duration,
1072}
1073
1074impl HealthProbe {
1075 pub fn default_grace(interval: std::time::Duration, retries: u32) -> std::time::Duration {
1082 (interval * retries * 2).clamp(
1083 std::time::Duration::from_secs(60),
1084 std::time::Duration::from_secs(300),
1085 )
1086 }
1087
1088 pub fn failure_counts(&self, passed: bool, since_start: std::time::Duration) -> bool {
1093 if passed {
1094 since_start >= self.start_period
1095 } else {
1096 since_start >= self.startup_grace
1097 }
1098 }
1099}
1100
1101impl Healthcheck {
1102 pub fn probe(&self) -> Result<Option<HealthProbe>, String> {
1104 if self.disable {
1105 return Ok(None);
1106 }
1107 let argv = match self.test.split_first() {
1108 None => return Err("healthcheck needs a test".into()),
1109 Some((k, _)) if k == "NONE" => return Ok(None),
1110 Some((k, rest)) if k == "CMD" => rest.to_vec(),
1111 Some((k, rest)) if k == "CMD-SHELL" => {
1112 if rest.len() != 1 {
1113 return Err("CMD-SHELL takes exactly one shell line".into());
1114 }
1115 vec!["/bin/sh".into(), "-c".into(), rest[0].clone()]
1116 }
1117 Some((k, _)) => {
1118 return Err(format!(
1119 "healthcheck test must start with CMD, CMD-SHELL or NONE, not {k:?} (a plain string is a shell line)"
1120 ));
1121 }
1122 };
1123 if argv.is_empty() {
1124 return Err("healthcheck test has no command".into());
1125 }
1126 let dur = |v: &Option<String>, default: u64| -> Result<std::time::Duration, String> {
1127 match v {
1128 Some(s) => flex::parse_duration(s),
1129 None => Ok(std::time::Duration::from_secs(default)),
1130 }
1131 };
1132 let interval = dur(&self.interval, 30)?;
1133 let retries = self.retries.unwrap_or(3).max(1);
1134 let start_period = dur(&self.start_period, 0)?;
1135 Ok(Some(HealthProbe {
1136 argv,
1137 interval,
1138 timeout: dur(&self.timeout, 30)?,
1139 retries,
1140 start_period,
1141 start_interval: dur(&self.start_interval, 5)?,
1142 startup_grace: match &self.start_period {
1143 Some(_) => start_period,
1144 None => HealthProbe::default_grace(interval, retries),
1145 },
1146 }))
1147 }
1148}
1149
1150#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1152#[serde(rename_all = "snake_case")]
1153pub enum DependCondition {
1154 #[default]
1155 ServiceStarted,
1156 ServiceHealthy,
1157}
1158
1159#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1160#[serde(deny_unknown_fields)]
1161pub struct Dependency {
1162 #[serde(default)]
1163 pub condition: DependCondition,
1164}
1165
1166#[derive(Deserialize, JsonSchema)]
1167#[serde(untagged)]
1168#[allow(dead_code)]
1169enum DependsOnRepr {
1170 List(Vec<String>),
1171 Map(BTreeMap<String, Dependency>),
1172}
1173
1174fn depends_on<'de, D: serde::Deserializer<'de>>(
1175 d: D,
1176) -> Result<BTreeMap<String, Dependency>, D::Error> {
1177 Ok(match DependsOnRepr::deserialize(d)? {
1178 DependsOnRepr::List(l) => l.into_iter().map(|s| (s, Dependency::default())).collect(),
1179 DependsOnRepr::Map(m) => m,
1180 })
1181}
1182
1183#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1185#[serde(deny_unknown_fields)]
1186pub struct Deploy {
1187 #[serde(default, skip_serializing_if = "Option::is_none")]
1189 pub mode: Option<String>,
1190
1191 #[serde(default, skip_serializing_if = "Option::is_none")]
1193 pub replicas: Option<u32>,
1194
1195 #[serde(default, skip_serializing_if = "Option::is_none")]
1197 pub update_config: Option<UpdateConfig>,
1198
1199 #[serde(default, skip_serializing_if = "Option::is_none")]
1201 pub rollback_config: Option<UpdateConfig>,
1202
1203 #[serde(default, skip_serializing_if = "Option::is_none")]
1205 pub restart_policy: Option<RestartPolicy>,
1206
1207 #[serde(default, skip_serializing_if = "Option::is_none")]
1210 pub resources: Option<Resources>,
1211
1212 #[serde(
1214 default,
1215 deserialize_with = "flex::string_map_or_list",
1216 skip_serializing_if = "BTreeMap::is_empty"
1217 )]
1218 #[schemars(with = "flex::MapOrList")]
1219 pub labels: BTreeMap<String, String>,
1220}
1221
1222#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1223#[serde(rename_all = "kebab-case")]
1224pub enum UpdateOrder {
1225 #[default]
1228 StopFirst,
1229 StartFirst,
1232}
1233
1234#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1235#[serde(rename_all = "snake_case")]
1236pub enum FailureAction {
1237 #[default]
1239 Pause,
1240 Rollback,
1242 Continue,
1244}
1245
1246#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1247#[serde(deny_unknown_fields)]
1248pub struct UpdateConfig {
1249 #[serde(default, skip_serializing_if = "Option::is_none")]
1251 pub parallelism: Option<u32>,
1252
1253 #[serde(
1255 default,
1256 deserialize_with = "flex::opt_string",
1257 skip_serializing_if = "Option::is_none"
1258 )]
1259 #[schemars(with = "Option<flex::IntOrString>")]
1260 pub delay: Option<String>,
1261
1262 #[serde(default, skip_serializing_if = "Option::is_none")]
1264 pub failure_action: Option<FailureAction>,
1265
1266 #[serde(
1269 default,
1270 deserialize_with = "flex::opt_string",
1271 skip_serializing_if = "Option::is_none"
1272 )]
1273 #[schemars(with = "Option<flex::IntOrString>")]
1274 pub monitor: Option<String>,
1275
1276 #[serde(default, skip_serializing_if = "Option::is_none")]
1278 pub order: Option<UpdateOrder>,
1279}
1280
1281#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1282#[serde(rename_all = "kebab-case")]
1283pub enum RestartCondition {
1284 None,
1285 OnFailure,
1286 #[default]
1287 Any,
1288}
1289
1290#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1291#[serde(deny_unknown_fields)]
1292pub struct RestartPolicy {
1293 #[serde(default, skip_serializing_if = "Option::is_none")]
1295 pub condition: Option<RestartCondition>,
1296
1297 #[serde(
1299 default,
1300 deserialize_with = "flex::opt_string",
1301 skip_serializing_if = "Option::is_none"
1302 )]
1303 #[schemars(with = "Option<flex::IntOrString>")]
1304 pub delay: Option<String>,
1305
1306 #[serde(default, skip_serializing_if = "Option::is_none")]
1308 pub max_attempts: Option<u32>,
1309
1310 #[serde(
1312 default,
1313 deserialize_with = "flex::opt_string",
1314 skip_serializing_if = "Option::is_none"
1315 )]
1316 #[schemars(with = "Option<flex::IntOrString>")]
1317 pub window: Option<String>,
1318}
1319
1320#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1321#[serde(deny_unknown_fields)]
1322pub struct Resources {
1323 #[serde(default, skip_serializing_if = "Option::is_none")]
1324 pub limits: Option<ResourceLimits>,
1325}
1326
1327#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1328#[serde(deny_unknown_fields)]
1329pub struct ResourceLimits {
1330 #[serde(
1331 default,
1332 deserialize_with = "flex::opt_string",
1333 skip_serializing_if = "Option::is_none"
1334 )]
1335 #[schemars(with = "Option<flex::IntOrString>")]
1336 pub cpus: Option<String>,
1337
1338 #[serde(
1339 default,
1340 deserialize_with = "flex::opt_string",
1341 skip_serializing_if = "Option::is_none"
1342 )]
1343 #[schemars(with = "Option<flex::IntOrString>")]
1344 pub memory: Option<String>,
1345}
1346
1347#[derive(Debug, Clone, Default, PartialEq, Serialize, JsonSchema)]
1349#[serde(deny_unknown_fields)]
1350pub struct SecretRef {
1351 pub source: String,
1353 #[serde(default, skip_serializing_if = "Option::is_none")]
1355 pub target: Option<String>,
1356 #[serde(default, skip_serializing_if = "Option::is_none")]
1358 pub uid: Option<u32>,
1359 #[serde(default, skip_serializing_if = "Option::is_none")]
1360 pub gid: Option<u32>,
1361 #[serde(
1363 default,
1364 deserialize_with = "flex::opt_string",
1365 skip_serializing_if = "Option::is_none"
1366 )]
1367 #[schemars(with = "Option<flex::IntOrString>")]
1368 pub mode: Option<String>,
1369 #[serde(default, skip_serializing_if = "Option::is_none")]
1372 pub on_change: Option<OnChange>,
1373}
1374
1375#[derive(Deserialize)]
1376#[serde(untagged)]
1377enum SecretRefRepr {
1378 Name(String),
1379 Long {
1380 source: String,
1381 #[serde(default)]
1382 target: Option<String>,
1383 #[serde(default)]
1384 uid: Option<flex::Scalar>,
1385 #[serde(default)]
1386 gid: Option<flex::Scalar>,
1387 #[serde(default)]
1388 mode: Option<flex::Scalar>,
1389 #[serde(default)]
1390 on_change: Option<OnChange>,
1391 },
1392}
1393
1394impl<'de> Deserialize<'de> for SecretRef {
1395 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1396 use serde::de::Error as _;
1397 let id = |v: Option<flex::Scalar>, what: &str| -> Result<Option<u32>, D::Error> {
1398 v.map(|s| {
1399 let s = s.into_string();
1400 s.trim().parse().map_err(|_| {
1401 D::Error::custom(format!("secret {what} must be a number, got {s:?}"))
1402 })
1403 })
1404 .transpose()
1405 };
1406 match SecretRefRepr::deserialize(d).map_err(|_| {
1407 D::Error::custom(
1408 "expected a secret name or {source, target, uid, gid, mode, on_change}",
1409 )
1410 })? {
1411 SecretRefRepr::Name(source) => Ok(SecretRef {
1412 source,
1413 ..Default::default()
1414 }),
1415 SecretRefRepr::Long {
1416 source,
1417 target,
1418 uid,
1419 gid,
1420 mode,
1421 on_change,
1422 } => Ok(SecretRef {
1423 source,
1424 target,
1425 on_change,
1426 uid: id(uid, "uid")?,
1427 gid: id(gid, "gid")?,
1428 mode: mode.map(flex::Scalar::into_string),
1430 }),
1431 }
1432 }
1433}
1434
1435impl SecretRef {
1436 pub fn guest_path(&self) -> String {
1438 let t = self.target.as_deref().unwrap_or(&self.source);
1439 if t.starts_with('/') {
1440 t.to_string()
1441 } else {
1442 format!("/run/secrets/{t}")
1443 }
1444 }
1445
1446 pub fn file_mode(&self) -> Result<u32, String> {
1448 match &self.mode {
1449 None => Ok(0o400),
1450 Some(m) => u32::from_str_radix(m.trim().trim_start_matches("0o"), 8)
1451 .ok()
1452 .filter(|m| *m <= 0o7777)
1453 .ok_or_else(|| format!("secret mode {m:?} is not an octal mode like 0400")),
1454 }
1455 }
1456}
1457
1458impl SandboxSpec {
1459 pub fn secret_keys(&self) -> std::collections::BTreeSet<&str> {
1461 self.secrets
1462 .iter()
1463 .map(|r| r.source.as_str())
1464 .chain(self.env.secrets.values().map(String::as_str))
1465 .chain(self.env.files.values().map(String::as_str))
1466 .collect()
1467 }
1468
1469 pub fn has_secret_files(&self) -> bool {
1472 !self.secrets.is_empty() || !self.env.files.is_empty()
1473 }
1474
1475 pub fn secret_on_change(&self, key: &str) -> Option<OnChange> {
1478 self.secrets
1479 .iter()
1480 .filter(|r| r.source == key)
1481 .filter_map(|r| r.on_change)
1482 .chain(
1483 self.env
1484 .secrets
1485 .iter()
1486 .chain(&self.env.files)
1487 .filter(|(_, k)| *k == key)
1488 .filter_map(|(var, _)| self.env.on_change.get(var).copied()),
1489 )
1490 .max()
1491 }
1492
1493 pub fn long_running(&self) -> bool {
1495 self.restart.is_some_and(|r| r.is_long_running())
1496 }
1497
1498 pub fn replicas(&self) -> u32 {
1500 self.deploy.as_ref().and_then(|d| d.replicas).unwrap_or(1)
1501 }
1502
1503 pub fn health_probe(&self) -> Result<Option<HealthProbe>, String> {
1505 match &self.healthcheck {
1506 None => Ok(None),
1507 Some(h) => h.probe(),
1508 }
1509 }
1510}
1511
1512mod builder;
1513pub use builder::{PortBinding, Volume};
1514
1515pub fn compose_schema() -> serde_json::Value {
1517 serde_json::to_value(schemars::schema_for!(ComposeFile)).expect("schema serializes")
1518}
1519
1520#[cfg(test)]
1521mod tests;