Skip to main content

snix_build/oci/
spec.rs

1//! Module to create a OCI runtime spec for a given [BuildRequest].
2use crate::buildservice::{BuildConstraints, BuildRequest};
3use oci_spec::runtime::{
4    Capability, LinuxIdMappingBuilder, LinuxNamespace, LinuxNamespaceBuilder, LinuxNamespaceType,
5};
6use std::{collections::HashSet, path::Path};
7
8use super::{
9    scratch_name,
10    subuid::{SubordinateError, SubordinateInfo},
11};
12use thiserror::Error;
13
14#[derive(Debug, Error)]
15pub enum SpecError {
16    #[error("oci error: {0}")]
17    OciError(oci_spec::OciSpecError),
18    #[error("subordinate error: {0}")]
19    SubordinateError(SubordinateError),
20}
21
22/// For a given [BuildRequest], return an OCI runtime spec.
23///
24/// While there's no IO occuring in this function, the generated spec contains
25/// path references relative to the "bundle location".
26/// Due to overlayfs requiring its layers to be absolute paths, we also need a
27/// bundle_dir parameter, pointing to the location of the bundle dir itself.
28///
29/// The paths used in the spec are the following (relative to a "bundle root"):
30///
31/// - `inputs`, a directory where the castore nodes specified the build request
32///   inputs are supposed to be populated.
33/// - `outputs`, a directory where all writes to the store_dir during the build
34///   are directed to.
35/// - `root`, a minimal skeleton of files that'll be present at /.
36/// - `scratch`, a directory containing other directories which will be
37///   bind-mounted read-write into the container and used as scratch space
38///   during the build.
39///   No assumptions should be made about what's inside this directory.
40///
41/// Generating these paths, and populating contents, like a skeleton root
42/// is up to another function, this function doesn't do filesystem IO.
43pub(crate) fn make_spec(
44    request: &BuildRequest,
45    rootless: bool,
46    sandbox_shell: &str,
47) -> Result<oci_spec::runtime::Spec, SpecError> {
48    let allow_network = request
49        .constraints
50        .contains(&BuildConstraints::NetworkAccess);
51
52    // Assemble ro_host_mounts. Start with constraints.available_ro_paths.
53    let mut ro_host_mounts: Vec<_> = request
54        .constraints
55        .iter()
56        .filter_map(|constraint| match constraint {
57            BuildConstraints::AvailableReadOnlyPath(path) => {
58                Some((Path::new(path), Path::new(path)))
59            }
60            _ => None,
61        })
62        .collect();
63
64    // If provide_bin_sh is set, mount sandbox_shell to /bin/sh
65    if request
66        .constraints
67        .contains(&BuildConstraints::ProvideBinSh)
68    {
69        ro_host_mounts.push((Path::new(sandbox_shell), Path::new("/bin/sh")))
70    }
71
72    oci_spec::runtime::SpecBuilder::default()
73        .process(
74            configure_process(
75                &request.command_args,
76                Path::new(&request.working_dir),
77                request
78                    .environment_vars
79                    .iter()
80                    .map(|e| {
81                        (
82                            e.key.as_str(),
83                            // TODO: decide what to do with non-bytes env values
84                            String::from_utf8(e.value.to_vec()).expect("invalid string in env"),
85                        )
86                    })
87                    .collect::<Vec<_>>(),
88                rootless,
89            )
90            .map_err(SpecError::OciError)?,
91        )
92        .linux(configure_linux(allow_network, rootless)?)
93        .root(
94            oci_spec::runtime::RootBuilder::default()
95                .path("root")
96                .readonly(true)
97                .build()
98                .map_err(SpecError::OciError)?,
99        )
100        .hostname("localhost")
101        .mounts(
102            configure_mounts(
103                rootless,
104                allow_network,
105                &request.scratch_paths,
106                request.inputs.iter(),
107                Path::new(&request.inputs_dir),
108                ro_host_mounts,
109            )
110            .map_err(SpecError::OciError)?,
111        )
112        .build()
113        .map_err(SpecError::OciError)
114}
115
116/// Return the Process part of the OCI Runtime spec.
117/// This configures the command, it's working dir, env and terminal setup.
118/// It also takes care of setting rlimits and capabilities.
119/// Capabilities are a bit more complicated in case rootless building is requested.
120fn configure_process<'a>(
121    command_args: &[String],
122    cwd: &Path,
123    env: impl IntoIterator<Item = (&'a str, String)>,
124    rootless: bool,
125) -> Result<oci_spec::runtime::Process, oci_spec::OciSpecError> {
126    let spec_builder = oci_spec::runtime::ProcessBuilder::default()
127        .args(command_args)
128        .env(
129            env.into_iter()
130                .map(|(k, v)| format!("{k}={v}"))
131                .collect::<Vec<_>>(),
132        )
133        .terminal(true)
134        .user(
135            oci_spec::runtime::UserBuilder::default()
136                .uid(1000u32)
137                .gid(100u32)
138                .build()?,
139        )
140        .cwd(Path::new("/").join(cwd)) // relative to the bundle root, but at least runc wants it to also be absolute.
141        .capabilities({
142            let caps: HashSet<Capability> = if !rootless {
143                HashSet::from([Capability::AuditWrite, Capability::Kill])
144            } else {
145                HashSet::from([
146                    Capability::AuditWrite,
147                    Capability::Chown,
148                    Capability::DacOverride,
149                    Capability::Fowner,
150                    Capability::Fsetid,
151                    Capability::Kill,
152                    Capability::Mknod,
153                    Capability::NetBindService,
154                    Capability::NetRaw,
155                    Capability::Setfcap,
156                    Capability::Setgid,
157                    Capability::Setpcap,
158                    Capability::Setuid,
159                    Capability::SysChroot,
160                ])
161            };
162
163            oci_spec::runtime::LinuxCapabilitiesBuilder::default()
164                .bounding(caps.clone())
165                .effective(caps.clone())
166                .inheritable(caps.clone())
167                .permitted(caps.clone())
168                .ambient(caps)
169                .build()?
170        })
171        .rlimits([oci_spec::runtime::PosixRlimitBuilder::default()
172            .typ(oci_spec::runtime::PosixRlimitType::RlimitNofile)
173            .hard(1024_u64)
174            .soft(1024_u64)
175            .build()?])
176        .no_new_privileges(true);
177
178    spec_builder.build()
179}
180
181/// Return the Linux part of the OCI Runtime spec.
182/// This configures various namespaces, masked and read-only paths.
183fn configure_linux(
184    allow_network: bool,
185    rootless: bool,
186) -> Result<oci_spec::runtime::Linux, SpecError> {
187    let mut linux = oci_spec::runtime::Linux::default();
188
189    // explicitly set namespaces, depending on allow_network.
190    linux.set_namespaces(Some({
191        let mut namespace_types = vec![
192            LinuxNamespaceType::Pid,
193            LinuxNamespaceType::Ipc,
194            LinuxNamespaceType::Uts,
195            LinuxNamespaceType::Mount,
196            // We want to create a cgroup namespace in the future to be able to trace resource usage
197            // For now it's disabled as it causes issues in cases where the host machine is running in a
198            // messed up cgroup
199            // LinuxNamespaceType::Cgroup,
200        ];
201        if !allow_network {
202            namespace_types.push(LinuxNamespaceType::Network)
203        }
204        if rootless {
205            namespace_types.push(LinuxNamespaceType::User)
206        }
207
208        namespace_types
209            .into_iter()
210            .map(|e| LinuxNamespaceBuilder::default().typ(e).build())
211            .collect::<Result<Vec<LinuxNamespace>, _>>()
212            .map_err(SpecError::OciError)?
213    }));
214
215    linux.set_masked_paths(Some(
216        [
217            "/proc/kcore",
218            "/proc/latency_stats",
219            "/proc/timer_list",
220            "/proc/timer_stats",
221            "/proc/sched_debug",
222            "/sys/firmware",
223        ]
224        .into_iter()
225        .map(|e| e.to_string())
226        .collect::<Vec<_>>(),
227    ));
228
229    linux.set_readonly_paths(Some(
230        [
231            "/proc/asound",
232            "/proc/bus",
233            "/proc/fs",
234            "/proc/irq",
235            "/proc/sys",
236            "/proc/sysrq-trigger",
237        ]
238        .into_iter()
239        .map(|e| e.to_string())
240        .collect::<Vec<_>>(),
241    ));
242    let info = SubordinateInfo::for_effective_user().map_err(SpecError::SubordinateError)?;
243    linux.set_uid_mappings(Some(vec![
244        LinuxIdMappingBuilder::default()
245            .host_id(info.uid)
246            .container_id(0_u32)
247            .size(1_u32)
248            .build()
249            .unwrap(),
250        LinuxIdMappingBuilder::default()
251            .host_id(info.subuid)
252            .container_id(1000_u32)
253            .size(1_u32)
254            .build()
255            .unwrap(),
256    ]));
257    linux.set_gid_mappings(Some(vec![
258        LinuxIdMappingBuilder::default()
259            .host_id(info.gid)
260            .container_id(0_u32)
261            .size(1_u32)
262            .build()
263            .unwrap(),
264        LinuxIdMappingBuilder::default()
265            .host_id(info.subgid)
266            .container_id(100_u32)
267            .size(1_u32)
268            .build()
269            .unwrap(),
270    ]));
271
272    Ok(linux)
273}
274
275/// Return the Mounts part of the OCI Runtime spec.
276/// It first sets up the standard mounts, then scratch paths, bind mounts for
277/// all inputs, and finally read-only paths from the hosts.
278fn configure_mounts<'a, S>(
279    rootless: bool,
280    allow_network: bool,
281    scratch_paths: impl IntoIterator<Item = S>,
282    inputs: impl Iterator<Item = (&'a snix_castore::PathComponent, &'a snix_castore::Node)>,
283
284    inputs_dir: &Path,
285    ro_host_mounts: impl IntoIterator<Item = (&'a Path, &'a Path)>,
286) -> Result<Vec<oci_spec::runtime::Mount>, oci_spec::OciSpecError>
287where
288    S: AsRef<str> + 'a,
289{
290    let mut mounts: Vec<_> = if rootless {
291        oci_spec::runtime::get_rootless_mounts()
292    } else {
293        oci_spec::runtime::get_default_mounts()
294    };
295
296    mounts.push(configure_mount(
297        Path::new("tmpfs"),
298        Path::new("/tmp"),
299        "tmpfs",
300        &["nosuid", "noatime", "mode=700"],
301    )?);
302
303    // For each scratch path, create a bind mount entry.
304    let scratch_root = Path::new("scratch"); // relative path
305    for scratch_path in scratch_paths {
306        let scratch_path = scratch_path.as_ref();
307        let src = scratch_root.join(scratch_name(scratch_path));
308        mounts.push(configure_mount(
309            &src,
310            &Path::new("/").join(scratch_path),
311            "none",
312            &["rbind", "rw"],
313        )?);
314    }
315
316    // For each input, create a bind mount from inputs/$name into $inputs_dir/$name.
317    for (input_name, _input) in inputs {
318        let input_name = std::str::from_utf8(input_name.as_ref()).expect("invalid input name");
319        mounts.push(configure_mount(
320            &Path::new("inputs").join(input_name),
321            &Path::new("/").join(inputs_dir).join(input_name),
322            "none",
323            &[
324                "rbind", "ro",
325                // "nosuid" is required, otherwise mounting will just fail with
326                // a generic permission error.
327                // See https://github.com/wllenyj/containerd/commit/42a386c8164bef16d59590c61ab00806f854d8fd
328                "nosuid", "nodev",
329            ],
330        )?);
331    }
332
333    // Process ro_host_mounts
334    for (src, dst) in ro_host_mounts.into_iter() {
335        mounts.push(configure_mount(src, dst, "none", &["rbind", "ro"])?);
336    }
337
338    // In case network is enabled, also mount in /etc/{resolv.conf,services,hosts}
339    if allow_network {
340        for p in [
341            Path::new("/etc/resolv.conf"),
342            Path::new("/etc/services"),
343            Path::new("/etc/hosts"),
344        ] {
345            mounts.push(configure_mount(p, p, "none", &["rbind", "ro"])?);
346        }
347    }
348
349    Ok(mounts)
350}
351
352/// Helper function to produce a mount.
353fn configure_mount(
354    source: &Path,
355    destination: &Path,
356    typ: &str,
357    options: &[&str],
358) -> Result<oci_spec::runtime::Mount, oci_spec::OciSpecError> {
359    oci_spec::runtime::MountBuilder::default()
360        .destination(destination)
361        .typ(typ.to_string())
362        .source(source)
363        .options(options.iter().map(|e| e.to_string()).collect::<Vec<_>>())
364        .build()
365}