bootc_lib/bootc_composefs/
uki_addons_cli.rs1use std::io::{Seek, SeekFrom};
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use bootc_mount::tempmount::TempMount;
6use camino::Utf8PathBuf;
7use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt};
8use fn_error_context::context;
9use ostree_ext::{
10 composefs::fsverity::{FsVerityHashValue, Sha512HashValue},
11 composefs_boot::{
12 bootloader::{EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_EXT},
13 cmdline::ComposefsCmdline as ComposefsBootCmdline,
14 uki,
15 },
16 composefs_oci::linked_erofs_images,
17};
18
19use crate::bootc_composefs::boot::STAGED_GLOBAL_UKI_ADDONS_DIR;
20use crate::spec::Host;
21use crate::{
22 bootc_composefs::{
23 boot::{
24 BOOTC_UKI_DIR, EFI_LINUX, GLOBAL_UKI_ADDONS_DIR, get_global_uki_addon_name,
25 get_scoped_uki_addon_name, get_uki_addon_dir_name,
26 },
27 status::list_bootloader_entries,
28 uki_addon::{UkiAddonType, list_installed_uki_addons, list_referenced_uki_addons},
29 },
30 cli::{UkiAddonCliOpts, UkiAddonScope},
31 store::{BootedComposefs, Storage},
32};
33
34#[context("Verifying {addon_type:?} {addon_name} addon exists")]
35fn verify_addon_exists(
36 boot_dir: &Dir,
37 addon_name: &str,
38 addon_type: UkiAddonScope,
39) -> Result<Utf8PathBuf> {
40 let mut path = Utf8PathBuf::from("boot");
41
42 match addon_type {
43 UkiAddonScope::Global => {
44 let addons_dir = boot_dir.open_dir(GLOBAL_UKI_ADDONS_DIR)?;
45
46 for entry in addons_dir.entries_utf8()? {
47 let entry = entry?;
48 let filename = entry.file_name()?;
49
50 if let Some(name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) {
51 if name == addon_name {
52 return Ok(path.join(GLOBAL_UKI_ADDONS_DIR).join(filename));
53 }
54 };
55 }
56 }
57
58 UkiAddonScope::Scoped => {
59 path = path.join(EFI_LINUX);
60
61 for entry in boot_dir
62 .open_dir(EFI_LINUX)
63 .context("Opening EFI/Linux")?
64 .entries_utf8()?
65 {
66 let entry = entry?;
67
68 if !entry.file_type()?.is_dir() {
69 continue;
70 }
71
72 let dirname = entry.file_name()?;
73
74 if !dirname.ends_with(EFI_ADDON_DIR_EXT) {
75 continue;
76 }
77
78 path.push(&dirname);
79
80 for addon_ent in entry.open_dir()?.entries()? {
81 let addon_ent = addon_ent?;
82 let filename = addon_ent.file_name()?;
83
84 if let Some(name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) {
85 if name == addon_name {
86 return Ok(path.join(filename));
87 }
88 };
89 }
90
91 path.pop();
92 }
93 }
94 };
95
96 anyhow::bail!("{addon_name} not found");
97}
98
99pub(crate) fn handle_addon_cli_cmd(
100 storage: &Storage,
101 booted_cfs: &BootedComposefs,
102 host: &Host,
103 opts: &UkiAddonCliOpts,
104) -> Result<()> {
105 let Ok(boot_mount) = storage.require_boot_mount() else {
106 anyhow::bail!("ESP not found");
107 };
108
109 match opts {
110 UkiAddonCliOpts::List { json, verbose } => {
111 let mut addons = list_installed_uki_addons(storage, &host.status.staged)?;
112
113 if !verbose {
114 addons = addons
115 .into_iter()
116 .filter(|addon| !addon.staged)
117 .collect::<Vec<_>>();
118 }
119
120 if *json {
121 return serde_json::to_writer(std::io::stdout(), &addons)
122 .context("Writing JSON output");
123 }
124
125 if addons.is_empty() {
126 println!("No UKI addons installed");
127 return Ok(());
128 }
129
130 for (i, addon) in addons.iter().enumerate() {
131 if i > 0 {
133 println!();
134 }
135 println!("{addon}");
136 }
137 }
138 UkiAddonCliOpts::Remove {
139 name: addon_name,
140 addon_type,
141 } => {
142 let addons = list_installed_uki_addons(storage, &host.status.staged)?;
143
144 let is_staged = host.status.staged.is_some();
145
146 match addon_type {
147 UkiAddonScope::Scoped => {
148 let depl_id = match &host.status.staged {
150 Some(staged) => staged.require_composefs()?.verity.as_str(),
151 None => &*booted_cfs.cmdline.digest,
152 };
153
154 let found = addons.iter().any(|addon| {
155 matches!(
156 &addon.addon_type,
157 UkiAddonType::Scoped { depl_id: id } if id == depl_id
158 ) && addon.name == *addon_name
159 && addon.staged == is_staged
160 });
161
162 if !found {
163 anyhow::bail!(
164 "No addon found with the name {addon_name} for deployment {depl_id}"
165 );
166 }
167
168 let addon_path = Path::new(BOOTC_UKI_DIR)
169 .join(get_uki_addon_dir_name(depl_id))
170 .join(get_scoped_uki_addon_name(addon_name));
171
172 let mut addon_file = boot_mount
175 .fd
176 .open(&addon_path)
177 .with_context(|| format!("Opening {}", addon_path.display()))?;
178
179 match uki::get_cmdline_buffered(&mut addon_file) {
180 Ok(cmdline_str) => {
181 let cfs_cmdline_info =
182 ComposefsBootCmdline::<Sha512HashValue>::from_cmdline(&cmdline_str)
183 .context("Parsing composefs=")?;
184
185 if let Some(cmdline) = cfs_cmdline_info {
186 anyhow::bail!(
187 "Composefs commandline {cmdline:?} found in addon {addon_name}, cannot remove"
188 );
189 };
190 }
191 Err(uki::UkiError::MissingSection(..)) => {
192 }
194 Err(e) => Err(e).context("Reading cmdline section from addon")?,
195 };
196
197 boot_mount
198 .fd
199 .remove_file(&addon_path)
200 .with_context(|| format!("Failed to remove addon {addon_name}"))?;
201
202 println!("Removed addon {addon_name}");
203
204 let addons_dir = addon_path
205 .parent()
206 .expect("Expected addon path to have a parent");
207
208 let num_ents = boot_mount
209 .fd
210 .open_dir(addons_dir)
211 .context("Opening addons dir")?
212 .entries()
213 .context("Getting addons dir entries")?
214 .count();
215
216 if num_ents == 0 {
218 boot_mount.fd.remove_dir(&addons_dir).with_context(|| {
219 format!("Removing addons dir: {}", addons_dir.display())
220 })?;
221
222 println!("Removed empty directory {}", addons_dir.display());
223 }
224 }
225
226 UkiAddonScope::Global => {
227 let found = addons.iter().any(|addon| {
228 addon.addon_type == UkiAddonType::Global
229 && addon.name == *addon_name
230 && addon.staged == is_staged
231 });
232
233 if !found {
234 anyhow::bail!("No Global addon found with the name {addon_name}");
235 }
236
237 let full_addon_name = get_global_uki_addon_name(addon_name);
238
239 tracing::debug!("Removing Global UKI Addon {full_addon_name}");
240
241 let global_addons_dir = if is_staged {
242 STAGED_GLOBAL_UKI_ADDONS_DIR
243 } else {
244 GLOBAL_UKI_ADDONS_DIR
245 };
246
247 boot_mount
248 .fd
249 .remove_file(format!("{global_addons_dir}/{full_addon_name}"))
250 .with_context(|| format!("Removing global addon {full_addon_name}"))?;
251
252 println!("Removed Global Addon {addon_name}");
253
254 let num_ents = boot_mount
255 .fd
256 .open_dir(global_addons_dir)
257 .context("Opening global addons dir")?
258 .entries()
259 .context("Getting global addons dir entries")?
260 .count();
261
262 if num_ents == 0 {
264 boot_mount
265 .fd
266 .remove_dir(global_addons_dir)
267 .context("Removing global addons dir")?;
268
269 println!("Removed empty directory {global_addons_dir}");
270
271 if is_staged {
277 println!(
278 "Note: '{addon_name}' was the last staged global addon. \
279 An empty staged global addon set is not applied on boot, so \
280 the currently active global addons remain in effect after \
281 reboot. To drop all global addons, remove them while no \
282 deployment is staged."
283 );
284 }
285 }
286 }
287 }
288 }
289
290 UkiAddonCliOpts::Add {
291 name: addon_name,
292 addon_type,
293 } => {
294 let deployment_id = match &host.status.staged {
296 Some(staged) => staged.require_composefs()?.verity.as_str(),
297 None => &*booted_cfs.cmdline.digest,
298 };
299
300 let is_staged = host.status.staged.is_some();
301
302 let deployment_digest = Sha512HashValue::from_hex(deployment_id.as_bytes())
305 .with_context(|| {
306 format!("Composefs deployment has bad FSVerity: {deployment_id}")
307 })?;
308
309 let addons = list_installed_uki_addons(storage, &host.status.staged)?;
310
311 let already_present = addons.iter().any(|addon| match addon_type {
312 UkiAddonScope::Global => {
313 addon.addon_type == UkiAddonType::Global
314 && addon.name == *addon_name
315 && addon.staged == is_staged
316 }
317 UkiAddonScope::Scoped => {
318 matches!(
319 &addon.addon_type,
320 UkiAddonType::Scoped { depl_id: id } if *id == deployment_digest.to_hex()
321 ) && addon.name == *addon_name
322 && addon.staged == is_staged
323 }
324 });
325
326 if already_present {
327 println!("Addon {addon_name} is already present. Nothing to do.");
328 return Ok(());
329 }
330
331 let linked_images = linked_erofs_images(&booted_cfs.repo, &deployment_digest)
332 .context("Finding linked EROFS for booted deployment")?;
333
334 let Some(non_bootable_img) = linked_images.iter().find(|img| !img.bootable) else {
335 anyhow::bail!("No non-bootable image found. Cannot gather UKI Addons");
336 };
337
338 tracing::debug!("non_bootable_img: {non_bootable_img:#?}");
339
340 let composefs_mnt_fd = booted_cfs
342 .repo
343 .mount(&non_bootable_img.id.to_hex())
344 .context("Failed to mount composefs image")?;
345
346 let composefs = TempMount::mount_fd(composefs_mnt_fd)
347 .context("Attaching composefs image to temporary directory")?;
348
349 let cfs_boot_dir = composefs
350 .fd
351 .open_dir("boot")
352 .context("Opening boot directory in composefs image")?;
353
354 let addon_path = verify_addon_exists(&cfs_boot_dir, addon_name, *addon_type)?;
357
358 let mut addon_file = composefs
360 .fd
361 .open(&addon_path)
362 .with_context(|| format!("Opening UKI Addon at {addon_path}"))?;
363
364 let cmdline = uki::get_cmdline_buffered(&mut addon_file);
366
367 match cmdline {
368 Ok(ref cmdline_str) => {
369 let cfs_cmdline_info =
370 ComposefsBootCmdline::<Sha512HashValue>::from_cmdline(cmdline_str)
371 .context("Parsing composefs=")?;
372
373 if let Some(cfs_cmdline) = cfs_cmdline_info {
374 anyhow::bail!(
375 "composefs cmdline {cfs_cmdline:?} found in UKI Addon {addon_name}. Refusing to add"
376 );
377 }
378 }
379 Err(uki::UkiError::MissingSection(..)) => {
380 }
383 Err(e) => {
384 return Err(e).context("Getting UKI cmdline");
385 }
386 };
387
388 let (dest_dir, dest_name) = match addon_type {
389 UkiAddonScope::Global => {
390 let global_addons_dir = if is_staged {
391 STAGED_GLOBAL_UKI_ADDONS_DIR
392 } else {
393 GLOBAL_UKI_ADDONS_DIR
394 };
395
396 boot_mount
397 .fd
398 .create_dir_all(global_addons_dir)
399 .context("Creating global addons directory")?;
400
401 let global_addons_dir = boot_mount
402 .fd
403 .open_dir(global_addons_dir)
404 .context("Opening global addons dir")?;
405
406 (global_addons_dir, get_global_uki_addon_name(addon_name))
407 }
408 UkiAddonScope::Scoped => {
409 let dir_path = Path::new(BOOTC_UKI_DIR)
410 .join(get_uki_addon_dir_name(&deployment_digest.to_hex()));
411
412 boot_mount
413 .fd
414 .create_dir_all(&dir_path)
415 .context("Creating addons directory")?;
416
417 let to_dir = boot_mount
418 .fd
419 .open_dir(&dir_path)
420 .context("Opening addons directory")?;
421
422 (to_dir, get_scoped_uki_addon_name(addon_name))
423 }
424 };
425
426 addon_file
427 .seek(SeekFrom::Start(0))
428 .context("Seeking to start of addon")?;
429
430 dest_dir
431 .atomic_replace_with(&dest_name, |writer| std::io::copy(&mut addon_file, writer))
432 .with_context(|| format!("Writing addon {dest_name}"))?;
433
434 rustix::fs::fsync(
435 dest_dir
436 .reopen_as_ownedfd()
437 .context("Reopening as owned fd")?,
438 )
439 .context("fsync")?;
440
441 match addon_type {
442 UkiAddonScope::Global => println!("Global UKI Addon {addon_name} added"),
443 UkiAddonScope::Scoped => {
444 println!(
445 "UKI Addon {addon_name} added to {} deployment",
446 if host.status.staged.is_some() {
447 "staged"
448 } else {
449 "booted"
450 }
451 )
452 }
453 }
454 }
455 UkiAddonCliOpts::ListReferenced { json } => {
456 let referenced = list_referenced_uki_addons(
457 booted_cfs,
458 &list_bootloader_entries(storage)?,
459 &host.status.staged,
460 )?;
461
462 if *json {
463 return serde_json::to_writer(std::io::stdout(), &referenced)
464 .context("Writing JSON output");
465 }
466
467 if referenced.is_empty() {
468 println!("No referenced UKI addons found");
469 return Ok(());
470 }
471
472 for (verity, addons) in &referenced {
473 println!("Deployment {verity}:");
474 if addons.is_empty() {
475 println!(" (none)");
476 } else {
477 for addon in addons {
478 println!(" {addon}");
479 }
480 }
481 }
482 }
483 }
484
485 Ok(())
486}