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, SecretDef};
15
16#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
19#[serde(deny_unknown_fields)]
20pub struct ComposeFile {
21 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub name: Option<String>,
26
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub incus_project: Option<String>,
30
31 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
34 pub volumes: BTreeMap<String, NamedVolumeSpec>,
35
36 #[serde(default)]
38 pub services: BTreeMap<String, SandboxSpec>,
39
40 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
43 pub secrets: BTreeMap<String, SecretDef>,
44}
45
46#[derive(Debug, Clone, Default, PartialEq)]
49pub struct Environment {
50 pub vars: BTreeMap<String, String>,
52 pub secrets: BTreeMap<String, String>,
54 pub on_change: BTreeMap<String, OnChange>,
56}
57
58impl Environment {
59 pub fn is_empty(&self) -> bool {
60 self.vars.is_empty() && self.secrets.is_empty()
61 }
62}
63
64impl std::ops::Deref for Environment {
66 type Target = BTreeMap<String, String>;
67 fn deref(&self) -> &Self::Target {
68 &self.vars
69 }
70}
71
72impl std::ops::DerefMut for Environment {
73 fn deref_mut(&mut self) -> &mut Self::Target {
74 &mut self.vars
75 }
76}
77
78impl<'a> IntoIterator for &'a Environment {
79 type Item = (&'a String, &'a String);
80 type IntoIter = std::collections::btree_map::Iter<'a, String, String>;
81 fn into_iter(self) -> Self::IntoIter {
82 self.vars.iter()
83 }
84}
85
86impl From<BTreeMap<String, String>> for Environment {
87 fn from(vars: BTreeMap<String, String>) -> Self {
88 Environment {
89 vars,
90 secrets: BTreeMap::new(),
91 on_change: BTreeMap::new(),
92 }
93 }
94}
95
96impl Serialize for Environment {
97 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
98 use serde::ser::SerializeMap;
99 let mut m = s.serialize_map(None)?;
100 let mut keys: Vec<&String> = self.vars.keys().chain(self.secrets.keys()).collect();
101 keys.sort();
102 keys.dedup();
103 for k in keys {
104 match (self.vars.get(k), self.secrets.get(k)) {
105 (Some(v), _) => m.serialize_entry(k, v)?,
106 (None, Some(sec)) => {
107 let mut v = BTreeMap::from([("secret", sec.as_str())]);
108 if let Some(o) = self.on_change.get(k) {
109 v.insert("on_change", o.as_str());
110 }
111 m.serialize_entry(k, &v)?
112 }
113 (None, None) => {}
114 }
115 }
116 m.end()
117 }
118}
119
120impl<'de> Deserialize<'de> for Environment {
121 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
122 use serde::de::Error as _;
123 let mut env = Environment::default();
124 match flex::EnvMapOrList::deserialize(d)? {
125 flex::EnvMapOrList::Map(m) => {
126 for (k, v) in m {
127 match v {
128 flex::EnvValue::Scalar(v) => {
129 env.vars.insert(k, v.into_string());
130 }
131 flex::EnvValue::Secret { secret, .. } if secret.is_empty() => {
132 return Err(D::Error::custom(format!(
133 "environment {k}: secret needs a top-level secret's name"
134 )));
135 }
136 flex::EnvValue::Secret { secret, on_change } => {
137 if let Some(o) = on_change {
138 env.on_change.insert(k.clone(), o);
139 }
140 env.secrets.insert(k, secret);
141 }
142 }
143 }
144 }
145 flex::EnvMapOrList::List(l) => {
146 for item in l {
147 let Some((k, v)) = item.split_once('=') else {
148 return Err(D::Error::custom(format!(
149 "environment entry {item:?} has no value: write {item}=VALUE"
150 )));
151 };
152 env.vars.insert(k.to_string(), v.to_string());
153 }
154 }
155 }
156 Ok(env)
157 }
158}
159
160#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
162#[serde(deny_unknown_fields)]
163pub struct NamedVolumeSpec {
164 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub name: Option<String>,
168
169 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub pool: Option<String>,
173
174 #[serde(
176 default,
177 deserialize_with = "flex::string_map",
178 skip_serializing_if = "BTreeMap::is_empty"
179 )]
180 #[schemars(with = "BTreeMap<String, flex::Scalar>")]
181 pub config: BTreeMap<String, String>,
182
183 #[serde(
185 default,
186 deserialize_with = "flex::bool",
187 skip_serializing_if = "std::ops::Not::not"
188 )]
189 #[schemars(with = "flex::BoolOrString")]
190 pub external: bool,
191}
192
193#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "kebab-case")]
196pub enum InstanceType {
197 #[default]
200 Container,
201 #[serde(alias = "vm")]
204 VirtualMachine,
205}
206
207impl JsonSchema for InstanceType {
211 fn schema_name() -> std::borrow::Cow<'static, str> {
212 "InstanceType".into()
213 }
214
215 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
216 schemars::json_schema!({
217 "description": "Instance type.",
218 "oneOf": [
219 {
220 "type": "string",
221 "const": "container",
222 "description": "A system container (lxc): shares the host kernel, near-zero overhead, idmapped bind mounts, proxies in both directions."
223 },
224 {
225 "type": "string",
226 "const": "virtual-machine",
227 "description": "A virtual machine (qemu): its own kernel. Needs a VM image and the incus agent in the guest for exec."
228 },
229 {
230 "type": "string",
231 "const": "vm",
232 "description": "Shorthand for virtual-machine."
233 }
234 ]
235 })
236 }
237}
238
239impl InstanceType {
240 pub fn as_api(&self) -> &'static str {
241 match self {
242 InstanceType::Container => "container",
243 InstanceType::VirtualMachine => "virtual-machine",
244 }
245 }
246}
247
248#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
250#[serde(deny_unknown_fields)]
251pub struct SandboxSpec {
252 #[serde(
255 default,
256 rename = "container_name",
257 skip_serializing_if = "Option::is_none"
258 )]
259 pub name: Option<String>,
260
261 #[serde(default)]
265 pub image: String,
266
267 #[serde(default, rename = "type", skip_serializing_if = "is_default")]
277 pub instance_type: InstanceType,
278
279 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub storage: Option<String>,
283
284 #[serde(
286 default,
287 deserialize_with = "flex::opt_string",
288 skip_serializing_if = "Option::is_none"
289 )]
290 #[schemars(with = "Option<flex::IntOrString>")]
291 pub cpus: Option<String>,
292
293 #[serde(
295 default,
296 deserialize_with = "flex::opt_string",
297 skip_serializing_if = "Option::is_none"
298 )]
299 #[schemars(with = "Option<flex::IntOrString>")]
300 pub cpuset: Option<String>,
301
302 #[serde(
305 default,
306 rename = "mem_limit",
307 deserialize_with = "flex::opt_string",
308 skip_serializing_if = "Option::is_none"
309 )]
310 #[schemars(with = "Option<flex::IntOrString>")]
311 pub memory: Option<String>,
312
313 #[serde(
316 default,
317 deserialize_with = "flex::opt_bool",
318 skip_serializing_if = "Option::is_none"
319 )]
320 #[schemars(with = "Option<flex::BoolOrString>")]
321 pub privileged: Option<bool>,
322
323 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub idmap: Option<IdmapSpec>,
326
327 #[serde(
329 default,
330 rename = "incus_profiles",
331 skip_serializing_if = "Option::is_none"
332 )]
333 pub profiles: Option<Vec<String>>,
334
335 #[serde(
339 default,
340 deserialize_with = "flex::string_map_or_list",
341 skip_serializing_if = "BTreeMap::is_empty"
342 )]
343 #[schemars(with = "flex::MapOrList")]
344 pub labels: BTreeMap<String, String>,
345
346 #[serde(
351 default,
352 rename = "environment",
353 skip_serializing_if = "Environment::is_empty"
354 )]
355 #[schemars(with = "flex::EnvMapOrList")]
356 pub env: Environment,
357
358 #[serde(default, skip_serializing_if = "Vec::is_empty")]
361 pub volumes: Vec<VolumeSpec>,
362
363 #[serde(default, skip_serializing_if = "Vec::is_empty")]
366 pub ports: Vec<PortSpec>,
367
368 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub ready: Option<Vec<ReadyCheck>>,
372
373 #[serde(
376 default,
377 deserialize_with = "flex::opt_string",
378 skip_serializing_if = "Option::is_none"
379 )]
380 #[schemars(with = "Option<flex::IntOrString>")]
381 pub ready_timeout: Option<String>,
382
383 #[serde(
386 default,
387 deserialize_with = "flex::opt_string",
388 skip_serializing_if = "Option::is_none"
389 )]
390 #[schemars(with = "Option<flex::IntOrString>")]
391 pub user: Option<String>,
392
393 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub working_dir: Option<String>,
396
397 #[serde(default, skip_serializing_if = "ExecSpec::is_empty")]
399 pub exec: ExecSpec,
400
401 #[serde(
407 default,
408 deserialize_with = "flex::opt_command",
409 skip_serializing_if = "Option::is_none"
410 )]
411 #[schemars(with = "Option<flex::Command>")]
412 pub command: Option<Vec<String>>,
413
414 #[serde(
418 default,
419 deserialize_with = "flex::opt_command",
420 skip_serializing_if = "Option::is_none"
421 )]
422 #[schemars(with = "Option<flex::Command>")]
423 pub entrypoint: Option<Vec<String>>,
424
425 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub restart: Option<RestartMode>,
432
433 #[serde(default, skip_serializing_if = "Option::is_none")]
437 pub healthcheck: Option<Healthcheck>,
438
439 #[serde(
442 default,
443 deserialize_with = "depends_on",
444 skip_serializing_if = "BTreeMap::is_empty"
445 )]
446 #[schemars(with = "DependsOnRepr")]
447 pub depends_on: BTreeMap<String, Dependency>,
448
449 #[serde(default, skip_serializing_if = "Option::is_none")]
451 pub deploy: Option<Deploy>,
452
453 #[serde(default, skip_serializing_if = "Vec::is_empty")]
457 pub domains: Vec<DomainSpec>,
458
459 #[serde(default, skip_serializing_if = "Vec::is_empty")]
462 pub secrets: Vec<SecretRef>,
463
464 #[serde(default, skip_serializing_if = "Option::is_none")]
472 pub egress: Option<crate::egress::EgressSpec>,
473
474 #[serde(
476 default,
477 deserialize_with = "flex::string_map",
478 skip_serializing_if = "BTreeMap::is_empty"
479 )]
480 #[schemars(with = "BTreeMap<String, flex::Scalar>")]
481 pub raw_config: BTreeMap<String, String>,
482
483 #[serde(
485 default,
486 deserialize_with = "flex::string_map_map",
487 skip_serializing_if = "BTreeMap::is_empty"
488 )]
489 #[schemars(with = "BTreeMap<String, BTreeMap<String, flex::Scalar>>")]
490 pub raw_devices: BTreeMap<String, BTreeMap<String, String>>,
491 #[serde(skip)]
493 pub workspace_nesting: bool,
494 #[serde(skip)]
497 pub stack_udp: bool,
498}
499
500impl SandboxSpec {
501 pub fn exec_defaults(&self) -> ExecDefaults {
503 ExecDefaults {
504 user: self.user.clone(),
505 cwd: self.working_dir.clone(),
506 env: self.exec.env.clone(),
507 login: self.exec.login,
508 }
509 }
510}
511
512fn is_default<T: Default + PartialEq>(v: &T) -> bool {
513 *v == T::default()
514}
515
516#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
518#[serde(deny_unknown_fields)]
519pub struct DomainSpec {
520 pub host: String,
524
525 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub path: Option<String>,
528
529 #[serde(default, skip_serializing_if = "Option::is_none")]
532 pub port: Option<u16>,
533
534 #[serde(
537 default,
538 deserialize_with = "flex::opt_bool",
539 skip_serializing_if = "Option::is_none"
540 )]
541 #[schemars(with = "Option<flex::BoolOrString>")]
542 pub https: Option<bool>,
543
544 #[serde(default, skip_serializing_if = "Option::is_none")]
548 pub redirect: Option<String>,
549
550 #[serde(
552 default,
553 deserialize_with = "flex::bool",
554 skip_serializing_if = "std::ops::Not::not"
555 )]
556 #[schemars(with = "flex::BoolOrString")]
557 pub strip_prefix: bool,
558
559 #[serde(
561 default,
562 deserialize_with = "flex::bool",
563 skip_serializing_if = "std::ops::Not::not"
564 )]
565 #[schemars(with = "flex::BoolOrString")]
566 pub www_redirect: bool,
567}
568
569#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
587#[serde(untagged)]
588pub enum IdmapSpec {
589 Mode(IdmapMode),
590 Map(IdmapMap),
591 Raw(IdmapRaw),
592}
593
594#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
595#[serde(rename_all = "snake_case")]
596pub enum IdmapMode {
597 #[default]
598 Auto,
599 None,
600 Always,
601}
602
603#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
604#[serde(deny_unknown_fields)]
605pub struct IdmapMap {
606 #[serde(default)]
607 pub mode: IdmapMode,
608 #[serde(default = "default_id")]
609 pub host_uid: u32,
610 #[serde(default = "default_id")]
611 pub host_gid: u32,
612 #[serde(default = "default_id")]
613 pub guest_uid: u32,
614 #[serde(default = "default_id")]
615 pub guest_gid: u32,
616}
617
618#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
619#[serde(deny_unknown_fields)]
620pub struct IdmapRaw {
621 pub raw: String,
623}
624
625fn default_id() -> u32 {
626 1000
627}
628
629#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
631#[serde(rename_all = "snake_case")]
632pub enum MountType {
633 #[default]
635 Bind,
636 Volume,
638}
639
640#[derive(Debug, Clone, Default, PartialEq, Serialize)]
643pub struct VolumeSpec {
644 #[serde(rename = "type")]
646 pub mount_type: MountType,
647 pub source: String,
649 pub target: String,
651 #[serde(skip_serializing_if = "std::ops::Not::not")]
652 pub read_only: bool,
653 #[serde(skip_serializing_if = "std::ops::Not::not")]
654 pub external: bool,
655 #[serde(skip_serializing_if = "Option::is_none")]
656 pub pool: Option<String>,
657 #[serde(skip_serializing_if = "Option::is_none")]
658 pub owner: Option<String>,
659 #[serde(skip_serializing_if = "Option::is_none")]
660 pub device: Option<String>,
661 #[serde(skip_serializing_if = "VolumeOptions::is_default")]
662 pub volume: VolumeOptions,
663 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
664 pub options: BTreeMap<String, String>,
665}
666
667#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
669#[serde(deny_unknown_fields)]
670pub struct VolumeOptions {
671 #[serde(
675 default,
676 deserialize_with = "flex::bool",
677 skip_serializing_if = "std::ops::Not::not"
678 )]
679 #[schemars(with = "flex::BoolOrString")]
680 pub nocopy: bool,
681}
682
683impl VolumeOptions {
684 fn is_default(&self) -> bool {
685 *self == Self::default()
686 }
687}
688
689#[derive(Deserialize, JsonSchema)]
691#[serde(deny_unknown_fields)]
692#[allow(dead_code)]
693pub(crate) struct VolumeMount {
694 #[serde(default, rename = "type")]
697 mount_type: Option<MountType>,
698
699 source: String,
703
704 target: String,
706
707 #[serde(default, deserialize_with = "flex::bool")]
709 #[schemars(with = "flex::BoolOrString")]
710 read_only: bool,
711
712 #[serde(default, deserialize_with = "flex::bool")]
714 #[schemars(with = "flex::BoolOrString")]
715 external: bool,
716
717 #[serde(default)]
720 pool: Option<String>,
721
722 #[serde(default, deserialize_with = "flex::opt_string")]
726 #[schemars(with = "Option<flex::IntOrString>")]
727 owner: Option<String>,
728
729 #[serde(default)]
732 device: Option<String>,
733
734 #[serde(default)]
736 volume: VolumeOptions,
737
738 #[serde(default, deserialize_with = "flex::string_map")]
740 #[schemars(with = "BTreeMap<String, flex::Scalar>")]
741 options: BTreeMap<String, String>,
742}
743
744pub(crate) fn is_host_path(source: &str) -> bool {
746 source.starts_with('/') || source.starts_with('.') || source.starts_with('~')
747}
748
749impl From<VolumeMount> for VolumeSpec {
750 fn from(m: VolumeMount) -> Self {
751 let mount_type = m.mount_type.unwrap_or(if is_host_path(&m.source) {
752 MountType::Bind
753 } else {
754 MountType::Volume
755 });
756 VolumeSpec {
757 mount_type,
758 source: m.source,
759 target: m.target,
760 read_only: m.read_only,
761 external: m.external,
762 pool: m.pool,
763 owner: m.owner,
764 device: m.device,
765 volume: m.volume,
766 options: m.options,
767 }
768 }
769}
770
771impl<'de> Deserialize<'de> for VolumeSpec {
772 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
773 use serde::de::Error as _;
774 match serde_json::Value::deserialize(d)? {
775 serde_json::Value::String(s) => {
776 crate::shorthand::volume(&s).map_err(|e| D::Error::custom(e.to_string()))
777 }
778 v @ serde_json::Value::Object(_) => serde_json::from_value::<VolumeMount>(v)
779 .map(Into::into)
780 .map_err(|e| D::Error::custom(format!("volume: {e}"))),
781 other => Err(D::Error::custom(format!(
782 "volume: expected SOURCE:TARGET[:OPTIONS] or {{type, source, target, ...}}, got {other}"
783 ))),
784 }
785 }
786}
787
788impl JsonSchema for VolumeSpec {
789 fn schema_name() -> std::borrow::Cow<'static, str> {
790 "VolumeSpec".into()
791 }
792
793 fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
794 let long = g.subschema_for::<VolumeMount>();
795 schemars::json_schema!({
796 "description": "A mount: `SOURCE:TARGET[:OPTIONS]` or the long form.",
797 "oneOf": [
798 {
799 "type": "string",
800 "description": "SOURCE:TARGET[:OPTIONS]. OPTIONS is a comma list of ro, rw, owner=USER, device=NAME, pool=POOL, external."
801 },
802 long
803 ]
804 })
805 }
806}
807
808#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
810#[serde(rename_all = "snake_case")]
811pub enum PortBind {
812 #[default]
814 Host,
815 Guest,
817}
818
819impl PortBind {
820 pub fn as_str(&self) -> &'static str {
821 match self {
822 PortBind::Host => "host",
823 PortBind::Guest => "guest",
824 }
825 }
826}
827
828#[derive(Debug, Clone, Default, PartialEq)]
831pub struct PortSpec {
832 pub name: Option<String>,
834 pub bind: PortBind,
835 pub listen: String,
838 pub connect: String,
840 pub search: Option<u16>,
844 pub options: BTreeMap<String, String>,
846}
847
848#[derive(Serialize, Deserialize, JsonSchema)]
850#[serde(deny_unknown_fields)]
851pub(crate) struct PortMapping {
852 #[serde(default, skip_serializing_if = "Option::is_none")]
854 name: Option<String>,
855
856 #[serde(deserialize_with = "flex::string", serialize_with = "port_number")]
858 #[schemars(with = "flex::IntOrString")]
859 target: String,
860
861 #[serde(deserialize_with = "flex::string", serialize_with = "port_number")]
864 #[schemars(with = "flex::IntOrString")]
865 published: String,
866
867 #[serde(default, skip_serializing_if = "Option::is_none")]
869 host_ip: Option<String>,
870
871 #[serde(default, skip_serializing_if = "Option::is_none")]
873 protocol: Option<String>,
874
875 #[serde(
877 default,
878 deserialize_with = "flex::string_map",
879 skip_serializing_if = "BTreeMap::is_empty"
880 )]
881 #[schemars(with = "BTreeMap<String, flex::Scalar>")]
882 options: BTreeMap<String, String>,
883}
884
885fn port_number<S: serde::Serializer>(p: &str, s: S) -> Result<S::Ok, S::Error> {
887 match p.parse::<u16>() {
888 Ok(n) => s.serialize_u16(n),
889 Err(_) => s.serialize_str(p),
890 }
891}
892
893#[derive(Serialize, Deserialize, JsonSchema)]
895#[serde(deny_unknown_fields)]
896pub(crate) struct ProxyPort {
897 #[serde(default, skip_serializing_if = "Option::is_none")]
899 name: Option<String>,
900
901 #[serde(default, skip_serializing_if = "is_default")]
904 bind: PortBind,
905
906 #[serde(deserialize_with = "flex::string")]
910 #[schemars(with = "flex::IntOrString")]
911 listen: String,
912
913 #[serde(deserialize_with = "flex::string")]
916 #[schemars(with = "flex::IntOrString")]
917 connect: String,
918
919 #[serde(
921 default,
922 deserialize_with = "flex::string_map",
923 skip_serializing_if = "BTreeMap::is_empty"
924 )]
925 #[schemars(with = "BTreeMap<String, flex::Scalar>")]
926 options: BTreeMap<String, String>,
927}
928
929impl PortMapping {
930 fn into_spec(self) -> crate::error::Result<PortSpec> {
931 let proto = self.protocol.as_deref().unwrap_or("tcp");
932 let mut p = crate::shorthand::docker_port(
933 self.host_ip.as_deref(),
934 &self.published,
935 &self.target,
936 proto,
937 )?;
938 p.name = self.name;
939 p.options = self.options;
940 Ok(p)
941 }
942}
943
944fn connect_port(connect: &str) -> Option<(&str, &str)> {
947 let (proto, rest) = match connect.split_once(':') {
948 Some((p @ ("tcp" | "udp"), rest)) => (p, rest),
949 _ => match connect.rsplit_once('/') {
950 Some((rest, p @ ("tcp" | "udp"))) => (p, rest),
951 _ => ("tcp", connect),
952 },
953 };
954 let port = match rest.rsplit_once(':') {
955 Some(("127.0.0.1" | "0.0.0.0", port)) => port,
956 Some(_) => return None,
957 None => rest,
958 };
959 port.parse::<u16>().ok().map(|_| (proto, port))
960}
961
962impl PortSpec {
963 fn as_mapping(&self) -> Option<PortMapping> {
967 if self.bind != PortBind::Host {
968 return None;
969 }
970 let listen = crate::plan::normalize_addr(&self.listen, "127.0.0.1").ok()?;
971 let (lproto, host, lport) = crate::plan::split_addr(&listen)?;
972 let (cproto, cport) = connect_port(&self.connect)?;
973 if lproto != cproto {
974 return None;
975 }
976 let published = match self.search.filter(|n| *n > 0) {
977 Some(n) => format!("{lport}-{}", lport.checked_add(n)?),
978 None => lport.to_string(),
979 };
980 Some(PortMapping {
981 name: self.name.clone(),
982 target: cport.to_string(),
983 published,
984 host_ip: (host != "127.0.0.1").then(|| host.trim_matches(['[', ']']).to_string()),
985 protocol: (lproto != "tcp").then(|| lproto.to_string()),
986 options: self.options.clone(),
987 })
988 }
989}
990
991impl Serialize for PortSpec {
992 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
993 if let Some(m) = self.as_mapping() {
994 return m.serialize(s);
995 }
996 ProxyPort {
997 name: self.name.clone(),
998 bind: self.bind,
999 listen: self.listen.clone(),
1000 connect: self.connect.clone(),
1001 options: self.options.clone(),
1002 }
1003 .serialize(s)
1004 }
1005}
1006
1007impl<'de> Deserialize<'de> for PortSpec {
1008 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1009 use serde::de::Error as _;
1010 let v = serde_json::Value::deserialize(d)?;
1011 let custom = |e: String| D::Error::custom(format!("port: {e}"));
1012 match v {
1013 serde_json::Value::String(s) => {
1014 crate::shorthand::docker_short_port(&s).map_err(|e| custom(e.to_string()))
1015 }
1016 serde_json::Value::Number(n) => crate::shorthand::docker_short_port(&n.to_string())
1017 .map_err(|e| custom(e.to_string())),
1018 serde_json::Value::Object(ref m) if m.contains_key("search") => Err(custom(
1019 "search is not an isb key: publish a range instead, e.g. \"5173-5223:5173\" or published: 5173-5223".into(),
1020 )),
1021 serde_json::Value::Object(ref m)
1022 if ["listen", "connect", "bind"].iter().any(|k| m.contains_key(*k)) =>
1023 {
1024 let r: ProxyPort = serde_json::from_value(v).map_err(|e| custom(e.to_string()))?;
1025 Ok(PortSpec {
1026 name: r.name,
1027 bind: r.bind,
1028 listen: r.listen,
1029 connect: r.connect,
1030 search: None,
1031 options: r.options,
1032 })
1033 }
1034 v @ serde_json::Value::Object(_) => serde_json::from_value::<PortMapping>(v)
1035 .map_err(|e| custom(e.to_string()))?
1036 .into_spec()
1037 .map_err(|e| custom(e.to_string())),
1038 other => Err(custom(format!(
1039 "expected [HOST_IP:]PUBLISHED:TARGET[/PROTOCOL], {{target, published, ...}} or {{listen, connect, ...}}, got {other}"
1040 ))),
1041 }
1042 }
1043}
1044
1045impl JsonSchema for PortSpec {
1046 fn schema_name() -> std::borrow::Cow<'static, str> {
1047 "PortSpec".into()
1048 }
1049
1050 fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
1051 let mapping = g.subschema_for::<PortMapping>();
1052 let proxy = g.subschema_for::<ProxyPort>();
1053 schemars::json_schema!({
1054 "description": "A published port, docker style, or an incus proxy in either direction.",
1055 "oneOf": [
1056 {
1057 "type": "string",
1058 "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."
1059 },
1060 mapping,
1061 proxy
1062 ]
1063 })
1064 }
1065}
1066
1067#[derive(Debug, Clone, PartialEq, JsonSchema)]
1070#[serde(rename_all = "snake_case")]
1071pub enum ReadyCheck {
1072 Running,
1074 Agent,
1076 DefaultRoute,
1078 UserExists(String),
1080 PathWritable(String),
1082 Command(Vec<String>),
1084}
1085
1086#[derive(Serialize, Deserialize)]
1089#[serde(untagged)]
1090enum ReadyRepr {
1091 Name(String),
1092 UserExists { user_exists: String },
1093 PathWritable { path_writable: String },
1094 Command { command: Vec<flex::Scalar> },
1095}
1096
1097impl Serialize for ReadyCheck {
1098 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1099 match self {
1100 ReadyCheck::Running => ReadyRepr::Name("running".into()),
1101 ReadyCheck::DefaultRoute => ReadyRepr::Name("default_route".into()),
1102 ReadyCheck::Agent => ReadyRepr::Name("agent".into()),
1103 ReadyCheck::UserExists(u) => ReadyRepr::UserExists {
1104 user_exists: u.clone(),
1105 },
1106 ReadyCheck::PathWritable(p) => ReadyRepr::PathWritable {
1107 path_writable: p.clone(),
1108 },
1109 ReadyCheck::Command(c) => ReadyRepr::Command {
1110 command: c.iter().cloned().map(flex::Scalar::String).collect(),
1111 },
1112 }
1113 .serialize(s)
1114 }
1115}
1116
1117impl<'de> Deserialize<'de> for ReadyCheck {
1118 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1119 use serde::de::Error as _;
1120 let r = ReadyRepr::deserialize(d).map_err(|_| {
1121 D::Error::custom(
1122 "expected running, agent, default_route, {user_exists: USER}, {path_writable: PATH} or {command: [ARGV...]}",
1123 )
1124 })?;
1125 Ok(match r {
1126 ReadyRepr::Name(n) => match n.as_str() {
1127 "running" => ReadyCheck::Running,
1128 "default_route" => ReadyCheck::DefaultRoute,
1129 "agent" => ReadyCheck::Agent,
1130 other => {
1131 return Err(D::Error::custom(format!(
1132 "unknown readiness check {other:?} (running, agent, default_route, user_exists, path_writable, command)"
1133 )));
1134 }
1135 },
1136 ReadyRepr::UserExists { user_exists } => ReadyCheck::UserExists(user_exists),
1137 ReadyRepr::PathWritable { path_writable } => ReadyCheck::PathWritable(path_writable),
1138 ReadyRepr::Command { command } => {
1139 ReadyCheck::Command(command.into_iter().map(flex::Scalar::into_string).collect())
1140 }
1141 })
1142 }
1143}
1144
1145impl std::fmt::Display for ReadyCheck {
1146 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1147 match self {
1148 ReadyCheck::Running => write!(f, "running"),
1149 ReadyCheck::DefaultRoute => write!(f, "default_route"),
1150 ReadyCheck::Agent => write!(f, "agent"),
1151 ReadyCheck::UserExists(u) => write!(f, "user_exists({u})"),
1152 ReadyCheck::PathWritable(p) => write!(f, "path_writable({p})"),
1153 ReadyCheck::Command(c) => write!(f, "command({})", c.join(" ")),
1154 }
1155 }
1156}
1157
1158#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1160#[serde(deny_unknown_fields)]
1161pub struct ExecSpec {
1162 #[serde(
1165 default,
1166 deserialize_with = "flex::env_map_or_list",
1167 skip_serializing_if = "BTreeMap::is_empty"
1168 )]
1169 #[schemars(with = "flex::MapOrList")]
1170 pub env: BTreeMap<String, String>,
1171
1172 #[serde(
1175 default,
1176 deserialize_with = "flex::bool",
1177 skip_serializing_if = "std::ops::Not::not"
1178 )]
1179 #[schemars(with = "flex::BoolOrString")]
1180 pub login: bool,
1181}
1182
1183impl ExecSpec {
1184 pub fn is_empty(&self) -> bool {
1185 self == &ExecSpec::default()
1186 }
1187}
1188
1189#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1191#[serde(deny_unknown_fields)]
1192pub struct ExecDefaults {
1193 #[serde(
1196 default,
1197 deserialize_with = "flex::opt_string",
1198 skip_serializing_if = "Option::is_none"
1199 )]
1200 #[schemars(with = "Option<flex::IntOrString>")]
1201 pub user: Option<String>,
1202
1203 #[serde(default, skip_serializing_if = "Option::is_none")]
1205 pub cwd: Option<String>,
1206
1207 #[serde(
1209 default,
1210 deserialize_with = "flex::string_map",
1211 skip_serializing_if = "BTreeMap::is_empty"
1212 )]
1213 #[schemars(with = "BTreeMap<String, flex::Scalar>")]
1214 pub env: BTreeMap<String, String>,
1215
1216 #[serde(
1219 default,
1220 deserialize_with = "flex::bool",
1221 skip_serializing_if = "std::ops::Not::not"
1222 )]
1223 #[schemars(with = "flex::BoolOrString")]
1224 pub login: bool,
1225}
1226
1227impl ExecDefaults {
1228 pub fn is_empty(&self) -> bool {
1229 self == &ExecDefaults::default()
1230 }
1231}
1232
1233#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, JsonSchema)]
1239#[serde(rename_all = "kebab-case")]
1240pub enum RestartMode {
1241 #[default]
1242 No,
1243 Always,
1244 OnFailure,
1245 UnlessStopped,
1246}
1247
1248impl<'de> Deserialize<'de> for RestartMode {
1250 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1251 use serde::de::Error as _;
1252 let s = flex::Scalar::deserialize(d)?.into_string();
1253 Ok(match s.as_str() {
1254 "no" | "false" | "" => RestartMode::No,
1255 "always" => RestartMode::Always,
1256 "on-failure" => RestartMode::OnFailure,
1257 "unless-stopped" => RestartMode::UnlessStopped,
1258 other => {
1259 return Err(D::Error::custom(format!(
1260 "unknown restart {other:?} (no, always, on-failure, unless-stopped)"
1261 )));
1262 }
1263 })
1264 }
1265}
1266
1267impl RestartMode {
1268 pub fn is_long_running(&self) -> bool {
1269 *self != RestartMode::No
1270 }
1271}
1272
1273#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1275#[serde(deny_unknown_fields)]
1276pub struct Healthcheck {
1277 #[serde(
1280 default,
1281 deserialize_with = "health_test",
1282 skip_serializing_if = "Vec::is_empty"
1283 )]
1284 #[schemars(with = "Option<flex::Command>")]
1285 pub test: Vec<String>,
1286
1287 #[serde(
1289 default,
1290 deserialize_with = "flex::opt_string",
1291 skip_serializing_if = "Option::is_none"
1292 )]
1293 #[schemars(with = "Option<flex::IntOrString>")]
1294 pub interval: Option<String>,
1295
1296 #[serde(
1298 default,
1299 deserialize_with = "flex::opt_string",
1300 skip_serializing_if = "Option::is_none"
1301 )]
1302 #[schemars(with = "Option<flex::IntOrString>")]
1303 pub timeout: Option<String>,
1304
1305 #[serde(default, skip_serializing_if = "Option::is_none")]
1307 pub retries: Option<u32>,
1308
1309 #[serde(
1311 default,
1312 deserialize_with = "flex::opt_string",
1313 skip_serializing_if = "Option::is_none"
1314 )]
1315 #[schemars(with = "Option<flex::IntOrString>")]
1316 pub start_period: Option<String>,
1317
1318 #[serde(
1320 default,
1321 deserialize_with = "flex::opt_string",
1322 skip_serializing_if = "Option::is_none"
1323 )]
1324 #[schemars(with = "Option<flex::IntOrString>")]
1325 pub start_interval: Option<String>,
1326
1327 #[serde(
1329 default,
1330 deserialize_with = "flex::bool",
1331 skip_serializing_if = "std::ops::Not::not"
1332 )]
1333 #[schemars(with = "flex::BoolOrString")]
1334 pub disable: bool,
1335}
1336
1337fn health_test<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
1338 match flex::Command::deserialize(d)? {
1339 flex::Command::String(s) => Ok(vec!["CMD-SHELL".into(), s]),
1340 flex::Command::Argv(v) => Ok(v.into_iter().map(flex::Scalar::into_string).collect()),
1341 }
1342}
1343
1344#[derive(Debug, Clone, PartialEq)]
1346pub struct HealthProbe {
1347 pub argv: Vec<String>,
1348 pub interval: std::time::Duration,
1349 pub timeout: std::time::Duration,
1350 pub retries: u32,
1351 pub start_period: std::time::Duration,
1352 pub start_interval: std::time::Duration,
1353}
1354
1355impl Healthcheck {
1356 pub fn probe(&self) -> Result<Option<HealthProbe>, String> {
1358 if self.disable {
1359 return Ok(None);
1360 }
1361 let argv = match self.test.split_first() {
1362 None => return Err("healthcheck needs a test".into()),
1363 Some((k, _)) if k == "NONE" => return Ok(None),
1364 Some((k, rest)) if k == "CMD" => rest.to_vec(),
1365 Some((k, rest)) if k == "CMD-SHELL" => {
1366 if rest.len() != 1 {
1367 return Err("CMD-SHELL takes exactly one shell line".into());
1368 }
1369 vec!["/bin/sh".into(), "-c".into(), rest[0].clone()]
1370 }
1371 Some((k, _)) => {
1372 return Err(format!(
1373 "healthcheck test must start with CMD, CMD-SHELL or NONE, not {k:?} (a plain string is a shell line)"
1374 ));
1375 }
1376 };
1377 if argv.is_empty() {
1378 return Err("healthcheck test has no command".into());
1379 }
1380 let dur = |v: &Option<String>, default: u64| -> Result<std::time::Duration, String> {
1381 match v {
1382 Some(s) => flex::parse_duration(s),
1383 None => Ok(std::time::Duration::from_secs(default)),
1384 }
1385 };
1386 Ok(Some(HealthProbe {
1387 argv,
1388 interval: dur(&self.interval, 30)?,
1389 timeout: dur(&self.timeout, 30)?,
1390 retries: self.retries.unwrap_or(3).max(1),
1391 start_period: dur(&self.start_period, 0)?,
1392 start_interval: dur(&self.start_interval, 5)?,
1393 }))
1394 }
1395}
1396
1397#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1399#[serde(rename_all = "snake_case")]
1400pub enum DependCondition {
1401 #[default]
1402 ServiceStarted,
1403 ServiceHealthy,
1404}
1405
1406#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1407#[serde(deny_unknown_fields)]
1408pub struct Dependency {
1409 #[serde(default)]
1410 pub condition: DependCondition,
1411}
1412
1413#[derive(Deserialize, JsonSchema)]
1414#[serde(untagged)]
1415#[allow(dead_code)]
1416enum DependsOnRepr {
1417 List(Vec<String>),
1418 Map(BTreeMap<String, Dependency>),
1419}
1420
1421fn depends_on<'de, D: serde::Deserializer<'de>>(
1422 d: D,
1423) -> Result<BTreeMap<String, Dependency>, D::Error> {
1424 Ok(match DependsOnRepr::deserialize(d)? {
1425 DependsOnRepr::List(l) => l.into_iter().map(|s| (s, Dependency::default())).collect(),
1426 DependsOnRepr::Map(m) => m,
1427 })
1428}
1429
1430#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1432#[serde(deny_unknown_fields)]
1433pub struct Deploy {
1434 #[serde(default, skip_serializing_if = "Option::is_none")]
1436 pub mode: Option<String>,
1437
1438 #[serde(default, skip_serializing_if = "Option::is_none")]
1440 pub replicas: Option<u32>,
1441
1442 #[serde(default, skip_serializing_if = "Option::is_none")]
1444 pub update_config: Option<UpdateConfig>,
1445
1446 #[serde(default, skip_serializing_if = "Option::is_none")]
1448 pub rollback_config: Option<UpdateConfig>,
1449
1450 #[serde(default, skip_serializing_if = "Option::is_none")]
1452 pub restart_policy: Option<RestartPolicy>,
1453
1454 #[serde(default, skip_serializing_if = "Option::is_none")]
1457 pub resources: Option<Resources>,
1458
1459 #[serde(
1461 default,
1462 deserialize_with = "flex::string_map_or_list",
1463 skip_serializing_if = "BTreeMap::is_empty"
1464 )]
1465 #[schemars(with = "flex::MapOrList")]
1466 pub labels: BTreeMap<String, String>,
1467}
1468
1469#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1470#[serde(rename_all = "kebab-case")]
1471pub enum UpdateOrder {
1472 #[default]
1475 StopFirst,
1476 StartFirst,
1479}
1480
1481#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1482#[serde(rename_all = "snake_case")]
1483pub enum FailureAction {
1484 #[default]
1486 Pause,
1487 Rollback,
1489 Continue,
1491}
1492
1493#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1494#[serde(deny_unknown_fields)]
1495pub struct UpdateConfig {
1496 #[serde(default, skip_serializing_if = "Option::is_none")]
1498 pub parallelism: Option<u32>,
1499
1500 #[serde(
1502 default,
1503 deserialize_with = "flex::opt_string",
1504 skip_serializing_if = "Option::is_none"
1505 )]
1506 #[schemars(with = "Option<flex::IntOrString>")]
1507 pub delay: Option<String>,
1508
1509 #[serde(default, skip_serializing_if = "Option::is_none")]
1511 pub failure_action: Option<FailureAction>,
1512
1513 #[serde(
1516 default,
1517 deserialize_with = "flex::opt_string",
1518 skip_serializing_if = "Option::is_none"
1519 )]
1520 #[schemars(with = "Option<flex::IntOrString>")]
1521 pub monitor: Option<String>,
1522
1523 #[serde(default, skip_serializing_if = "Option::is_none")]
1525 pub order: Option<UpdateOrder>,
1526}
1527
1528#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1529#[serde(rename_all = "kebab-case")]
1530pub enum RestartCondition {
1531 None,
1532 OnFailure,
1533 #[default]
1534 Any,
1535}
1536
1537#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1538#[serde(deny_unknown_fields)]
1539pub struct RestartPolicy {
1540 #[serde(default, skip_serializing_if = "Option::is_none")]
1542 pub condition: Option<RestartCondition>,
1543
1544 #[serde(
1546 default,
1547 deserialize_with = "flex::opt_string",
1548 skip_serializing_if = "Option::is_none"
1549 )]
1550 #[schemars(with = "Option<flex::IntOrString>")]
1551 pub delay: Option<String>,
1552
1553 #[serde(default, skip_serializing_if = "Option::is_none")]
1555 pub max_attempts: Option<u32>,
1556
1557 #[serde(
1559 default,
1560 deserialize_with = "flex::opt_string",
1561 skip_serializing_if = "Option::is_none"
1562 )]
1563 #[schemars(with = "Option<flex::IntOrString>")]
1564 pub window: Option<String>,
1565}
1566
1567#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1568#[serde(deny_unknown_fields)]
1569pub struct Resources {
1570 #[serde(default, skip_serializing_if = "Option::is_none")]
1571 pub limits: Option<ResourceLimits>,
1572}
1573
1574#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1575#[serde(deny_unknown_fields)]
1576pub struct ResourceLimits {
1577 #[serde(
1578 default,
1579 deserialize_with = "flex::opt_string",
1580 skip_serializing_if = "Option::is_none"
1581 )]
1582 #[schemars(with = "Option<flex::IntOrString>")]
1583 pub cpus: Option<String>,
1584
1585 #[serde(
1586 default,
1587 deserialize_with = "flex::opt_string",
1588 skip_serializing_if = "Option::is_none"
1589 )]
1590 #[schemars(with = "Option<flex::IntOrString>")]
1591 pub memory: Option<String>,
1592}
1593
1594#[derive(Debug, Clone, Default, PartialEq, Serialize, JsonSchema)]
1596#[serde(deny_unknown_fields)]
1597pub struct SecretRef {
1598 pub source: String,
1600 #[serde(default, skip_serializing_if = "Option::is_none")]
1602 pub target: Option<String>,
1603 #[serde(default, skip_serializing_if = "Option::is_none")]
1605 pub uid: Option<u32>,
1606 #[serde(default, skip_serializing_if = "Option::is_none")]
1607 pub gid: Option<u32>,
1608 #[serde(
1610 default,
1611 deserialize_with = "flex::opt_string",
1612 skip_serializing_if = "Option::is_none"
1613 )]
1614 #[schemars(with = "Option<flex::IntOrString>")]
1615 pub mode: Option<String>,
1616 #[serde(default, skip_serializing_if = "Option::is_none")]
1619 pub on_change: Option<OnChange>,
1620}
1621
1622#[derive(Deserialize)]
1623#[serde(untagged)]
1624enum SecretRefRepr {
1625 Name(String),
1626 Long {
1627 source: String,
1628 #[serde(default)]
1629 target: Option<String>,
1630 #[serde(default)]
1631 uid: Option<flex::Scalar>,
1632 #[serde(default)]
1633 gid: Option<flex::Scalar>,
1634 #[serde(default)]
1635 mode: Option<flex::Scalar>,
1636 #[serde(default)]
1637 on_change: Option<OnChange>,
1638 },
1639}
1640
1641impl<'de> Deserialize<'de> for SecretRef {
1642 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1643 use serde::de::Error as _;
1644 let id = |v: Option<flex::Scalar>, what: &str| -> Result<Option<u32>, D::Error> {
1645 v.map(|s| {
1646 let s = s.into_string();
1647 s.trim().parse().map_err(|_| {
1648 D::Error::custom(format!("secret {what} must be a number, got {s:?}"))
1649 })
1650 })
1651 .transpose()
1652 };
1653 match SecretRefRepr::deserialize(d).map_err(|_| {
1654 D::Error::custom(
1655 "expected a secret name or {source, target, uid, gid, mode, on_change}",
1656 )
1657 })? {
1658 SecretRefRepr::Name(source) => Ok(SecretRef {
1659 source,
1660 ..Default::default()
1661 }),
1662 SecretRefRepr::Long {
1663 source,
1664 target,
1665 uid,
1666 gid,
1667 mode,
1668 on_change,
1669 } => Ok(SecretRef {
1670 source,
1671 target,
1672 on_change,
1673 uid: id(uid, "uid")?,
1674 gid: id(gid, "gid")?,
1675 mode: mode.map(flex::Scalar::into_string),
1677 }),
1678 }
1679 }
1680}
1681
1682impl SecretRef {
1683 pub fn guest_path(&self) -> String {
1685 let t = self.target.as_deref().unwrap_or(&self.source);
1686 if t.starts_with('/') {
1687 t.to_string()
1688 } else {
1689 format!("/run/secrets/{t}")
1690 }
1691 }
1692
1693 pub fn file_mode(&self) -> Result<u32, String> {
1695 match &self.mode {
1696 None => Ok(0o400),
1697 Some(m) => u32::from_str_radix(m.trim().trim_start_matches("0o"), 8)
1698 .ok()
1699 .filter(|m| *m <= 0o7777)
1700 .ok_or_else(|| format!("secret mode {m:?} is not an octal mode like 0400")),
1701 }
1702 }
1703}
1704
1705impl SandboxSpec {
1706 pub fn secret_keys(&self) -> std::collections::BTreeSet<&str> {
1708 self.secrets
1709 .iter()
1710 .map(|r| r.source.as_str())
1711 .chain(self.env.secrets.values().map(String::as_str))
1712 .collect()
1713 }
1714
1715 pub fn secret_on_change(&self, key: &str) -> Option<OnChange> {
1718 self.secrets
1719 .iter()
1720 .filter(|r| r.source == key)
1721 .filter_map(|r| r.on_change)
1722 .chain(
1723 self.env
1724 .secrets
1725 .iter()
1726 .filter(|(_, k)| *k == key)
1727 .filter_map(|(var, _)| self.env.on_change.get(var).copied()),
1728 )
1729 .max()
1730 }
1731
1732 pub fn long_running(&self) -> bool {
1734 self.restart.is_some_and(|r| r.is_long_running())
1735 }
1736
1737 pub fn replicas(&self) -> u32 {
1739 self.deploy.as_ref().and_then(|d| d.replicas).unwrap_or(1)
1740 }
1741
1742 pub fn health_probe(&self) -> Result<Option<HealthProbe>, String> {
1744 match &self.healthcheck {
1745 None => Ok(None),
1746 Some(h) => h.probe(),
1747 }
1748 }
1749}
1750
1751mod builder;
1752pub use builder::{PortBinding, Volume};
1753
1754pub fn compose_schema() -> serde_json::Value {
1756 serde_json::to_value(schemars::schema_for!(ComposeFile)).expect("schema serializes")
1757}
1758
1759#[cfg(test)]
1760mod tests;