1use std::collections::BTreeMap;
7
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use crate::flex;
12
13#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
16#[serde(deny_unknown_fields)]
17pub struct ComposeFile {
18 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub name: Option<String>,
23
24 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub incus_project: Option<String>,
27
28 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
31 pub volumes: BTreeMap<String, NamedVolumeSpec>,
32
33 #[serde(default)]
35 pub services: BTreeMap<String, SandboxSpec>,
36
37 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
40 pub secrets: BTreeMap<String, SecretDef>,
41}
42
43#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
45#[serde(deny_unknown_fields)]
46pub struct SecretDef {
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub file: Option<String>,
50
51 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub environment: Option<String>,
55
56 #[serde(
59 default,
60 deserialize_with = "flex::bool",
61 skip_serializing_if = "std::ops::Not::not"
62 )]
63 #[schemars(with = "flex::BoolOrString")]
64 pub external: bool,
65
66 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub name: Option<String>,
70
71 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub age: Option<String>,
75
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub driver: Option<String>,
79
80 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub refresh: Option<String>,
85}
86
87impl SecretDef {
88 pub fn validate(&self) -> std::result::Result<(), String> {
91 let sources = [
92 self.file.is_some(),
93 self.environment.is_some(),
94 self.external,
95 self.age.is_some(),
96 self.driver.is_some(),
97 ];
98 if sources.iter().filter(|s| **s).count() != 1 {
99 return Err(
100 "needs exactly one of file, environment, external, age, or driver (with name)"
101 .into(),
102 );
103 }
104 if self.name.is_some() && !self.external && self.driver.is_none() {
105 return Err("name goes with external or driver".into());
106 }
107 if self.driver.is_some() && self.name.as_deref().is_none_or(str::is_empty) {
108 return Err("driver needs name: the driver's reference to the secret".into());
109 }
110 if self.external {
111 if let Some(n) = &self.name {
112 crate::secrets::validate_name(n).map_err(|e| e.to_string())?;
113 }
114 }
115 if self.age.as_deref().is_some_and(|a| a.trim().is_empty()) {
116 return Err("age is empty".into());
117 }
118 if let Some(r) = &self.refresh {
119 if self.driver.is_none() {
120 return Err("refresh goes with driver".into());
121 }
122 let d = flex::parse_duration(r).map_err(|e| format!("refresh: {e}"))?;
123 if d < std::time::Duration::from_secs(10) {
124 return Err(format!("refresh {r:?}: at least 10s"));
125 }
126 }
127 Ok(())
128 }
129
130 pub fn store_name<'a>(&'a self, key: &'a str) -> Option<&'a str> {
132 self.external.then(|| self.name.as_deref().unwrap_or(key))
133 }
134
135 pub fn refresh_interval(&self) -> std::time::Duration {
137 self.refresh
138 .as_deref()
139 .and_then(|r| flex::parse_duration(r).ok())
140 .unwrap_or(DEFAULT_SECRET_REFRESH)
141 }
142
143 pub fn is_client_side(&self) -> bool {
146 self.file.is_some() || self.environment.is_some()
147 }
148
149 pub fn source_kind(&self) -> &'static str {
151 if self.file.is_some() {
152 "file"
153 } else if self.environment.is_some() {
154 "environment"
155 } else if self.external {
156 "external"
157 } else if self.age.is_some() {
158 "age"
159 } else if self.driver.is_some() {
160 "driver"
161 } else {
162 "none"
163 }
164 }
165}
166
167pub const DEFAULT_SECRET_REFRESH: std::time::Duration = std::time::Duration::from_secs(3600);
169
170#[derive(Debug, Clone, Default, PartialEq)]
173pub struct Environment {
174 pub vars: BTreeMap<String, String>,
176 pub secrets: BTreeMap<String, String>,
178}
179
180impl Environment {
181 pub fn is_empty(&self) -> bool {
182 self.vars.is_empty() && self.secrets.is_empty()
183 }
184}
185
186impl std::ops::Deref for Environment {
188 type Target = BTreeMap<String, String>;
189 fn deref(&self) -> &Self::Target {
190 &self.vars
191 }
192}
193
194impl std::ops::DerefMut for Environment {
195 fn deref_mut(&mut self) -> &mut Self::Target {
196 &mut self.vars
197 }
198}
199
200impl<'a> IntoIterator for &'a Environment {
201 type Item = (&'a String, &'a String);
202 type IntoIter = std::collections::btree_map::Iter<'a, String, String>;
203 fn into_iter(self) -> Self::IntoIter {
204 self.vars.iter()
205 }
206}
207
208impl From<BTreeMap<String, String>> for Environment {
209 fn from(vars: BTreeMap<String, String>) -> Self {
210 Environment {
211 vars,
212 secrets: BTreeMap::new(),
213 }
214 }
215}
216
217impl Serialize for Environment {
218 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
219 use serde::ser::SerializeMap;
220 let mut m = s.serialize_map(None)?;
221 let mut keys: Vec<&String> = self.vars.keys().chain(self.secrets.keys()).collect();
222 keys.sort();
223 keys.dedup();
224 for k in keys {
225 match (self.vars.get(k), self.secrets.get(k)) {
226 (Some(v), _) => m.serialize_entry(k, v)?,
227 (None, Some(sec)) => {
228 m.serialize_entry(k, &BTreeMap::from([("secret", sec.as_str())]))?
229 }
230 (None, None) => {}
231 }
232 }
233 m.end()
234 }
235}
236
237impl<'de> Deserialize<'de> for Environment {
238 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
239 use serde::de::Error as _;
240 let mut env = Environment::default();
241 match flex::EnvMapOrList::deserialize(d)? {
242 flex::EnvMapOrList::Map(m) => {
243 for (k, v) in m {
244 match v {
245 flex::EnvValue::Scalar(v) => {
246 env.vars.insert(k, v.into_string());
247 }
248 flex::EnvValue::Secret { secret } if secret.is_empty() => {
249 return Err(D::Error::custom(format!(
250 "environment {k}: secret needs a top-level secret's name"
251 )));
252 }
253 flex::EnvValue::Secret { secret } => {
254 env.secrets.insert(k, secret);
255 }
256 }
257 }
258 }
259 flex::EnvMapOrList::List(l) => {
260 for item in l {
261 let Some((k, v)) = item.split_once('=') else {
262 return Err(D::Error::custom(format!(
263 "environment entry {item:?} has no value: write {item}=VALUE"
264 )));
265 };
266 env.vars.insert(k.to_string(), v.to_string());
267 }
268 }
269 }
270 Ok(env)
271 }
272}
273
274#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
276#[serde(deny_unknown_fields)]
277pub struct NamedVolumeSpec {
278 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub name: Option<String>,
282
283 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub pool: Option<String>,
287
288 #[serde(
290 default,
291 deserialize_with = "flex::string_map",
292 skip_serializing_if = "BTreeMap::is_empty"
293 )]
294 #[schemars(with = "BTreeMap<String, flex::Scalar>")]
295 pub config: BTreeMap<String, String>,
296
297 #[serde(
299 default,
300 deserialize_with = "flex::bool",
301 skip_serializing_if = "std::ops::Not::not"
302 )]
303 #[schemars(with = "flex::BoolOrString")]
304 pub external: bool,
305}
306
307#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
309#[serde(rename_all = "kebab-case")]
310pub enum InstanceType {
311 #[default]
314 Container,
315 #[serde(alias = "vm")]
318 VirtualMachine,
319}
320
321impl JsonSchema for InstanceType {
325 fn schema_name() -> std::borrow::Cow<'static, str> {
326 "InstanceType".into()
327 }
328
329 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
330 schemars::json_schema!({
331 "description": "Instance type.",
332 "oneOf": [
333 {
334 "type": "string",
335 "const": "container",
336 "description": "A system container (lxc): shares the host kernel, near-zero overhead, idmapped bind mounts, proxies in both directions."
337 },
338 {
339 "type": "string",
340 "const": "virtual-machine",
341 "description": "A virtual machine (qemu): its own kernel. Needs a VM image and the incus agent in the guest for exec."
342 },
343 {
344 "type": "string",
345 "const": "vm",
346 "description": "Shorthand for virtual-machine."
347 }
348 ]
349 })
350 }
351}
352
353impl InstanceType {
354 pub fn as_api(&self) -> &'static str {
355 match self {
356 InstanceType::Container => "container",
357 InstanceType::VirtualMachine => "virtual-machine",
358 }
359 }
360}
361
362#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
364#[serde(deny_unknown_fields)]
365pub struct SandboxSpec {
366 #[serde(
369 default,
370 rename = "container_name",
371 skip_serializing_if = "Option::is_none"
372 )]
373 pub name: Option<String>,
374
375 #[serde(default)]
379 pub image: String,
380
381 #[serde(default, rename = "type", skip_serializing_if = "is_default")]
391 pub instance_type: InstanceType,
392
393 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub storage: Option<String>,
397
398 #[serde(
400 default,
401 deserialize_with = "flex::opt_string",
402 skip_serializing_if = "Option::is_none"
403 )]
404 #[schemars(with = "Option<flex::IntOrString>")]
405 pub cpus: Option<String>,
406
407 #[serde(
409 default,
410 deserialize_with = "flex::opt_string",
411 skip_serializing_if = "Option::is_none"
412 )]
413 #[schemars(with = "Option<flex::IntOrString>")]
414 pub cpuset: Option<String>,
415
416 #[serde(
419 default,
420 rename = "mem_limit",
421 deserialize_with = "flex::opt_string",
422 skip_serializing_if = "Option::is_none"
423 )]
424 #[schemars(with = "Option<flex::IntOrString>")]
425 pub memory: Option<String>,
426
427 #[serde(
430 default,
431 deserialize_with = "flex::opt_bool",
432 skip_serializing_if = "Option::is_none"
433 )]
434 #[schemars(with = "Option<flex::BoolOrString>")]
435 pub privileged: Option<bool>,
436
437 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub idmap: Option<IdmapSpec>,
440
441 #[serde(
443 default,
444 rename = "incus_profiles",
445 skip_serializing_if = "Option::is_none"
446 )]
447 pub profiles: Option<Vec<String>>,
448
449 #[serde(
453 default,
454 deserialize_with = "flex::string_map_or_list",
455 skip_serializing_if = "BTreeMap::is_empty"
456 )]
457 #[schemars(with = "flex::MapOrList")]
458 pub labels: BTreeMap<String, String>,
459
460 #[serde(
465 default,
466 rename = "environment",
467 skip_serializing_if = "Environment::is_empty"
468 )]
469 #[schemars(with = "flex::EnvMapOrList")]
470 pub env: Environment,
471
472 #[serde(default, skip_serializing_if = "Vec::is_empty")]
475 pub volumes: Vec<VolumeSpec>,
476
477 #[serde(default, skip_serializing_if = "Vec::is_empty")]
480 pub ports: Vec<PortSpec>,
481
482 #[serde(default, skip_serializing_if = "Option::is_none")]
485 pub ready: Option<Vec<ReadyCheck>>,
486
487 #[serde(
490 default,
491 deserialize_with = "flex::opt_string",
492 skip_serializing_if = "Option::is_none"
493 )]
494 #[schemars(with = "Option<flex::IntOrString>")]
495 pub ready_timeout: Option<String>,
496
497 #[serde(
500 default,
501 deserialize_with = "flex::opt_string",
502 skip_serializing_if = "Option::is_none"
503 )]
504 #[schemars(with = "Option<flex::IntOrString>")]
505 pub user: Option<String>,
506
507 #[serde(default, skip_serializing_if = "Option::is_none")]
509 pub working_dir: Option<String>,
510
511 #[serde(default, skip_serializing_if = "ExecSpec::is_empty")]
513 pub exec: ExecSpec,
514
515 #[serde(
521 default,
522 deserialize_with = "flex::opt_command",
523 skip_serializing_if = "Option::is_none"
524 )]
525 #[schemars(with = "Option<flex::Command>")]
526 pub command: Option<Vec<String>>,
527
528 #[serde(
532 default,
533 deserialize_with = "flex::opt_command",
534 skip_serializing_if = "Option::is_none"
535 )]
536 #[schemars(with = "Option<flex::Command>")]
537 pub entrypoint: Option<Vec<String>>,
538
539 #[serde(default, skip_serializing_if = "Option::is_none")]
545 pub restart: Option<RestartMode>,
546
547 #[serde(default, skip_serializing_if = "Option::is_none")]
551 pub healthcheck: Option<Healthcheck>,
552
553 #[serde(
556 default,
557 deserialize_with = "depends_on",
558 skip_serializing_if = "BTreeMap::is_empty"
559 )]
560 #[schemars(with = "DependsOnRepr")]
561 pub depends_on: BTreeMap<String, Dependency>,
562
563 #[serde(default, skip_serializing_if = "Option::is_none")]
565 pub deploy: Option<Deploy>,
566
567 #[serde(default, skip_serializing_if = "Vec::is_empty")]
571 pub domains: Vec<DomainSpec>,
572
573 #[serde(default, skip_serializing_if = "Vec::is_empty")]
576 pub secrets: Vec<SecretRef>,
577
578 #[serde(default, skip_serializing_if = "Option::is_none")]
586 pub egress: Option<crate::egress::EgressSpec>,
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 pub raw_config: BTreeMap<String, String>,
596
597 #[serde(
599 default,
600 deserialize_with = "flex::string_map_map",
601 skip_serializing_if = "BTreeMap::is_empty"
602 )]
603 #[schemars(with = "BTreeMap<String, BTreeMap<String, flex::Scalar>>")]
604 pub raw_devices: BTreeMap<String, BTreeMap<String, String>>,
605 #[serde(skip)]
607 pub workspace_nesting: bool,
608 #[serde(skip)]
611 pub stack_udp: bool,
612}
613
614impl SandboxSpec {
615 pub fn exec_defaults(&self) -> ExecDefaults {
617 ExecDefaults {
618 user: self.user.clone(),
619 cwd: self.working_dir.clone(),
620 env: self.exec.env.clone(),
621 login: self.exec.login,
622 }
623 }
624}
625
626fn is_default<T: Default + PartialEq>(v: &T) -> bool {
627 *v == T::default()
628}
629
630#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
632#[serde(deny_unknown_fields)]
633pub struct DomainSpec {
634 pub host: String,
638
639 #[serde(default, skip_serializing_if = "Option::is_none")]
641 pub path: Option<String>,
642
643 #[serde(default, skip_serializing_if = "Option::is_none")]
646 pub port: Option<u16>,
647
648 #[serde(
651 default,
652 deserialize_with = "flex::opt_bool",
653 skip_serializing_if = "Option::is_none"
654 )]
655 #[schemars(with = "Option<flex::BoolOrString>")]
656 pub https: Option<bool>,
657
658 #[serde(default, skip_serializing_if = "Option::is_none")]
662 pub redirect: Option<String>,
663
664 #[serde(
666 default,
667 deserialize_with = "flex::bool",
668 skip_serializing_if = "std::ops::Not::not"
669 )]
670 #[schemars(with = "flex::BoolOrString")]
671 pub strip_prefix: bool,
672
673 #[serde(
675 default,
676 deserialize_with = "flex::bool",
677 skip_serializing_if = "std::ops::Not::not"
678 )]
679 #[schemars(with = "flex::BoolOrString")]
680 pub www_redirect: bool,
681}
682
683#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
701#[serde(untagged)]
702pub enum IdmapSpec {
703 Mode(IdmapMode),
704 Map(IdmapMap),
705 Raw(IdmapRaw),
706}
707
708#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
709#[serde(rename_all = "snake_case")]
710pub enum IdmapMode {
711 #[default]
712 Auto,
713 None,
714 Always,
715}
716
717#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
718#[serde(deny_unknown_fields)]
719pub struct IdmapMap {
720 #[serde(default)]
721 pub mode: IdmapMode,
722 #[serde(default = "default_id")]
723 pub host_uid: u32,
724 #[serde(default = "default_id")]
725 pub host_gid: u32,
726 #[serde(default = "default_id")]
727 pub guest_uid: u32,
728 #[serde(default = "default_id")]
729 pub guest_gid: u32,
730}
731
732#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
733#[serde(deny_unknown_fields)]
734pub struct IdmapRaw {
735 pub raw: String,
737}
738
739fn default_id() -> u32 {
740 1000
741}
742
743#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
745#[serde(rename_all = "snake_case")]
746pub enum MountType {
747 #[default]
749 Bind,
750 Volume,
752}
753
754#[derive(Debug, Clone, Default, PartialEq, Serialize)]
757pub struct VolumeSpec {
758 #[serde(rename = "type")]
760 pub mount_type: MountType,
761 pub source: String,
763 pub target: String,
765 #[serde(skip_serializing_if = "std::ops::Not::not")]
766 pub read_only: bool,
767 #[serde(skip_serializing_if = "std::ops::Not::not")]
768 pub external: bool,
769 #[serde(skip_serializing_if = "Option::is_none")]
770 pub pool: Option<String>,
771 #[serde(skip_serializing_if = "Option::is_none")]
772 pub owner: Option<String>,
773 #[serde(skip_serializing_if = "Option::is_none")]
774 pub device: Option<String>,
775 #[serde(skip_serializing_if = "VolumeOptions::is_default")]
776 pub volume: VolumeOptions,
777 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
778 pub options: BTreeMap<String, String>,
779}
780
781#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
783#[serde(deny_unknown_fields)]
784pub struct VolumeOptions {
785 #[serde(
789 default,
790 deserialize_with = "flex::bool",
791 skip_serializing_if = "std::ops::Not::not"
792 )]
793 #[schemars(with = "flex::BoolOrString")]
794 pub nocopy: bool,
795}
796
797impl VolumeOptions {
798 fn is_default(&self) -> bool {
799 *self == Self::default()
800 }
801}
802
803#[derive(Deserialize, JsonSchema)]
805#[serde(deny_unknown_fields)]
806#[allow(dead_code)]
807pub(crate) struct VolumeMount {
808 #[serde(default, rename = "type")]
811 mount_type: Option<MountType>,
812
813 source: String,
817
818 target: String,
820
821 #[serde(default, deserialize_with = "flex::bool")]
823 #[schemars(with = "flex::BoolOrString")]
824 read_only: bool,
825
826 #[serde(default, deserialize_with = "flex::bool")]
828 #[schemars(with = "flex::BoolOrString")]
829 external: bool,
830
831 #[serde(default)]
834 pool: Option<String>,
835
836 #[serde(default, deserialize_with = "flex::opt_string")]
840 #[schemars(with = "Option<flex::IntOrString>")]
841 owner: Option<String>,
842
843 #[serde(default)]
846 device: Option<String>,
847
848 #[serde(default)]
850 volume: VolumeOptions,
851
852 #[serde(default, deserialize_with = "flex::string_map")]
854 #[schemars(with = "BTreeMap<String, flex::Scalar>")]
855 options: BTreeMap<String, String>,
856}
857
858pub(crate) fn is_host_path(source: &str) -> bool {
860 source.starts_with('/') || source.starts_with('.') || source.starts_with('~')
861}
862
863impl From<VolumeMount> for VolumeSpec {
864 fn from(m: VolumeMount) -> Self {
865 let mount_type = m.mount_type.unwrap_or(if is_host_path(&m.source) {
866 MountType::Bind
867 } else {
868 MountType::Volume
869 });
870 VolumeSpec {
871 mount_type,
872 source: m.source,
873 target: m.target,
874 read_only: m.read_only,
875 external: m.external,
876 pool: m.pool,
877 owner: m.owner,
878 device: m.device,
879 volume: m.volume,
880 options: m.options,
881 }
882 }
883}
884
885impl<'de> Deserialize<'de> for VolumeSpec {
886 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
887 use serde::de::Error as _;
888 match serde_json::Value::deserialize(d)? {
889 serde_json::Value::String(s) => {
890 crate::shorthand::volume(&s).map_err(|e| D::Error::custom(e.to_string()))
891 }
892 v @ serde_json::Value::Object(_) => serde_json::from_value::<VolumeMount>(v)
893 .map(Into::into)
894 .map_err(|e| D::Error::custom(format!("volume: {e}"))),
895 other => Err(D::Error::custom(format!(
896 "volume: expected SOURCE:TARGET[:OPTIONS] or {{type, source, target, ...}}, got {other}"
897 ))),
898 }
899 }
900}
901
902impl JsonSchema for VolumeSpec {
903 fn schema_name() -> std::borrow::Cow<'static, str> {
904 "VolumeSpec".into()
905 }
906
907 fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
908 let long = g.subschema_for::<VolumeMount>();
909 schemars::json_schema!({
910 "description": "A mount: `SOURCE:TARGET[:OPTIONS]` or the long form.",
911 "oneOf": [
912 {
913 "type": "string",
914 "description": "SOURCE:TARGET[:OPTIONS]. OPTIONS is a comma list of ro, rw, owner=USER, device=NAME, pool=POOL, external."
915 },
916 long
917 ]
918 })
919 }
920}
921
922#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
924#[serde(rename_all = "snake_case")]
925pub enum PortBind {
926 #[default]
928 Host,
929 Guest,
931}
932
933impl PortBind {
934 pub fn as_str(&self) -> &'static str {
935 match self {
936 PortBind::Host => "host",
937 PortBind::Guest => "guest",
938 }
939 }
940}
941
942#[derive(Debug, Clone, Default, PartialEq)]
945pub struct PortSpec {
946 pub name: Option<String>,
948 pub bind: PortBind,
949 pub listen: String,
952 pub connect: String,
954 pub search: Option<u16>,
958 pub options: BTreeMap<String, String>,
960}
961
962#[derive(Serialize, Deserialize, JsonSchema)]
964#[serde(deny_unknown_fields)]
965pub(crate) struct PortMapping {
966 #[serde(default, skip_serializing_if = "Option::is_none")]
968 name: Option<String>,
969
970 #[serde(deserialize_with = "flex::string", serialize_with = "port_number")]
972 #[schemars(with = "flex::IntOrString")]
973 target: String,
974
975 #[serde(deserialize_with = "flex::string", serialize_with = "port_number")]
978 #[schemars(with = "flex::IntOrString")]
979 published: String,
980
981 #[serde(default, skip_serializing_if = "Option::is_none")]
983 host_ip: Option<String>,
984
985 #[serde(default, skip_serializing_if = "Option::is_none")]
987 protocol: Option<String>,
988
989 #[serde(
991 default,
992 deserialize_with = "flex::string_map",
993 skip_serializing_if = "BTreeMap::is_empty"
994 )]
995 #[schemars(with = "BTreeMap<String, flex::Scalar>")]
996 options: BTreeMap<String, String>,
997}
998
999fn port_number<S: serde::Serializer>(p: &str, s: S) -> Result<S::Ok, S::Error> {
1001 match p.parse::<u16>() {
1002 Ok(n) => s.serialize_u16(n),
1003 Err(_) => s.serialize_str(p),
1004 }
1005}
1006
1007#[derive(Serialize, Deserialize, JsonSchema)]
1009#[serde(deny_unknown_fields)]
1010pub(crate) struct ProxyPort {
1011 #[serde(default, skip_serializing_if = "Option::is_none")]
1013 name: Option<String>,
1014
1015 #[serde(default, skip_serializing_if = "is_default")]
1018 bind: PortBind,
1019
1020 #[serde(deserialize_with = "flex::string")]
1024 #[schemars(with = "flex::IntOrString")]
1025 listen: String,
1026
1027 #[serde(deserialize_with = "flex::string")]
1030 #[schemars(with = "flex::IntOrString")]
1031 connect: String,
1032
1033 #[serde(
1035 default,
1036 deserialize_with = "flex::string_map",
1037 skip_serializing_if = "BTreeMap::is_empty"
1038 )]
1039 #[schemars(with = "BTreeMap<String, flex::Scalar>")]
1040 options: BTreeMap<String, String>,
1041}
1042
1043impl PortMapping {
1044 fn into_spec(self) -> crate::error::Result<PortSpec> {
1045 let proto = self.protocol.as_deref().unwrap_or("tcp");
1046 let mut p = crate::shorthand::docker_port(
1047 self.host_ip.as_deref(),
1048 &self.published,
1049 &self.target,
1050 proto,
1051 )?;
1052 p.name = self.name;
1053 p.options = self.options;
1054 Ok(p)
1055 }
1056}
1057
1058fn connect_port(connect: &str) -> Option<(&str, &str)> {
1061 let (proto, rest) = match connect.split_once(':') {
1062 Some((p @ ("tcp" | "udp"), rest)) => (p, rest),
1063 _ => match connect.rsplit_once('/') {
1064 Some((rest, p @ ("tcp" | "udp"))) => (p, rest),
1065 _ => ("tcp", connect),
1066 },
1067 };
1068 let port = match rest.rsplit_once(':') {
1069 Some(("127.0.0.1" | "0.0.0.0", port)) => port,
1070 Some(_) => return None,
1071 None => rest,
1072 };
1073 port.parse::<u16>().ok().map(|_| (proto, port))
1074}
1075
1076impl PortSpec {
1077 fn as_mapping(&self) -> Option<PortMapping> {
1081 if self.bind != PortBind::Host {
1082 return None;
1083 }
1084 let listen = crate::plan::normalize_addr(&self.listen, "127.0.0.1").ok()?;
1085 let (lproto, host, lport) = crate::plan::split_addr(&listen)?;
1086 let (cproto, cport) = connect_port(&self.connect)?;
1087 if lproto != cproto {
1088 return None;
1089 }
1090 let published = match self.search.filter(|n| *n > 0) {
1091 Some(n) => format!("{lport}-{}", lport.checked_add(n)?),
1092 None => lport.to_string(),
1093 };
1094 Some(PortMapping {
1095 name: self.name.clone(),
1096 target: cport.to_string(),
1097 published,
1098 host_ip: (host != "127.0.0.1").then(|| host.trim_matches(['[', ']']).to_string()),
1099 protocol: (lproto != "tcp").then(|| lproto.to_string()),
1100 options: self.options.clone(),
1101 })
1102 }
1103}
1104
1105impl Serialize for PortSpec {
1106 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1107 if let Some(m) = self.as_mapping() {
1108 return m.serialize(s);
1109 }
1110 ProxyPort {
1111 name: self.name.clone(),
1112 bind: self.bind,
1113 listen: self.listen.clone(),
1114 connect: self.connect.clone(),
1115 options: self.options.clone(),
1116 }
1117 .serialize(s)
1118 }
1119}
1120
1121impl<'de> Deserialize<'de> for PortSpec {
1122 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1123 use serde::de::Error as _;
1124 let v = serde_json::Value::deserialize(d)?;
1125 let custom = |e: String| D::Error::custom(format!("port: {e}"));
1126 match v {
1127 serde_json::Value::String(s) => {
1128 crate::shorthand::docker_short_port(&s).map_err(|e| custom(e.to_string()))
1129 }
1130 serde_json::Value::Number(n) => crate::shorthand::docker_short_port(&n.to_string())
1131 .map_err(|e| custom(e.to_string())),
1132 serde_json::Value::Object(ref m) if m.contains_key("search") => Err(custom(
1133 "search is not an isb key: publish a range instead, e.g. \"5173-5223:5173\" or published: 5173-5223".into(),
1134 )),
1135 serde_json::Value::Object(ref m)
1136 if ["listen", "connect", "bind"].iter().any(|k| m.contains_key(*k)) =>
1137 {
1138 let r: ProxyPort = serde_json::from_value(v).map_err(|e| custom(e.to_string()))?;
1139 Ok(PortSpec {
1140 name: r.name,
1141 bind: r.bind,
1142 listen: r.listen,
1143 connect: r.connect,
1144 search: None,
1145 options: r.options,
1146 })
1147 }
1148 v @ serde_json::Value::Object(_) => serde_json::from_value::<PortMapping>(v)
1149 .map_err(|e| custom(e.to_string()))?
1150 .into_spec()
1151 .map_err(|e| custom(e.to_string())),
1152 other => Err(custom(format!(
1153 "expected [HOST_IP:]PUBLISHED:TARGET[/PROTOCOL], {{target, published, ...}} or {{listen, connect, ...}}, got {other}"
1154 ))),
1155 }
1156 }
1157}
1158
1159impl JsonSchema for PortSpec {
1160 fn schema_name() -> std::borrow::Cow<'static, str> {
1161 "PortSpec".into()
1162 }
1163
1164 fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
1165 let mapping = g.subschema_for::<PortMapping>();
1166 let proxy = g.subschema_for::<ProxyPort>();
1167 schemars::json_schema!({
1168 "description": "A published port, docker style, or an incus proxy in either direction.",
1169 "oneOf": [
1170 {
1171 "type": "string",
1172 "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."
1173 },
1174 mapping,
1175 proxy
1176 ]
1177 })
1178 }
1179}
1180
1181#[derive(Debug, Clone, PartialEq, JsonSchema)]
1184#[serde(rename_all = "snake_case")]
1185pub enum ReadyCheck {
1186 Running,
1188 Agent,
1190 DefaultRoute,
1192 UserExists(String),
1194 PathWritable(String),
1196 Command(Vec<String>),
1198}
1199
1200#[derive(Serialize, Deserialize)]
1203#[serde(untagged)]
1204enum ReadyRepr {
1205 Name(String),
1206 UserExists { user_exists: String },
1207 PathWritable { path_writable: String },
1208 Command { command: Vec<flex::Scalar> },
1209}
1210
1211impl Serialize for ReadyCheck {
1212 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1213 match self {
1214 ReadyCheck::Running => ReadyRepr::Name("running".into()),
1215 ReadyCheck::DefaultRoute => ReadyRepr::Name("default_route".into()),
1216 ReadyCheck::Agent => ReadyRepr::Name("agent".into()),
1217 ReadyCheck::UserExists(u) => ReadyRepr::UserExists {
1218 user_exists: u.clone(),
1219 },
1220 ReadyCheck::PathWritable(p) => ReadyRepr::PathWritable {
1221 path_writable: p.clone(),
1222 },
1223 ReadyCheck::Command(c) => ReadyRepr::Command {
1224 command: c.iter().cloned().map(flex::Scalar::String).collect(),
1225 },
1226 }
1227 .serialize(s)
1228 }
1229}
1230
1231impl<'de> Deserialize<'de> for ReadyCheck {
1232 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1233 use serde::de::Error as _;
1234 let r = ReadyRepr::deserialize(d).map_err(|_| {
1235 D::Error::custom(
1236 "expected running, agent, default_route, {user_exists: USER}, {path_writable: PATH} or {command: [ARGV...]}",
1237 )
1238 })?;
1239 Ok(match r {
1240 ReadyRepr::Name(n) => match n.as_str() {
1241 "running" => ReadyCheck::Running,
1242 "default_route" => ReadyCheck::DefaultRoute,
1243 "agent" => ReadyCheck::Agent,
1244 other => {
1245 return Err(D::Error::custom(format!(
1246 "unknown readiness check {other:?} (running, agent, default_route, user_exists, path_writable, command)"
1247 )));
1248 }
1249 },
1250 ReadyRepr::UserExists { user_exists } => ReadyCheck::UserExists(user_exists),
1251 ReadyRepr::PathWritable { path_writable } => ReadyCheck::PathWritable(path_writable),
1252 ReadyRepr::Command { command } => {
1253 ReadyCheck::Command(command.into_iter().map(flex::Scalar::into_string).collect())
1254 }
1255 })
1256 }
1257}
1258
1259impl std::fmt::Display for ReadyCheck {
1260 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1261 match self {
1262 ReadyCheck::Running => write!(f, "running"),
1263 ReadyCheck::DefaultRoute => write!(f, "default_route"),
1264 ReadyCheck::Agent => write!(f, "agent"),
1265 ReadyCheck::UserExists(u) => write!(f, "user_exists({u})"),
1266 ReadyCheck::PathWritable(p) => write!(f, "path_writable({p})"),
1267 ReadyCheck::Command(c) => write!(f, "command({})", c.join(" ")),
1268 }
1269 }
1270}
1271
1272#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1274#[serde(deny_unknown_fields)]
1275pub struct ExecSpec {
1276 #[serde(
1279 default,
1280 deserialize_with = "flex::env_map_or_list",
1281 skip_serializing_if = "BTreeMap::is_empty"
1282 )]
1283 #[schemars(with = "flex::MapOrList")]
1284 pub env: BTreeMap<String, String>,
1285
1286 #[serde(
1289 default,
1290 deserialize_with = "flex::bool",
1291 skip_serializing_if = "std::ops::Not::not"
1292 )]
1293 #[schemars(with = "flex::BoolOrString")]
1294 pub login: bool,
1295}
1296
1297impl ExecSpec {
1298 pub fn is_empty(&self) -> bool {
1299 self == &ExecSpec::default()
1300 }
1301}
1302
1303#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1305#[serde(deny_unknown_fields)]
1306pub struct ExecDefaults {
1307 #[serde(
1310 default,
1311 deserialize_with = "flex::opt_string",
1312 skip_serializing_if = "Option::is_none"
1313 )]
1314 #[schemars(with = "Option<flex::IntOrString>")]
1315 pub user: Option<String>,
1316
1317 #[serde(default, skip_serializing_if = "Option::is_none")]
1319 pub cwd: Option<String>,
1320
1321 #[serde(
1323 default,
1324 deserialize_with = "flex::string_map",
1325 skip_serializing_if = "BTreeMap::is_empty"
1326 )]
1327 #[schemars(with = "BTreeMap<String, flex::Scalar>")]
1328 pub env: BTreeMap<String, String>,
1329
1330 #[serde(
1333 default,
1334 deserialize_with = "flex::bool",
1335 skip_serializing_if = "std::ops::Not::not"
1336 )]
1337 #[schemars(with = "flex::BoolOrString")]
1338 pub login: bool,
1339}
1340
1341impl ExecDefaults {
1342 pub fn is_empty(&self) -> bool {
1343 self == &ExecDefaults::default()
1344 }
1345}
1346
1347#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, JsonSchema)]
1353#[serde(rename_all = "kebab-case")]
1354pub enum RestartMode {
1355 #[default]
1356 No,
1357 Always,
1358 OnFailure,
1359 UnlessStopped,
1360}
1361
1362impl<'de> Deserialize<'de> for RestartMode {
1364 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1365 use serde::de::Error as _;
1366 let s = flex::Scalar::deserialize(d)?.into_string();
1367 Ok(match s.as_str() {
1368 "no" | "false" | "" => RestartMode::No,
1369 "always" => RestartMode::Always,
1370 "on-failure" => RestartMode::OnFailure,
1371 "unless-stopped" => RestartMode::UnlessStopped,
1372 other => {
1373 return Err(D::Error::custom(format!(
1374 "unknown restart {other:?} (no, always, on-failure, unless-stopped)"
1375 )));
1376 }
1377 })
1378 }
1379}
1380
1381impl RestartMode {
1382 pub fn is_long_running(&self) -> bool {
1383 *self != RestartMode::No
1384 }
1385}
1386
1387#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1389#[serde(deny_unknown_fields)]
1390pub struct Healthcheck {
1391 #[serde(
1394 default,
1395 deserialize_with = "health_test",
1396 skip_serializing_if = "Vec::is_empty"
1397 )]
1398 #[schemars(with = "Option<flex::Command>")]
1399 pub test: Vec<String>,
1400
1401 #[serde(
1403 default,
1404 deserialize_with = "flex::opt_string",
1405 skip_serializing_if = "Option::is_none"
1406 )]
1407 #[schemars(with = "Option<flex::IntOrString>")]
1408 pub interval: Option<String>,
1409
1410 #[serde(
1412 default,
1413 deserialize_with = "flex::opt_string",
1414 skip_serializing_if = "Option::is_none"
1415 )]
1416 #[schemars(with = "Option<flex::IntOrString>")]
1417 pub timeout: Option<String>,
1418
1419 #[serde(default, skip_serializing_if = "Option::is_none")]
1421 pub retries: Option<u32>,
1422
1423 #[serde(
1425 default,
1426 deserialize_with = "flex::opt_string",
1427 skip_serializing_if = "Option::is_none"
1428 )]
1429 #[schemars(with = "Option<flex::IntOrString>")]
1430 pub start_period: Option<String>,
1431
1432 #[serde(
1434 default,
1435 deserialize_with = "flex::opt_string",
1436 skip_serializing_if = "Option::is_none"
1437 )]
1438 #[schemars(with = "Option<flex::IntOrString>")]
1439 pub start_interval: Option<String>,
1440
1441 #[serde(
1443 default,
1444 deserialize_with = "flex::bool",
1445 skip_serializing_if = "std::ops::Not::not"
1446 )]
1447 #[schemars(with = "flex::BoolOrString")]
1448 pub disable: bool,
1449}
1450
1451fn health_test<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
1452 match flex::Command::deserialize(d)? {
1453 flex::Command::String(s) => Ok(vec!["CMD-SHELL".into(), s]),
1454 flex::Command::Argv(v) => Ok(v.into_iter().map(flex::Scalar::into_string).collect()),
1455 }
1456}
1457
1458#[derive(Debug, Clone, PartialEq)]
1460pub struct HealthProbe {
1461 pub argv: Vec<String>,
1462 pub interval: std::time::Duration,
1463 pub timeout: std::time::Duration,
1464 pub retries: u32,
1465 pub start_period: std::time::Duration,
1466 pub start_interval: std::time::Duration,
1467}
1468
1469impl Healthcheck {
1470 pub fn probe(&self) -> Result<Option<HealthProbe>, String> {
1472 if self.disable {
1473 return Ok(None);
1474 }
1475 let argv = match self.test.split_first() {
1476 None => return Err("healthcheck needs a test".into()),
1477 Some((k, _)) if k == "NONE" => return Ok(None),
1478 Some((k, rest)) if k == "CMD" => rest.to_vec(),
1479 Some((k, rest)) if k == "CMD-SHELL" => {
1480 if rest.len() != 1 {
1481 return Err("CMD-SHELL takes exactly one shell line".into());
1482 }
1483 vec!["/bin/sh".into(), "-c".into(), rest[0].clone()]
1484 }
1485 Some((k, _)) => {
1486 return Err(format!(
1487 "healthcheck test must start with CMD, CMD-SHELL or NONE, not {k:?} (a plain string is a shell line)"
1488 ));
1489 }
1490 };
1491 if argv.is_empty() {
1492 return Err("healthcheck test has no command".into());
1493 }
1494 let dur = |v: &Option<String>, default: u64| -> Result<std::time::Duration, String> {
1495 match v {
1496 Some(s) => flex::parse_duration(s),
1497 None => Ok(std::time::Duration::from_secs(default)),
1498 }
1499 };
1500 Ok(Some(HealthProbe {
1501 argv,
1502 interval: dur(&self.interval, 30)?,
1503 timeout: dur(&self.timeout, 30)?,
1504 retries: self.retries.unwrap_or(3).max(1),
1505 start_period: dur(&self.start_period, 0)?,
1506 start_interval: dur(&self.start_interval, 5)?,
1507 }))
1508 }
1509}
1510
1511#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1513#[serde(rename_all = "snake_case")]
1514pub enum DependCondition {
1515 #[default]
1516 ServiceStarted,
1517 ServiceHealthy,
1518}
1519
1520#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1521#[serde(deny_unknown_fields)]
1522pub struct Dependency {
1523 #[serde(default)]
1524 pub condition: DependCondition,
1525}
1526
1527#[derive(Deserialize, JsonSchema)]
1528#[serde(untagged)]
1529#[allow(dead_code)]
1530enum DependsOnRepr {
1531 List(Vec<String>),
1532 Map(BTreeMap<String, Dependency>),
1533}
1534
1535fn depends_on<'de, D: serde::Deserializer<'de>>(
1536 d: D,
1537) -> Result<BTreeMap<String, Dependency>, D::Error> {
1538 Ok(match DependsOnRepr::deserialize(d)? {
1539 DependsOnRepr::List(l) => l.into_iter().map(|s| (s, Dependency::default())).collect(),
1540 DependsOnRepr::Map(m) => m,
1541 })
1542}
1543
1544#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1546#[serde(deny_unknown_fields)]
1547pub struct Deploy {
1548 #[serde(default, skip_serializing_if = "Option::is_none")]
1550 pub mode: Option<String>,
1551
1552 #[serde(default, skip_serializing_if = "Option::is_none")]
1554 pub replicas: Option<u32>,
1555
1556 #[serde(default, skip_serializing_if = "Option::is_none")]
1558 pub update_config: Option<UpdateConfig>,
1559
1560 #[serde(default, skip_serializing_if = "Option::is_none")]
1562 pub rollback_config: Option<UpdateConfig>,
1563
1564 #[serde(default, skip_serializing_if = "Option::is_none")]
1566 pub restart_policy: Option<RestartPolicy>,
1567
1568 #[serde(default, skip_serializing_if = "Option::is_none")]
1571 pub resources: Option<Resources>,
1572
1573 #[serde(
1575 default,
1576 deserialize_with = "flex::string_map_or_list",
1577 skip_serializing_if = "BTreeMap::is_empty"
1578 )]
1579 #[schemars(with = "flex::MapOrList")]
1580 pub labels: BTreeMap<String, String>,
1581}
1582
1583#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1584#[serde(rename_all = "kebab-case")]
1585pub enum UpdateOrder {
1586 #[default]
1589 StopFirst,
1590 StartFirst,
1593}
1594
1595#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1596#[serde(rename_all = "snake_case")]
1597pub enum FailureAction {
1598 #[default]
1600 Pause,
1601 Rollback,
1603 Continue,
1605}
1606
1607#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1608#[serde(deny_unknown_fields)]
1609pub struct UpdateConfig {
1610 #[serde(default, skip_serializing_if = "Option::is_none")]
1612 pub parallelism: Option<u32>,
1613
1614 #[serde(
1616 default,
1617 deserialize_with = "flex::opt_string",
1618 skip_serializing_if = "Option::is_none"
1619 )]
1620 #[schemars(with = "Option<flex::IntOrString>")]
1621 pub delay: Option<String>,
1622
1623 #[serde(default, skip_serializing_if = "Option::is_none")]
1625 pub failure_action: Option<FailureAction>,
1626
1627 #[serde(
1630 default,
1631 deserialize_with = "flex::opt_string",
1632 skip_serializing_if = "Option::is_none"
1633 )]
1634 #[schemars(with = "Option<flex::IntOrString>")]
1635 pub monitor: Option<String>,
1636
1637 #[serde(default, skip_serializing_if = "Option::is_none")]
1639 pub order: Option<UpdateOrder>,
1640}
1641
1642#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1643#[serde(rename_all = "kebab-case")]
1644pub enum RestartCondition {
1645 None,
1646 OnFailure,
1647 #[default]
1648 Any,
1649}
1650
1651#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1652#[serde(deny_unknown_fields)]
1653pub struct RestartPolicy {
1654 #[serde(default, skip_serializing_if = "Option::is_none")]
1656 pub condition: Option<RestartCondition>,
1657
1658 #[serde(
1660 default,
1661 deserialize_with = "flex::opt_string",
1662 skip_serializing_if = "Option::is_none"
1663 )]
1664 #[schemars(with = "Option<flex::IntOrString>")]
1665 pub delay: Option<String>,
1666
1667 #[serde(default, skip_serializing_if = "Option::is_none")]
1669 pub max_attempts: Option<u32>,
1670
1671 #[serde(
1673 default,
1674 deserialize_with = "flex::opt_string",
1675 skip_serializing_if = "Option::is_none"
1676 )]
1677 #[schemars(with = "Option<flex::IntOrString>")]
1678 pub window: Option<String>,
1679}
1680
1681#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1682#[serde(deny_unknown_fields)]
1683pub struct Resources {
1684 #[serde(default, skip_serializing_if = "Option::is_none")]
1685 pub limits: Option<ResourceLimits>,
1686}
1687
1688#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1689#[serde(deny_unknown_fields)]
1690pub struct ResourceLimits {
1691 #[serde(
1692 default,
1693 deserialize_with = "flex::opt_string",
1694 skip_serializing_if = "Option::is_none"
1695 )]
1696 #[schemars(with = "Option<flex::IntOrString>")]
1697 pub cpus: Option<String>,
1698
1699 #[serde(
1700 default,
1701 deserialize_with = "flex::opt_string",
1702 skip_serializing_if = "Option::is_none"
1703 )]
1704 #[schemars(with = "Option<flex::IntOrString>")]
1705 pub memory: Option<String>,
1706}
1707
1708#[derive(Debug, Clone, Default, PartialEq, Serialize, JsonSchema)]
1710#[serde(deny_unknown_fields)]
1711pub struct SecretRef {
1712 pub source: String,
1714 #[serde(default, skip_serializing_if = "Option::is_none")]
1716 pub target: Option<String>,
1717 #[serde(default, skip_serializing_if = "Option::is_none")]
1719 pub uid: Option<u32>,
1720 #[serde(default, skip_serializing_if = "Option::is_none")]
1721 pub gid: Option<u32>,
1722 #[serde(
1724 default,
1725 deserialize_with = "flex::opt_string",
1726 skip_serializing_if = "Option::is_none"
1727 )]
1728 #[schemars(with = "Option<flex::IntOrString>")]
1729 pub mode: Option<String>,
1730}
1731
1732#[derive(Deserialize)]
1733#[serde(untagged)]
1734enum SecretRefRepr {
1735 Name(String),
1736 Long {
1737 source: String,
1738 #[serde(default)]
1739 target: Option<String>,
1740 #[serde(default)]
1741 uid: Option<flex::Scalar>,
1742 #[serde(default)]
1743 gid: Option<flex::Scalar>,
1744 #[serde(default)]
1745 mode: Option<flex::Scalar>,
1746 },
1747}
1748
1749impl<'de> Deserialize<'de> for SecretRef {
1750 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1751 use serde::de::Error as _;
1752 let id = |v: Option<flex::Scalar>, what: &str| -> Result<Option<u32>, D::Error> {
1753 v.map(|s| {
1754 let s = s.into_string();
1755 s.trim().parse().map_err(|_| {
1756 D::Error::custom(format!("secret {what} must be a number, got {s:?}"))
1757 })
1758 })
1759 .transpose()
1760 };
1761 match SecretRefRepr::deserialize(d).map_err(|_| {
1762 D::Error::custom("expected a secret name or {source, target, uid, gid, mode}")
1763 })? {
1764 SecretRefRepr::Name(source) => Ok(SecretRef {
1765 source,
1766 ..Default::default()
1767 }),
1768 SecretRefRepr::Long {
1769 source,
1770 target,
1771 uid,
1772 gid,
1773 mode,
1774 } => Ok(SecretRef {
1775 source,
1776 target,
1777 uid: id(uid, "uid")?,
1778 gid: id(gid, "gid")?,
1779 mode: mode.map(flex::Scalar::into_string),
1781 }),
1782 }
1783 }
1784}
1785
1786impl SecretRef {
1787 pub fn guest_path(&self) -> String {
1789 let t = self.target.as_deref().unwrap_or(&self.source);
1790 if t.starts_with('/') {
1791 t.to_string()
1792 } else {
1793 format!("/run/secrets/{t}")
1794 }
1795 }
1796
1797 pub fn file_mode(&self) -> Result<u32, String> {
1799 match &self.mode {
1800 None => Ok(0o400),
1801 Some(m) => u32::from_str_radix(m.trim().trim_start_matches("0o"), 8)
1802 .ok()
1803 .filter(|m| *m <= 0o7777)
1804 .ok_or_else(|| format!("secret mode {m:?} is not an octal mode like 0400")),
1805 }
1806 }
1807}
1808
1809impl SandboxSpec {
1810 pub fn secret_keys(&self) -> std::collections::BTreeSet<&str> {
1812 self.secrets
1813 .iter()
1814 .map(|r| r.source.as_str())
1815 .chain(self.env.secrets.values().map(String::as_str))
1816 .collect()
1817 }
1818
1819 pub fn long_running(&self) -> bool {
1821 self.restart.is_some_and(|r| r.is_long_running())
1822 }
1823
1824 pub fn replicas(&self) -> u32 {
1826 self.deploy.as_ref().and_then(|d| d.replicas).unwrap_or(1)
1827 }
1828
1829 pub fn health_probe(&self) -> Result<Option<HealthProbe>, String> {
1831 match &self.healthcheck {
1832 None => Ok(None),
1833 Some(h) => h.probe(),
1834 }
1835 }
1836}
1837
1838mod builder;
1839pub use builder::{PortBinding, Volume};
1840
1841pub fn compose_schema() -> serde_json::Value {
1843 serde_json::to_value(schemars::schema_for!(ComposeFile)).expect("schema serializes")
1844}
1845
1846#[cfg(test)]
1847mod tests;