Skip to main content

isb_core/spec/
mount.rs

1//! Mounts: a host path or a named volume, in docker's short or long syntax.
2
3use std::collections::BTreeMap;
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use crate::flex;
9
10/// What a mount's `source` is.
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
12#[serde(rename_all = "snake_case")]
13pub enum MountType {
14    /// A host path.
15    #[default]
16    Bind,
17    /// A named custom storage volume.
18    Volume,
19}
20
21/// A mount. Written as `SOURCE:TARGET[:OPTIONS]` or as the long form
22/// (`VolumeMount` in the schema); always serialized in the long form.
23#[derive(Debug, Clone, Default, PartialEq, Serialize)]
24pub struct VolumeSpec {
25    /// `bind` (a host path) or `volume` (a named volume).
26    #[serde(rename = "type")]
27    pub mount_type: MountType,
28    /// Host path (bind) or volume key (volume).
29    pub source: String,
30    /// Absolute path inside the guest.
31    pub target: String,
32    #[serde(skip_serializing_if = "std::ops::Not::not")]
33    pub read_only: bool,
34    #[serde(skip_serializing_if = "std::ops::Not::not")]
35    pub external: bool,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub pool: Option<String>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub owner: Option<String>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub mode: Option<String>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub device: Option<String>,
44    #[serde(skip_serializing_if = "VolumeOptions::is_default")]
45    pub volume: VolumeOptions,
46    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
47    pub options: BTreeMap<String, String>,
48}
49
50/// docker's `volume:` block of a long-form mount.
51#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
52#[serde(deny_unknown_fields)]
53pub struct VolumeOptions {
54    /// Named volumes only: do not seed an empty volume with what the image has
55    /// at `target`. Seeding is docker's default; isb does it in containers
56    /// (incus `initial.copy`) when the server supports it.
57    #[serde(
58        default,
59        deserialize_with = "flex::bool",
60        skip_serializing_if = "std::ops::Not::not"
61    )]
62    #[schemars(with = "flex::BoolOrString")]
63    pub nocopy: bool,
64}
65
66impl VolumeOptions {
67    fn is_default(&self) -> bool {
68        *self == Self::default()
69    }
70}
71
72/// The long form of a mount.
73#[derive(Deserialize, JsonSchema)]
74#[serde(deny_unknown_fields)]
75#[allow(dead_code)]
76pub(crate) struct VolumeMount {
77    /// `bind` (a host path) or `volume` (a named volume). Default: `bind` when
78    /// `source` starts with `/`, `.` or `~`, else `volume`.
79    #[serde(default, rename = "type")]
80    mount_type: Option<MountType>,
81
82    /// Host path to bind-mount (relative paths resolve against the compose
83    /// file's directory, `~` expands, symlinks are resolved), or the key of a
84    /// named volume.
85    source: String,
86
87    /// Absolute path inside the guest.
88    target: String,
89
90    /// Mount read-only.
91    #[serde(default, deserialize_with = "flex::bool")]
92    #[schemars(with = "flex::BoolOrString")]
93    read_only: bool,
94
95    /// Named volumes only: the volume must already exist; isb never creates it.
96    #[serde(default, deserialize_with = "flex::bool")]
97    #[schemars(with = "flex::BoolOrString")]
98    external: bool,
99
100    /// Named volumes only: the storage pool. Default: the top-level volume's
101    /// pool, else the sandbox's root pool.
102    #[serde(default)]
103    pool: Option<String>,
104
105    /// Named volumes only: chown the mount point to this guest user (`dev`,
106    /// `dev:dev` or `1000:1000`) after it is attached, plus any root-owned
107    /// parents inside that user's home that the mount conjured. Default: a
108    /// new volume belongs to the service's `user`.
109    #[serde(default, deserialize_with = "flex::opt_string")]
110    #[schemars(with = "Option<flex::IntOrString>")]
111    owner: Option<String>,
112
113    /// Named volumes only: the mount point's octal mode (`"0770"`), set with
114    /// `owner`.
115    #[serde(default, deserialize_with = "flex::opt_string")]
116    #[schemars(with = "Option<flex::IntOrString>")]
117    mode: Option<String>,
118
119    /// incus device name. Default: derived from the target. Set it to adopt an
120    /// existing device under a known name.
121    #[serde(default)]
122    device: Option<String>,
123
124    /// docker's volume options (`nocopy`).
125    #[serde(default)]
126    volume: VolumeOptions,
127
128    /// Extra disk device properties (`shift`, `propagation`, ...), verbatim.
129    #[serde(default, deserialize_with = "flex::string_map")]
130    #[schemars(with = "BTreeMap<String, flex::Scalar>")]
131    options: BTreeMap<String, String>,
132}
133
134/// Whether a mount source names a host path rather than a volume.
135pub(crate) fn is_host_path(source: &str) -> bool {
136    source.starts_with('/') || source.starts_with('.') || source.starts_with('~')
137}
138
139impl From<VolumeMount> for VolumeSpec {
140    fn from(m: VolumeMount) -> Self {
141        let mount_type = m.mount_type.unwrap_or(if is_host_path(&m.source) {
142            MountType::Bind
143        } else {
144            MountType::Volume
145        });
146        VolumeSpec {
147            mount_type,
148            source: m.source,
149            target: m.target,
150            read_only: m.read_only,
151            external: m.external,
152            pool: m.pool,
153            owner: m.owner,
154            mode: m.mode,
155            device: m.device,
156            volume: m.volume,
157            options: m.options,
158        }
159    }
160}
161
162impl<'de> Deserialize<'de> for VolumeSpec {
163    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
164        use serde::de::Error as _;
165        match serde_json::Value::deserialize(d)? {
166            serde_json::Value::String(s) => {
167                crate::shorthand::volume(&s).map_err(|e| D::Error::custom(e.to_string()))
168            }
169            v @ serde_json::Value::Object(_) => serde_json::from_value::<VolumeMount>(v)
170                .map(Into::into)
171                .map_err(|e| D::Error::custom(format!("volume: {e}"))),
172            other => Err(D::Error::custom(format!(
173                "volume: expected SOURCE:TARGET[:OPTIONS] or {{type, source, target, ...}}, got {other}"
174            ))),
175        }
176    }
177}
178
179impl JsonSchema for VolumeSpec {
180    fn schema_name() -> std::borrow::Cow<'static, str> {
181        "VolumeSpec".into()
182    }
183
184    fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
185        let long = g.subschema_for::<VolumeMount>();
186        schemars::json_schema!({
187            "description": "A mount: `SOURCE:TARGET[:OPTIONS]` or the long form.",
188            "oneOf": [
189                {
190                    "type": "string",
191                    "description": "SOURCE:TARGET[:OPTIONS]. OPTIONS is a comma list of ro, rw, owner=USER, mode=MODE, device=NAME, pool=POOL, external."
192                },
193                long
194            ]
195        })
196    }
197}