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}
609
610impl SandboxSpec {
611 pub fn exec_defaults(&self) -> ExecDefaults {
613 ExecDefaults {
614 user: self.user.clone(),
615 cwd: self.working_dir.clone(),
616 env: self.exec.env.clone(),
617 login: self.exec.login,
618 }
619 }
620}
621
622fn is_default<T: Default + PartialEq>(v: &T) -> bool {
623 *v == T::default()
624}
625
626#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
628#[serde(deny_unknown_fields)]
629pub struct DomainSpec {
630 pub host: String,
634
635 #[serde(default, skip_serializing_if = "Option::is_none")]
637 pub path: Option<String>,
638
639 #[serde(default, skip_serializing_if = "Option::is_none")]
642 pub port: Option<u16>,
643
644 #[serde(
647 default,
648 deserialize_with = "flex::opt_bool",
649 skip_serializing_if = "Option::is_none"
650 )]
651 #[schemars(with = "Option<flex::BoolOrString>")]
652 pub https: Option<bool>,
653
654 #[serde(default, skip_serializing_if = "Option::is_none")]
658 pub redirect: Option<String>,
659
660 #[serde(
662 default,
663 deserialize_with = "flex::bool",
664 skip_serializing_if = "std::ops::Not::not"
665 )]
666 #[schemars(with = "flex::BoolOrString")]
667 pub strip_prefix: bool,
668
669 #[serde(
671 default,
672 deserialize_with = "flex::bool",
673 skip_serializing_if = "std::ops::Not::not"
674 )]
675 #[schemars(with = "flex::BoolOrString")]
676 pub www_redirect: bool,
677}
678
679#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
697#[serde(untagged)]
698pub enum IdmapSpec {
699 Mode(IdmapMode),
700 Map(IdmapMap),
701 Raw(IdmapRaw),
702}
703
704#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
705#[serde(rename_all = "snake_case")]
706pub enum IdmapMode {
707 #[default]
708 Auto,
709 None,
710 Always,
711}
712
713#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
714#[serde(deny_unknown_fields)]
715pub struct IdmapMap {
716 #[serde(default)]
717 pub mode: IdmapMode,
718 #[serde(default = "default_id")]
719 pub host_uid: u32,
720 #[serde(default = "default_id")]
721 pub host_gid: u32,
722 #[serde(default = "default_id")]
723 pub guest_uid: u32,
724 #[serde(default = "default_id")]
725 pub guest_gid: u32,
726}
727
728#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
729#[serde(deny_unknown_fields)]
730pub struct IdmapRaw {
731 pub raw: String,
733}
734
735fn default_id() -> u32 {
736 1000
737}
738
739#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
741#[serde(rename_all = "snake_case")]
742pub enum MountType {
743 #[default]
745 Bind,
746 Volume,
748}
749
750#[derive(Debug, Clone, Default, PartialEq, Serialize)]
753pub struct VolumeSpec {
754 #[serde(rename = "type")]
756 pub mount_type: MountType,
757 pub source: String,
759 pub target: String,
761 #[serde(skip_serializing_if = "std::ops::Not::not")]
762 pub read_only: bool,
763 #[serde(skip_serializing_if = "std::ops::Not::not")]
764 pub external: bool,
765 #[serde(skip_serializing_if = "Option::is_none")]
766 pub pool: Option<String>,
767 #[serde(skip_serializing_if = "Option::is_none")]
768 pub owner: Option<String>,
769 #[serde(skip_serializing_if = "Option::is_none")]
770 pub device: Option<String>,
771 #[serde(skip_serializing_if = "VolumeOptions::is_default")]
772 pub volume: VolumeOptions,
773 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
774 pub options: BTreeMap<String, String>,
775}
776
777#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
779#[serde(deny_unknown_fields)]
780pub struct VolumeOptions {
781 #[serde(
785 default,
786 deserialize_with = "flex::bool",
787 skip_serializing_if = "std::ops::Not::not"
788 )]
789 #[schemars(with = "flex::BoolOrString")]
790 pub nocopy: bool,
791}
792
793impl VolumeOptions {
794 fn is_default(&self) -> bool {
795 *self == Self::default()
796 }
797}
798
799#[derive(Deserialize, JsonSchema)]
801#[serde(deny_unknown_fields)]
802#[allow(dead_code)]
803pub(crate) struct VolumeMount {
804 #[serde(default, rename = "type")]
807 mount_type: Option<MountType>,
808
809 source: String,
813
814 target: String,
816
817 #[serde(default, deserialize_with = "flex::bool")]
819 #[schemars(with = "flex::BoolOrString")]
820 read_only: bool,
821
822 #[serde(default, deserialize_with = "flex::bool")]
824 #[schemars(with = "flex::BoolOrString")]
825 external: bool,
826
827 #[serde(default)]
830 pool: Option<String>,
831
832 #[serde(default, deserialize_with = "flex::opt_string")]
836 #[schemars(with = "Option<flex::IntOrString>")]
837 owner: Option<String>,
838
839 #[serde(default)]
842 device: Option<String>,
843
844 #[serde(default)]
846 volume: VolumeOptions,
847
848 #[serde(default, deserialize_with = "flex::string_map")]
850 #[schemars(with = "BTreeMap<String, flex::Scalar>")]
851 options: BTreeMap<String, String>,
852}
853
854pub(crate) fn is_host_path(source: &str) -> bool {
856 source.starts_with('/') || source.starts_with('.') || source.starts_with('~')
857}
858
859impl From<VolumeMount> for VolumeSpec {
860 fn from(m: VolumeMount) -> Self {
861 let mount_type = m.mount_type.unwrap_or(if is_host_path(&m.source) {
862 MountType::Bind
863 } else {
864 MountType::Volume
865 });
866 VolumeSpec {
867 mount_type,
868 source: m.source,
869 target: m.target,
870 read_only: m.read_only,
871 external: m.external,
872 pool: m.pool,
873 owner: m.owner,
874 device: m.device,
875 volume: m.volume,
876 options: m.options,
877 }
878 }
879}
880
881impl<'de> Deserialize<'de> for VolumeSpec {
882 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
883 use serde::de::Error as _;
884 match serde_json::Value::deserialize(d)? {
885 serde_json::Value::String(s) => {
886 crate::shorthand::volume(&s).map_err(|e| D::Error::custom(e.to_string()))
887 }
888 v @ serde_json::Value::Object(_) => serde_json::from_value::<VolumeMount>(v)
889 .map(Into::into)
890 .map_err(|e| D::Error::custom(format!("volume: {e}"))),
891 other => Err(D::Error::custom(format!(
892 "volume: expected SOURCE:TARGET[:OPTIONS] or {{type, source, target, ...}}, got {other}"
893 ))),
894 }
895 }
896}
897
898impl JsonSchema for VolumeSpec {
899 fn schema_name() -> std::borrow::Cow<'static, str> {
900 "VolumeSpec".into()
901 }
902
903 fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
904 let long = g.subschema_for::<VolumeMount>();
905 schemars::json_schema!({
906 "description": "A mount: `SOURCE:TARGET[:OPTIONS]` or the long form.",
907 "oneOf": [
908 {
909 "type": "string",
910 "description": "SOURCE:TARGET[:OPTIONS]. OPTIONS is a comma list of ro, rw, owner=USER, device=NAME, pool=POOL, external."
911 },
912 long
913 ]
914 })
915 }
916}
917
918#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
920#[serde(rename_all = "snake_case")]
921pub enum PortBind {
922 #[default]
924 Host,
925 Guest,
927}
928
929impl PortBind {
930 pub fn as_str(&self) -> &'static str {
931 match self {
932 PortBind::Host => "host",
933 PortBind::Guest => "guest",
934 }
935 }
936}
937
938#[derive(Debug, Clone, Default, PartialEq)]
941pub struct PortSpec {
942 pub name: Option<String>,
944 pub bind: PortBind,
945 pub listen: String,
948 pub connect: String,
950 pub search: Option<u16>,
954 pub options: BTreeMap<String, String>,
956}
957
958#[derive(Serialize, Deserialize, JsonSchema)]
960#[serde(deny_unknown_fields)]
961pub(crate) struct PortMapping {
962 #[serde(default, skip_serializing_if = "Option::is_none")]
964 name: Option<String>,
965
966 #[serde(deserialize_with = "flex::string", serialize_with = "port_number")]
968 #[schemars(with = "flex::IntOrString")]
969 target: String,
970
971 #[serde(deserialize_with = "flex::string", serialize_with = "port_number")]
974 #[schemars(with = "flex::IntOrString")]
975 published: String,
976
977 #[serde(default, skip_serializing_if = "Option::is_none")]
979 host_ip: Option<String>,
980
981 #[serde(default, skip_serializing_if = "Option::is_none")]
983 protocol: Option<String>,
984
985 #[serde(
987 default,
988 deserialize_with = "flex::string_map",
989 skip_serializing_if = "BTreeMap::is_empty"
990 )]
991 #[schemars(with = "BTreeMap<String, flex::Scalar>")]
992 options: BTreeMap<String, String>,
993}
994
995fn port_number<S: serde::Serializer>(p: &str, s: S) -> Result<S::Ok, S::Error> {
997 match p.parse::<u16>() {
998 Ok(n) => s.serialize_u16(n),
999 Err(_) => s.serialize_str(p),
1000 }
1001}
1002
1003#[derive(Serialize, Deserialize, JsonSchema)]
1005#[serde(deny_unknown_fields)]
1006pub(crate) struct ProxyPort {
1007 #[serde(default, skip_serializing_if = "Option::is_none")]
1009 name: Option<String>,
1010
1011 #[serde(default, skip_serializing_if = "is_default")]
1014 bind: PortBind,
1015
1016 #[serde(deserialize_with = "flex::string")]
1020 #[schemars(with = "flex::IntOrString")]
1021 listen: String,
1022
1023 #[serde(deserialize_with = "flex::string")]
1026 #[schemars(with = "flex::IntOrString")]
1027 connect: String,
1028
1029 #[serde(
1031 default,
1032 deserialize_with = "flex::string_map",
1033 skip_serializing_if = "BTreeMap::is_empty"
1034 )]
1035 #[schemars(with = "BTreeMap<String, flex::Scalar>")]
1036 options: BTreeMap<String, String>,
1037}
1038
1039impl PortMapping {
1040 fn into_spec(self) -> crate::error::Result<PortSpec> {
1041 let proto = self.protocol.as_deref().unwrap_or("tcp");
1042 let mut p = crate::shorthand::docker_port(
1043 self.host_ip.as_deref(),
1044 &self.published,
1045 &self.target,
1046 proto,
1047 )?;
1048 p.name = self.name;
1049 p.options = self.options;
1050 Ok(p)
1051 }
1052}
1053
1054fn connect_port(connect: &str) -> Option<(&str, &str)> {
1057 let (proto, rest) = match connect.split_once(':') {
1058 Some((p @ ("tcp" | "udp"), rest)) => (p, rest),
1059 _ => match connect.rsplit_once('/') {
1060 Some((rest, p @ ("tcp" | "udp"))) => (p, rest),
1061 _ => ("tcp", connect),
1062 },
1063 };
1064 let port = match rest.rsplit_once(':') {
1065 Some(("127.0.0.1" | "0.0.0.0", port)) => port,
1066 Some(_) => return None,
1067 None => rest,
1068 };
1069 port.parse::<u16>().ok().map(|_| (proto, port))
1070}
1071
1072impl PortSpec {
1073 fn as_mapping(&self) -> Option<PortMapping> {
1077 if self.bind != PortBind::Host {
1078 return None;
1079 }
1080 let listen = crate::plan::normalize_addr(&self.listen, "127.0.0.1").ok()?;
1081 let (lproto, host, lport) = crate::plan::split_addr(&listen)?;
1082 let (cproto, cport) = connect_port(&self.connect)?;
1083 if lproto != cproto {
1084 return None;
1085 }
1086 let published = match self.search.filter(|n| *n > 0) {
1087 Some(n) => format!("{lport}-{}", lport.checked_add(n)?),
1088 None => lport.to_string(),
1089 };
1090 Some(PortMapping {
1091 name: self.name.clone(),
1092 target: cport.to_string(),
1093 published,
1094 host_ip: (host != "127.0.0.1").then(|| host.trim_matches(['[', ']']).to_string()),
1095 protocol: (lproto != "tcp").then(|| lproto.to_string()),
1096 options: self.options.clone(),
1097 })
1098 }
1099}
1100
1101impl Serialize for PortSpec {
1102 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1103 if let Some(m) = self.as_mapping() {
1104 return m.serialize(s);
1105 }
1106 ProxyPort {
1107 name: self.name.clone(),
1108 bind: self.bind,
1109 listen: self.listen.clone(),
1110 connect: self.connect.clone(),
1111 options: self.options.clone(),
1112 }
1113 .serialize(s)
1114 }
1115}
1116
1117impl<'de> Deserialize<'de> for PortSpec {
1118 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1119 use serde::de::Error as _;
1120 let v = serde_json::Value::deserialize(d)?;
1121 let custom = |e: String| D::Error::custom(format!("port: {e}"));
1122 match v {
1123 serde_json::Value::String(s) => {
1124 crate::shorthand::docker_short_port(&s).map_err(|e| custom(e.to_string()))
1125 }
1126 serde_json::Value::Number(n) => crate::shorthand::docker_short_port(&n.to_string())
1127 .map_err(|e| custom(e.to_string())),
1128 serde_json::Value::Object(ref m) if m.contains_key("search") => Err(custom(
1129 "search is not an isb key: publish a range instead, e.g. \"5173-5223:5173\" or published: 5173-5223".into(),
1130 )),
1131 serde_json::Value::Object(ref m)
1132 if ["listen", "connect", "bind"].iter().any(|k| m.contains_key(*k)) =>
1133 {
1134 let r: ProxyPort = serde_json::from_value(v).map_err(|e| custom(e.to_string()))?;
1135 Ok(PortSpec {
1136 name: r.name,
1137 bind: r.bind,
1138 listen: r.listen,
1139 connect: r.connect,
1140 search: None,
1141 options: r.options,
1142 })
1143 }
1144 v @ serde_json::Value::Object(_) => serde_json::from_value::<PortMapping>(v)
1145 .map_err(|e| custom(e.to_string()))?
1146 .into_spec()
1147 .map_err(|e| custom(e.to_string())),
1148 other => Err(custom(format!(
1149 "expected [HOST_IP:]PUBLISHED:TARGET[/PROTOCOL], {{target, published, ...}} or {{listen, connect, ...}}, got {other}"
1150 ))),
1151 }
1152 }
1153}
1154
1155impl JsonSchema for PortSpec {
1156 fn schema_name() -> std::borrow::Cow<'static, str> {
1157 "PortSpec".into()
1158 }
1159
1160 fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
1161 let mapping = g.subschema_for::<PortMapping>();
1162 let proxy = g.subschema_for::<ProxyPort>();
1163 schemars::json_schema!({
1164 "description": "A published port, docker style, or an incus proxy in either direction.",
1165 "oneOf": [
1166 {
1167 "type": "string",
1168 "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."
1169 },
1170 mapping,
1171 proxy
1172 ]
1173 })
1174 }
1175}
1176
1177#[derive(Debug, Clone, PartialEq, JsonSchema)]
1180#[serde(rename_all = "snake_case")]
1181pub enum ReadyCheck {
1182 Running,
1184 Agent,
1186 DefaultRoute,
1188 UserExists(String),
1190 PathWritable(String),
1192 Command(Vec<String>),
1194}
1195
1196#[derive(Serialize, Deserialize)]
1199#[serde(untagged)]
1200enum ReadyRepr {
1201 Name(String),
1202 UserExists { user_exists: String },
1203 PathWritable { path_writable: String },
1204 Command { command: Vec<flex::Scalar> },
1205}
1206
1207impl Serialize for ReadyCheck {
1208 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1209 match self {
1210 ReadyCheck::Running => ReadyRepr::Name("running".into()),
1211 ReadyCheck::DefaultRoute => ReadyRepr::Name("default_route".into()),
1212 ReadyCheck::Agent => ReadyRepr::Name("agent".into()),
1213 ReadyCheck::UserExists(u) => ReadyRepr::UserExists {
1214 user_exists: u.clone(),
1215 },
1216 ReadyCheck::PathWritable(p) => ReadyRepr::PathWritable {
1217 path_writable: p.clone(),
1218 },
1219 ReadyCheck::Command(c) => ReadyRepr::Command {
1220 command: c.iter().cloned().map(flex::Scalar::String).collect(),
1221 },
1222 }
1223 .serialize(s)
1224 }
1225}
1226
1227impl<'de> Deserialize<'de> for ReadyCheck {
1228 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1229 use serde::de::Error as _;
1230 let r = ReadyRepr::deserialize(d).map_err(|_| {
1231 D::Error::custom(
1232 "expected running, agent, default_route, {user_exists: USER}, {path_writable: PATH} or {command: [ARGV...]}",
1233 )
1234 })?;
1235 Ok(match r {
1236 ReadyRepr::Name(n) => match n.as_str() {
1237 "running" => ReadyCheck::Running,
1238 "default_route" => ReadyCheck::DefaultRoute,
1239 "agent" => ReadyCheck::Agent,
1240 other => {
1241 return Err(D::Error::custom(format!(
1242 "unknown readiness check {other:?} (running, agent, default_route, user_exists, path_writable, command)"
1243 )));
1244 }
1245 },
1246 ReadyRepr::UserExists { user_exists } => ReadyCheck::UserExists(user_exists),
1247 ReadyRepr::PathWritable { path_writable } => ReadyCheck::PathWritable(path_writable),
1248 ReadyRepr::Command { command } => {
1249 ReadyCheck::Command(command.into_iter().map(flex::Scalar::into_string).collect())
1250 }
1251 })
1252 }
1253}
1254
1255impl std::fmt::Display for ReadyCheck {
1256 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1257 match self {
1258 ReadyCheck::Running => write!(f, "running"),
1259 ReadyCheck::DefaultRoute => write!(f, "default_route"),
1260 ReadyCheck::Agent => write!(f, "agent"),
1261 ReadyCheck::UserExists(u) => write!(f, "user_exists({u})"),
1262 ReadyCheck::PathWritable(p) => write!(f, "path_writable({p})"),
1263 ReadyCheck::Command(c) => write!(f, "command({})", c.join(" ")),
1264 }
1265 }
1266}
1267
1268#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1270#[serde(deny_unknown_fields)]
1271pub struct ExecSpec {
1272 #[serde(
1275 default,
1276 deserialize_with = "flex::env_map_or_list",
1277 skip_serializing_if = "BTreeMap::is_empty"
1278 )]
1279 #[schemars(with = "flex::MapOrList")]
1280 pub env: BTreeMap<String, String>,
1281
1282 #[serde(
1285 default,
1286 deserialize_with = "flex::bool",
1287 skip_serializing_if = "std::ops::Not::not"
1288 )]
1289 #[schemars(with = "flex::BoolOrString")]
1290 pub login: bool,
1291}
1292
1293impl ExecSpec {
1294 pub fn is_empty(&self) -> bool {
1295 self == &ExecSpec::default()
1296 }
1297}
1298
1299#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1301#[serde(deny_unknown_fields)]
1302pub struct ExecDefaults {
1303 #[serde(
1306 default,
1307 deserialize_with = "flex::opt_string",
1308 skip_serializing_if = "Option::is_none"
1309 )]
1310 #[schemars(with = "Option<flex::IntOrString>")]
1311 pub user: Option<String>,
1312
1313 #[serde(default, skip_serializing_if = "Option::is_none")]
1315 pub cwd: Option<String>,
1316
1317 #[serde(
1319 default,
1320 deserialize_with = "flex::string_map",
1321 skip_serializing_if = "BTreeMap::is_empty"
1322 )]
1323 #[schemars(with = "BTreeMap<String, flex::Scalar>")]
1324 pub env: BTreeMap<String, String>,
1325
1326 #[serde(
1329 default,
1330 deserialize_with = "flex::bool",
1331 skip_serializing_if = "std::ops::Not::not"
1332 )]
1333 #[schemars(with = "flex::BoolOrString")]
1334 pub login: bool,
1335}
1336
1337impl ExecDefaults {
1338 pub fn is_empty(&self) -> bool {
1339 self == &ExecDefaults::default()
1340 }
1341}
1342
1343#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, JsonSchema)]
1349#[serde(rename_all = "kebab-case")]
1350pub enum RestartMode {
1351 #[default]
1352 No,
1353 Always,
1354 OnFailure,
1355 UnlessStopped,
1356}
1357
1358impl<'de> Deserialize<'de> for RestartMode {
1360 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1361 use serde::de::Error as _;
1362 let s = flex::Scalar::deserialize(d)?.into_string();
1363 Ok(match s.as_str() {
1364 "no" | "false" | "" => RestartMode::No,
1365 "always" => RestartMode::Always,
1366 "on-failure" => RestartMode::OnFailure,
1367 "unless-stopped" => RestartMode::UnlessStopped,
1368 other => {
1369 return Err(D::Error::custom(format!(
1370 "unknown restart {other:?} (no, always, on-failure, unless-stopped)"
1371 )));
1372 }
1373 })
1374 }
1375}
1376
1377impl RestartMode {
1378 pub fn is_long_running(&self) -> bool {
1379 *self != RestartMode::No
1380 }
1381}
1382
1383#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1385#[serde(deny_unknown_fields)]
1386pub struct Healthcheck {
1387 #[serde(
1390 default,
1391 deserialize_with = "health_test",
1392 skip_serializing_if = "Vec::is_empty"
1393 )]
1394 #[schemars(with = "Option<flex::Command>")]
1395 pub test: Vec<String>,
1396
1397 #[serde(
1399 default,
1400 deserialize_with = "flex::opt_string",
1401 skip_serializing_if = "Option::is_none"
1402 )]
1403 #[schemars(with = "Option<flex::IntOrString>")]
1404 pub interval: Option<String>,
1405
1406 #[serde(
1408 default,
1409 deserialize_with = "flex::opt_string",
1410 skip_serializing_if = "Option::is_none"
1411 )]
1412 #[schemars(with = "Option<flex::IntOrString>")]
1413 pub timeout: Option<String>,
1414
1415 #[serde(default, skip_serializing_if = "Option::is_none")]
1417 pub retries: Option<u32>,
1418
1419 #[serde(
1421 default,
1422 deserialize_with = "flex::opt_string",
1423 skip_serializing_if = "Option::is_none"
1424 )]
1425 #[schemars(with = "Option<flex::IntOrString>")]
1426 pub start_period: Option<String>,
1427
1428 #[serde(
1430 default,
1431 deserialize_with = "flex::opt_string",
1432 skip_serializing_if = "Option::is_none"
1433 )]
1434 #[schemars(with = "Option<flex::IntOrString>")]
1435 pub start_interval: Option<String>,
1436
1437 #[serde(
1439 default,
1440 deserialize_with = "flex::bool",
1441 skip_serializing_if = "std::ops::Not::not"
1442 )]
1443 #[schemars(with = "flex::BoolOrString")]
1444 pub disable: bool,
1445}
1446
1447fn health_test<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
1448 match flex::Command::deserialize(d)? {
1449 flex::Command::String(s) => Ok(vec!["CMD-SHELL".into(), s]),
1450 flex::Command::Argv(v) => Ok(v.into_iter().map(flex::Scalar::into_string).collect()),
1451 }
1452}
1453
1454#[derive(Debug, Clone, PartialEq)]
1456pub struct HealthProbe {
1457 pub argv: Vec<String>,
1458 pub interval: std::time::Duration,
1459 pub timeout: std::time::Duration,
1460 pub retries: u32,
1461 pub start_period: std::time::Duration,
1462 pub start_interval: std::time::Duration,
1463}
1464
1465impl Healthcheck {
1466 pub fn probe(&self) -> Result<Option<HealthProbe>, String> {
1468 if self.disable {
1469 return Ok(None);
1470 }
1471 let argv = match self.test.split_first() {
1472 None => return Err("healthcheck needs a test".into()),
1473 Some((k, _)) if k == "NONE" => return Ok(None),
1474 Some((k, rest)) if k == "CMD" => rest.to_vec(),
1475 Some((k, rest)) if k == "CMD-SHELL" => {
1476 if rest.len() != 1 {
1477 return Err("CMD-SHELL takes exactly one shell line".into());
1478 }
1479 vec!["/bin/sh".into(), "-c".into(), rest[0].clone()]
1480 }
1481 Some((k, _)) => {
1482 return Err(format!(
1483 "healthcheck test must start with CMD, CMD-SHELL or NONE, not {k:?} (a plain string is a shell line)"
1484 ));
1485 }
1486 };
1487 if argv.is_empty() {
1488 return Err("healthcheck test has no command".into());
1489 }
1490 let dur = |v: &Option<String>, default: u64| -> Result<std::time::Duration, String> {
1491 match v {
1492 Some(s) => flex::parse_duration(s),
1493 None => Ok(std::time::Duration::from_secs(default)),
1494 }
1495 };
1496 Ok(Some(HealthProbe {
1497 argv,
1498 interval: dur(&self.interval, 30)?,
1499 timeout: dur(&self.timeout, 30)?,
1500 retries: self.retries.unwrap_or(3).max(1),
1501 start_period: dur(&self.start_period, 0)?,
1502 start_interval: dur(&self.start_interval, 5)?,
1503 }))
1504 }
1505}
1506
1507#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1509#[serde(rename_all = "snake_case")]
1510pub enum DependCondition {
1511 #[default]
1512 ServiceStarted,
1513 ServiceHealthy,
1514}
1515
1516#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1517#[serde(deny_unknown_fields)]
1518pub struct Dependency {
1519 #[serde(default)]
1520 pub condition: DependCondition,
1521}
1522
1523#[derive(Deserialize, JsonSchema)]
1524#[serde(untagged)]
1525#[allow(dead_code)]
1526enum DependsOnRepr {
1527 List(Vec<String>),
1528 Map(BTreeMap<String, Dependency>),
1529}
1530
1531fn depends_on<'de, D: serde::Deserializer<'de>>(
1532 d: D,
1533) -> Result<BTreeMap<String, Dependency>, D::Error> {
1534 Ok(match DependsOnRepr::deserialize(d)? {
1535 DependsOnRepr::List(l) => l.into_iter().map(|s| (s, Dependency::default())).collect(),
1536 DependsOnRepr::Map(m) => m,
1537 })
1538}
1539
1540#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1542#[serde(deny_unknown_fields)]
1543pub struct Deploy {
1544 #[serde(default, skip_serializing_if = "Option::is_none")]
1546 pub mode: Option<String>,
1547
1548 #[serde(default, skip_serializing_if = "Option::is_none")]
1550 pub replicas: Option<u32>,
1551
1552 #[serde(default, skip_serializing_if = "Option::is_none")]
1554 pub update_config: Option<UpdateConfig>,
1555
1556 #[serde(default, skip_serializing_if = "Option::is_none")]
1558 pub rollback_config: Option<UpdateConfig>,
1559
1560 #[serde(default, skip_serializing_if = "Option::is_none")]
1562 pub restart_policy: Option<RestartPolicy>,
1563
1564 #[serde(default, skip_serializing_if = "Option::is_none")]
1567 pub resources: Option<Resources>,
1568
1569 #[serde(
1571 default,
1572 deserialize_with = "flex::string_map_or_list",
1573 skip_serializing_if = "BTreeMap::is_empty"
1574 )]
1575 #[schemars(with = "flex::MapOrList")]
1576 pub labels: BTreeMap<String, String>,
1577}
1578
1579#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1580#[serde(rename_all = "kebab-case")]
1581pub enum UpdateOrder {
1582 #[default]
1585 StopFirst,
1586 StartFirst,
1589}
1590
1591#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1592#[serde(rename_all = "snake_case")]
1593pub enum FailureAction {
1594 #[default]
1596 Pause,
1597 Rollback,
1599 Continue,
1601}
1602
1603#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1604#[serde(deny_unknown_fields)]
1605pub struct UpdateConfig {
1606 #[serde(default, skip_serializing_if = "Option::is_none")]
1608 pub parallelism: Option<u32>,
1609
1610 #[serde(
1612 default,
1613 deserialize_with = "flex::opt_string",
1614 skip_serializing_if = "Option::is_none"
1615 )]
1616 #[schemars(with = "Option<flex::IntOrString>")]
1617 pub delay: Option<String>,
1618
1619 #[serde(default, skip_serializing_if = "Option::is_none")]
1621 pub failure_action: Option<FailureAction>,
1622
1623 #[serde(
1626 default,
1627 deserialize_with = "flex::opt_string",
1628 skip_serializing_if = "Option::is_none"
1629 )]
1630 #[schemars(with = "Option<flex::IntOrString>")]
1631 pub monitor: Option<String>,
1632
1633 #[serde(default, skip_serializing_if = "Option::is_none")]
1635 pub order: Option<UpdateOrder>,
1636}
1637
1638#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1639#[serde(rename_all = "kebab-case")]
1640pub enum RestartCondition {
1641 None,
1642 OnFailure,
1643 #[default]
1644 Any,
1645}
1646
1647#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1648#[serde(deny_unknown_fields)]
1649pub struct RestartPolicy {
1650 #[serde(default, skip_serializing_if = "Option::is_none")]
1652 pub condition: Option<RestartCondition>,
1653
1654 #[serde(
1656 default,
1657 deserialize_with = "flex::opt_string",
1658 skip_serializing_if = "Option::is_none"
1659 )]
1660 #[schemars(with = "Option<flex::IntOrString>")]
1661 pub delay: Option<String>,
1662
1663 #[serde(default, skip_serializing_if = "Option::is_none")]
1665 pub max_attempts: Option<u32>,
1666
1667 #[serde(
1669 default,
1670 deserialize_with = "flex::opt_string",
1671 skip_serializing_if = "Option::is_none"
1672 )]
1673 #[schemars(with = "Option<flex::IntOrString>")]
1674 pub window: Option<String>,
1675}
1676
1677#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1678#[serde(deny_unknown_fields)]
1679pub struct Resources {
1680 #[serde(default, skip_serializing_if = "Option::is_none")]
1681 pub limits: Option<ResourceLimits>,
1682}
1683
1684#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
1685#[serde(deny_unknown_fields)]
1686pub struct ResourceLimits {
1687 #[serde(
1688 default,
1689 deserialize_with = "flex::opt_string",
1690 skip_serializing_if = "Option::is_none"
1691 )]
1692 #[schemars(with = "Option<flex::IntOrString>")]
1693 pub cpus: Option<String>,
1694
1695 #[serde(
1696 default,
1697 deserialize_with = "flex::opt_string",
1698 skip_serializing_if = "Option::is_none"
1699 )]
1700 #[schemars(with = "Option<flex::IntOrString>")]
1701 pub memory: Option<String>,
1702}
1703
1704#[derive(Debug, Clone, Default, PartialEq, Serialize, JsonSchema)]
1706#[serde(deny_unknown_fields)]
1707pub struct SecretRef {
1708 pub source: String,
1710 #[serde(default, skip_serializing_if = "Option::is_none")]
1712 pub target: Option<String>,
1713 #[serde(default, skip_serializing_if = "Option::is_none")]
1715 pub uid: Option<u32>,
1716 #[serde(default, skip_serializing_if = "Option::is_none")]
1717 pub gid: Option<u32>,
1718 #[serde(
1720 default,
1721 deserialize_with = "flex::opt_string",
1722 skip_serializing_if = "Option::is_none"
1723 )]
1724 #[schemars(with = "Option<flex::IntOrString>")]
1725 pub mode: Option<String>,
1726}
1727
1728#[derive(Deserialize)]
1729#[serde(untagged)]
1730enum SecretRefRepr {
1731 Name(String),
1732 Long {
1733 source: String,
1734 #[serde(default)]
1735 target: Option<String>,
1736 #[serde(default)]
1737 uid: Option<flex::Scalar>,
1738 #[serde(default)]
1739 gid: Option<flex::Scalar>,
1740 #[serde(default)]
1741 mode: Option<flex::Scalar>,
1742 },
1743}
1744
1745impl<'de> Deserialize<'de> for SecretRef {
1746 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1747 use serde::de::Error as _;
1748 let id = |v: Option<flex::Scalar>, what: &str| -> Result<Option<u32>, D::Error> {
1749 v.map(|s| {
1750 let s = s.into_string();
1751 s.trim().parse().map_err(|_| {
1752 D::Error::custom(format!("secret {what} must be a number, got {s:?}"))
1753 })
1754 })
1755 .transpose()
1756 };
1757 match SecretRefRepr::deserialize(d).map_err(|_| {
1758 D::Error::custom("expected a secret name or {source, target, uid, gid, mode}")
1759 })? {
1760 SecretRefRepr::Name(source) => Ok(SecretRef {
1761 source,
1762 ..Default::default()
1763 }),
1764 SecretRefRepr::Long {
1765 source,
1766 target,
1767 uid,
1768 gid,
1769 mode,
1770 } => Ok(SecretRef {
1771 source,
1772 target,
1773 uid: id(uid, "uid")?,
1774 gid: id(gid, "gid")?,
1775 mode: mode.map(flex::Scalar::into_string),
1777 }),
1778 }
1779 }
1780}
1781
1782impl SecretRef {
1783 pub fn guest_path(&self) -> String {
1785 let t = self.target.as_deref().unwrap_or(&self.source);
1786 if t.starts_with('/') {
1787 t.to_string()
1788 } else {
1789 format!("/run/secrets/{t}")
1790 }
1791 }
1792
1793 pub fn file_mode(&self) -> Result<u32, String> {
1795 match &self.mode {
1796 None => Ok(0o400),
1797 Some(m) => u32::from_str_radix(m.trim().trim_start_matches("0o"), 8)
1798 .ok()
1799 .filter(|m| *m <= 0o7777)
1800 .ok_or_else(|| format!("secret mode {m:?} is not an octal mode like 0400")),
1801 }
1802 }
1803}
1804
1805impl SandboxSpec {
1806 pub fn secret_keys(&self) -> std::collections::BTreeSet<&str> {
1808 self.secrets
1809 .iter()
1810 .map(|r| r.source.as_str())
1811 .chain(self.env.secrets.values().map(String::as_str))
1812 .collect()
1813 }
1814
1815 pub fn long_running(&self) -> bool {
1817 self.restart.is_some_and(|r| r.is_long_running())
1818 }
1819
1820 pub fn replicas(&self) -> u32 {
1822 self.deploy.as_ref().and_then(|d| d.replicas).unwrap_or(1)
1823 }
1824
1825 pub fn health_probe(&self) -> Result<Option<HealthProbe>, String> {
1827 match &self.healthcheck {
1828 None => Ok(None),
1829 Some(h) => h.probe(),
1830 }
1831 }
1832}
1833
1834pub struct Volume;
1838
1839impl Volume {
1840 pub fn bind(host_path: impl Into<String>) -> VolumeSpec {
1842 VolumeSpec {
1843 mount_type: MountType::Bind,
1844 source: host_path.into(),
1845 ..Default::default()
1846 }
1847 }
1848
1849 pub fn named(name: impl Into<String>) -> VolumeSpec {
1851 VolumeSpec {
1852 mount_type: MountType::Volume,
1853 source: name.into(),
1854 ..Default::default()
1855 }
1856 }
1857}
1858
1859impl VolumeSpec {
1860 pub fn external(mut self, external: bool) -> Self {
1862 self.external = external;
1863 self
1864 }
1865 pub fn read_only(mut self, ro: bool) -> Self {
1866 self.read_only = ro;
1867 self
1868 }
1869 pub fn owner(mut self, owner: impl Into<String>) -> Self {
1870 self.owner = Some(owner.into());
1871 self
1872 }
1873 pub fn device(mut self, name: impl Into<String>) -> Self {
1874 self.device = Some(name.into());
1875 self
1876 }
1877 pub fn pool(mut self, pool: impl Into<String>) -> Self {
1878 self.pool = Some(pool.into());
1879 self
1880 }
1881 pub fn nocopy(mut self, nocopy: bool) -> Self {
1883 self.volume.nocopy = nocopy;
1884 self
1885 }
1886 pub fn option(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
1887 self.options.insert(k.into(), v.into());
1888 self
1889 }
1890}
1891
1892pub struct PortBinding;
1894
1895impl PortBinding {
1896 pub fn host(listen: impl Into<String>, connect: impl Into<String>) -> PortSpec {
1899 PortSpec {
1900 bind: PortBind::Host,
1901 listen: listen.into(),
1902 connect: connect.into(),
1903 ..Default::default()
1904 }
1905 }
1906
1907 pub fn guest(listen: impl Into<String>, connect: impl Into<String>) -> PortSpec {
1909 PortSpec {
1910 bind: PortBind::Guest,
1911 listen: listen.into(),
1912 connect: connect.into(),
1913 ..Default::default()
1914 }
1915 }
1916}
1917
1918impl PortSpec {
1919 pub fn name(mut self, n: impl Into<String>) -> Self {
1920 self.name = Some(n.into());
1921 self
1922 }
1923 pub fn search(mut self, n: u16) -> Self {
1924 self.search = Some(n);
1925 self
1926 }
1927}
1928
1929impl SandboxSpec {
1930 pub fn new(name: impl Into<String>, image: impl Into<String>) -> Self {
1931 SandboxSpec {
1932 name: Some(name.into()),
1933 image: image.into(),
1934 ..Default::default()
1935 }
1936 }
1937 pub fn cpus(mut self, cpus: impl ToString) -> Self {
1938 self.cpus = Some(cpus.to_string());
1939 self
1940 }
1941 pub fn cpuset(mut self, set: impl Into<String>) -> Self {
1942 self.cpuset = Some(set.into());
1943 self
1944 }
1945 pub fn memory(mut self, m: impl Into<String>) -> Self {
1946 self.memory = Some(m.into());
1947 self
1948 }
1949 pub fn storage(mut self, pool: impl Into<String>) -> Self {
1950 self.storage = Some(pool.into());
1951 self
1952 }
1953 pub fn idmap(mut self, idmap: IdmapSpec) -> Self {
1954 self.idmap = Some(idmap);
1955 self
1956 }
1957 pub fn privileged(mut self, p: bool) -> Self {
1958 self.privileged = Some(p);
1959 self
1960 }
1961 pub fn label(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
1962 self.labels.insert(k.into(), v.into());
1963 self
1964 }
1965 pub fn env(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
1966 self.env.insert(k.into(), v.into());
1967 self
1968 }
1969 pub fn egress(mut self, e: crate::egress::EgressSpec) -> Self {
1971 self.egress = Some(e);
1972 self
1973 }
1974 pub fn volume(mut self, guest_path: impl Into<String>, mut vol: VolumeSpec) -> Self {
1976 vol.target = guest_path.into();
1977 self.volumes.push(vol);
1978 self
1979 }
1980 pub fn port(mut self, p: PortSpec) -> Self {
1981 self.ports.push(p);
1982 self
1983 }
1984 pub fn ready(mut self, checks: Vec<ReadyCheck>) -> Self {
1985 self.ready = Some(checks);
1986 self
1987 }
1988 pub fn ready_timeout(mut self, t: impl Into<String>) -> Self {
1989 self.ready_timeout = Some(t.into());
1990 self
1991 }
1992 pub fn user(mut self, u: impl Into<String>) -> Self {
1993 self.user = Some(u.into());
1994 self
1995 }
1996 pub fn working_dir(mut self, c: impl Into<String>) -> Self {
1997 self.working_dir = Some(c.into());
1998 self
1999 }
2000 pub fn raw_config(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
2001 self.raw_config.insert(k.into(), v.into());
2002 self
2003 }
2004 pub fn raw_device(mut self, name: impl Into<String>, props: BTreeMap<String, String>) -> Self {
2005 self.raw_devices.insert(name.into(), props);
2006 self
2007 }
2008}
2009
2010pub fn compose_schema() -> serde_json::Value {
2012 serde_json::to_value(schemars::schema_for!(ComposeFile)).expect("schema serializes")
2013}
2014
2015#[cfg(test)]
2016mod tests;