ferroday_cage/provision/layer.rs
1//! The disposable overlay upper a layered build installs its increment into.
2//!
3//! A build pipeline provisions a base system once, then per component stages
4//! only that component's build dependencies over it — resolving against the base
5//! as already-installed, extracting the increment into an overlay upper, and
6//! configuring it in a cage rooted on an overlay of the pristine base. The base
7//! is never mutated; the increment lives in the upper, which the [`BuildLayer`]
8//! below removes when dropped.
9//!
10//! The handle is here rather than in a userland layer because nothing about it
11//! is one userland's: an upper directory, the work directory beside it, and the
12//! identity map that decides how both are removed. What differs between layers
13//! is how a base's already-installed set is read and how an increment is
14//! configured, and those stay with the layer that knows the archive.
15//!
16//! [`Rooting`] is here for the same reason. Whether a wave's cage is rooted on
17//! the tree being built or on an overlay of a base and that tree is a property
18//! of whether the build is layered, not of the archive it installs from.
19
20use std::path::{Path, PathBuf};
21
22use crate::{Cage, CageBuilder, IdentityMap};
23
24/// Where a wave of a provisioning run is rooted.
25///
26/// A full bootstrap roots its waves on the tree it is building. A layered build
27/// roots them on an overlay of the pristine base and that tree, which is the
28/// increment's writable upper: the command reads the base's own interpreter, its
29/// accounts and its package database through the merged view, and everything it
30/// writes lands in the upper, so the base comes out of the build untouched.
31///
32/// Only a layer that runs something in a cage during a bootstrap needs this —
33/// Debian's `dpkg` waves and Alpine's install scripts. The Gentoo layer runs
34/// nothing: a stage3 is extracted, and a binary package is merged and
35/// registered without its phase functions, so a `gentoo`-only build reaches
36/// [`BuildLayer`] and no further.
37#[cfg_attr(
38 not(any(feature = "debian", feature = "alpine")),
39 allow(
40 dead_code,
41 reason = "the Gentoo layer roots no cage during a bootstrap"
42 )
43)]
44#[derive(Clone, Copy)]
45pub(crate) enum Rooting<'a> {
46 /// The tree being built is the whole root.
47 Whole,
48 /// The tree being built is an increment over the base at this path.
49 Over(&'a Path),
50}
51
52impl<'a> Rooting<'a> {
53 /// The rooting a build with `base` runs under: over it when a base was
54 /// configured, on the tree alone when none was.
55 ///
56 /// The Debian layer names the two cases where it decides them and reaches
57 /// for [`Rooting`] already made; only the Alpine layer carries an
58 /// `Option<&Path>` this far, so a build without that feature has no caller.
59 #[cfg_attr(not(feature = "alpine"), allow(dead_code))]
60 pub(crate) fn over(base: Option<&'a Path>) -> Rooting<'a> {
61 match base {
62 Some(base) => Rooting::Over(base),
63 None => Rooting::Whole,
64 }
65 }
66
67 /// A cage builder rooted for the tree at `staging`, which is the whole root
68 /// or the increment's upper according to which rooting this is.
69 ///
70 /// The type's own allowance covers its variants and not this: an inherent
71 /// method is not the enum. It is dead in the same builds and for the same
72 /// reason — a `gentoo`-only build roots no cage during a bootstrap.
73 #[cfg_attr(
74 not(any(feature = "debian", feature = "alpine")),
75 allow(
76 dead_code,
77 reason = "the Gentoo layer roots no cage during a bootstrap"
78 )
79 )]
80 pub(crate) fn builder(self, staging: &Path) -> CageBuilder {
81 match self {
82 Rooting::Whole => Cage::builder().rootfs(staging),
83 // The base is the read-only lower and the tree being built is the
84 // writable upper, so the command sees the merged root and writes
85 // only into the upper.
86 Rooting::Over(base) => Cage::builder().overlay_rootfs(base, staging),
87 }
88 }
89}
90
91/// A staged build layer: the disposable overlay upper an increment was installed
92/// into, removed when the handle is dropped.
93///
94// Which provisioners return one depends on the userland features built, and a
95// link to a layer that is not compiled does not resolve. The sentence is
96// therefore written per feature combination, as is each link's definition below.
97#[cfg_attr(feature = "debian", doc = "Returned by [`Debian::stage_layer`].")]
98#[cfg_attr(feature = "alpine", doc = "Returned by [`Alpine::stage_layer`].")]
99#[cfg_attr(feature = "gentoo", doc = "Returned by [`Gentoo::stage_layer`].")]
100///
101/// The upper it owns is the writable layer of a build root — the caller roots a
102/// cage on
103/// [`overlay_rootfs(base, layer.path())`](crate::CageBuilder::overlay_rootfs)
104/// to build against the merged `base + increment` view. Dropping the handle
105/// removes the upper and the overlay's work directory beside it, reverting the
106/// increment and leaving the base pristine; the base is a separate directory
107/// the handle never touches.
108///
109/// Keep the handle alive for as long as the build root is in use, and drop it to
110/// dispose of the increment. The removal is best-effort at drop time — a drop
111/// cannot report an error — so a caller that must observe a disposal failure
112/// removes the upper directory explicitly through
113/// [`provision::remove`](crate::provision::remove) before dropping the handle,
114/// which then finds nothing to remove.
115///
116#[cfg_attr(
117 feature = "debian",
118 doc = "[`Debian::stage_layer`]: crate::provision::debian::Debian::stage_layer"
119)]
120#[cfg_attr(
121 feature = "alpine",
122 doc = "[`Alpine::stage_layer`]: crate::provision::alpine::Alpine::stage_layer"
123)]
124#[cfg_attr(
125 feature = "gentoo",
126 doc = "[`Gentoo::stage_layer`]: crate::provision::gentoo::Gentoo::stage_layer"
127)]
128#[derive(Debug)]
129pub struct BuildLayer {
130 /// The overlay upper the increment was installed into: the build root's
131 /// writable layer.
132 upper: PathBuf,
133 /// The overlay work directory beside the upper. The kernel leaves a mode-`0`
134 /// directory here that a plain recursive delete cannot descend, so disposal
135 /// treats it specially.
136 work: PathBuf,
137 /// The identity map the increment was configured under, which decides how the
138 /// upper and work directory are removed.
139 identity_map: IdentityMap,
140}
141
142impl BuildLayer {
143 /// Builds a layer handle over a staged `upper`, computing the sibling work
144 /// directory the overlay-rooted cage manages so disposal removes both.
145 pub(crate) fn new(upper: &Path, identity_map: IdentityMap) -> BuildLayer {
146 // The work directory is the same sibling an overlay-rooted cage derives
147 // from this upper; a canonical upper always has a parent and file name,
148 // so the fallback to the upper itself is unreachable in practice and
149 // simply makes disposal a no-op rather than panicking.
150 let work = crate::spec::overlay_work_dir(upper).unwrap_or_else(|| upper.to_path_buf());
151 BuildLayer {
152 upper: upper.to_path_buf(),
153 work,
154 identity_map,
155 }
156 }
157
158 /// The overlay upper directory, the build root's writable layer.
159 ///
160 /// Pass it as the upper of
161 /// [`CageBuilder::overlay_rootfs`](crate::CageBuilder::overlay_rootfs), with
162 /// the base as the lower, to root a build cage on the merged view.
163 pub fn path(&self) -> &Path {
164 &self.upper
165 }
166}
167
168impl Drop for BuildLayer {
169 fn drop(&mut self) {
170 // Under the single-identity map every entry is the caller's own, but the
171 // overlay leaves a mode-`0` work directory a plain delete cannot descend;
172 // a chmod-aware removal restores traversable permissions as it goes.
173 // Under a range map the increment and the work directory hold
174 // subordinate-owned entries the caller cannot chmod, so the removal
175 // escalates through the identity map, where root inside it owns them.
176 if matches!(self.identity_map, IdentityMap::Single) {
177 crate::host::force_remove_dir_all(&self.upper);
178 crate::host::force_remove_dir_all(&self.work);
179 } else {
180 // The map the increment was staged under, rather than the
181 // subordinate default: an explicit range map allocates ids the
182 // bundled chain would not, and the removal has to re-enter the map
183 // the tree's ownership was written in.
184 //
185 // And no lock: an upper is a directory a caller named as a build
186 // layer, not a published rootfs, so `<upper>.lock` is a path this
187 // never created and may not delete. The work directory is the
188 // layer's own and has no lock either.
189 for tree in [&self.upper, &self.work] {
190 let _ = crate::provision::Remove::new(tree)
191 .map(self.identity_map.clone())
192 .remove_lock(false)
193 .run();
194 }
195 }
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::scratch::Scratch;
203
204 #[test]
205 fn a_single_map_layer_drop_removes_the_upper_and_work_tree() {
206 // A single-identity layer disposes of its upper and the overlay work
207 // directory, including a mode-`0` work/work directory a plain recursive
208 // delete cannot descend — the artifact an overlay mount leaves behind.
209 use std::os::unix::fs::PermissionsExt;
210
211 let dir = Scratch::for_test("layer", "dispose-single");
212 let upper = dir.join("upper");
213 std::fs::create_dir_all(upper.join("usr/bin")).unwrap();
214 std::fs::write(upper.join("usr/bin/tool"), b"x").unwrap();
215
216 let work = crate::spec::overlay_work_dir(&upper).unwrap();
217 std::fs::create_dir_all(work.join("work")).unwrap();
218 std::fs::write(work.join("index"), b"i").unwrap();
219 // The overlay artifact: a directory with no permissions at all.
220 std::fs::set_permissions(work.join("work"), std::fs::Permissions::from_mode(0o000))
221 .unwrap();
222
223 let layer = BuildLayer::new(&upper, IdentityMap::Single);
224 assert_eq!(layer.path(), upper.as_path());
225 drop(layer);
226
227 assert!(!upper.exists(), "the upper is removed");
228 assert!(
229 !work.exists(),
230 "the work directory is removed despite mode-0"
231 );
232 }
233
234 #[test]
235 fn a_layer_drop_leaves_a_lock_sibling_it_never_created() {
236 // An upper is a directory a caller named as a build layer, not a
237 // published rootfs -- `ensure` wrote no lock beside it, so `<upper>.lock`
238 // is an unrelated path, and deleting it is a removal outside the tree
239 // the layer owns. A build root and its layers routinely sit beside the
240 // published trees a cache holds.
241 let dir = Scratch::for_test("layer", "dispose-lock");
242 let upper = dir.join("upper");
243 std::fs::create_dir_all(&upper).unwrap();
244 let sibling = dir.join("upper.lock");
245 std::fs::write(&sibling, b"someone else's").unwrap();
246
247 // Asserted for the single-identity map, whose removal never touched the
248 // sibling, and for the escalating one, which did.
249 for map in [IdentityMap::Single, IdentityMap::Subordinate] {
250 std::fs::create_dir_all(&upper).unwrap();
251 drop(BuildLayer::new(&upper, map));
252 assert!(!upper.exists(), "the upper is removed");
253 assert!(
254 sibling.exists(),
255 "a lock the layer never created went with it",
256 );
257 }
258 assert_eq!(std::fs::read(&sibling).unwrap(), b"someone else's");
259 }
260}