Skip to main content

libcoreinst/
install.rs

1// Copyright 2019 CoreOS, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use anyhow::{bail, Context, Result};
16use nix::mount;
17use regex::{Captures, Regex};
18use serde::Deserialize;
19use std::collections::HashMap;
20use std::fs::{self, File, OpenOptions, Permissions};
21use std::io::{self, BufReader, Seek, SeekFrom, Write};
22use std::num::NonZeroU32;
23use std::os::unix::fs::{FileTypeExt, PermissionsExt};
24use std::path::{Path, PathBuf};
25
26use crate::blockdev::*;
27use crate::cmdline::*;
28use crate::download::*;
29use crate::io::*;
30#[cfg(target_arch = "s390x")]
31use crate::s390x;
32use crate::source::*;
33
34// Match the grub.cfg console settings commands in
35// https://github.com/coreos/coreos-assembler/blob/main/src/grub.cfg
36const GRUB_CFG_CONSOLE_SETTINGS_RE: &str = r"(?P<prefix>\n# CONSOLE-SETTINGS-START\n)(?P<commands>([^\n]*\n)*)(?P<suffix># CONSOLE-SETTINGS-END\n)";
37
38pub fn install(config: InstallConfig) -> Result<()> {
39    // evaluate config files
40    let config = config.expand_config_files()?;
41
42    // make sure we have a device path
43    let device = config
44        .dest_device
45        .as_deref()
46        .context("destination device must be specified")?;
47
48    // find Ignition config and do some simple validation
49    let mut ignition = if let Some(file) = &config.ignition_file {
50        Some(
51            OpenOptions::new()
52                .read(true)
53                .open(file)
54                .with_context(|| format!("opening source Ignition config {file}"))?,
55        )
56    } else if let Some(url) = &config.ignition_url {
57        if url.scheme() == "http" {
58            if config.ignition_hash.is_none() && !config.insecure_ignition {
59                bail!("refusing to fetch Ignition config over HTTP without --ignition-hash or --insecure-ignition");
60            }
61        } else if url.scheme() != "https" {
62            bail!("unknown protocol for URL '{}'", url);
63        }
64        Some(
65            download_to_tempfile(url, config.fetch_retries)
66                .with_context(|| format!("downloading source Ignition config {url}"))?,
67        )
68    } else {
69        None
70    };
71    if let Some(mut file) = ignition.as_mut() {
72        // make sure we have valid JSON and not e.g. an HTML page.
73        // we don't parse with the ignition-config crate because its parser
74        // rejects unrecognized config versions, and we want to allow those.
75        // iso/pxe customize are more restrictive because they want to
76        // manipulate the config, but for us it's an opaque blob.
77        let reader = BufReader::with_capacity(BUFFER_SIZE, &mut file);
78        serde_json::from_reader::<_, serde_json::Value>(reader)
79            .context("parsing specified Ignition config")?;
80        file.rewind().context("rewinding Ignition config file")?;
81    }
82
83    // find network config
84    // If the user requested us to copy networking config by passing
85    // -n or --copy-network then copy networking config from the
86    // directory defined by --network-dir.
87    let network_config = if config.copy_network {
88        Some(config.network_dir.as_str())
89    } else {
90        None
91    };
92
93    // parse partition saving filters
94    let save_partitions = parse_partition_filters(
95        &config
96            .save_partlabel
97            .iter()
98            .map(|s| s.as_str())
99            .collect::<Vec<&str>>(),
100        &config
101            .save_partindex
102            .iter()
103            .map(|s| s.as_str())
104            .collect::<Vec<&str>>(),
105    )?;
106
107    // compute sector size
108    // Uninitialized ECKD DASD's blocksize is 512, but after formatting
109    // it changes to the recommended 4096
110    // https://bugzilla.redhat.com/show_bug.cgi?id=1905159
111    #[allow(clippy::match_bool, clippy::match_single_binding)]
112    let sector_size = match is_dasd(device, None)
113        .with_context(|| format!("checking whether {device} is an IBM DASD disk"))?
114    {
115        #[cfg(target_arch = "s390x")]
116        true => s390x::dasd_try_get_sector_size(device).transpose(),
117        _ => None,
118    };
119    let sector_size = sector_size
120        .unwrap_or_else(|| get_sector_size_for_path(Path::new(device)))
121        .with_context(|| format!("getting sector size of {device}"))?
122        .get();
123
124    // Set up DASD.  We need to do this before initiating the download
125    // because otherwise the download might time out while we're low-level
126    // formatting the DASD.
127    #[cfg(target_arch = "s390x")]
128    {
129        if is_dasd(device, None)? {
130            if !save_partitions.is_empty() {
131                // The user requested partition saving, but SavedPartitions
132                // doesn't understand DASD VTOCs and won't find any partitions
133                // to save.
134                bail!("saving DASD partitions is not supported");
135            }
136            s390x::prepare_dasd(device)?;
137        }
138    }
139
140    // set up image source
141    // create location
142    let location: Box<dyn ImageLocation> = if let Some(image_file) = &config.image_file {
143        Box::new(FileLocation::new(image_file))
144    } else if let Some(image_url) = &config.image_url {
145        Box::new(UrlLocation::new(image_url, config.fetch_retries))
146    } else if config.offline {
147        match OsmetLocation::new(config.architecture.as_str(), sector_size)? {
148            Some(osmet) => Box::new(osmet),
149            None => bail!("cannot perform offline install; metadata missing"),
150        }
151    } else {
152        // For now, using --stream automatically will cause a download. In the future, we could
153        // opportunistically use osmet if the version and stream match an osmet file/the live ISO.
154
155        let maybe_osmet = match config.stream {
156            Some(_) => None,
157            None => OsmetLocation::new(config.architecture.as_str(), sector_size)?,
158        };
159
160        if let Some(osmet) = maybe_osmet {
161            Box::new(osmet)
162        } else {
163            let format = match sector_size {
164                4096 => "4k.raw.xz",
165                512 => "raw.xz",
166                n => {
167                    // could bail on non-512, but let's be optimistic and just warn but try the regular
168                    // 512b image
169                    eprintln!(
170                        "Found non-standard sector size {n} for {device}, assuming 512b-compatible"
171                    );
172                    "raw.xz"
173                }
174            };
175            Box::new(StreamLocation::new(
176                config.stream.as_deref().unwrap_or("stable"),
177                config.architecture.as_str(),
178                "metal",
179                format,
180                config.stream_base_url.as_ref(),
181                config.fetch_retries,
182            )?)
183        }
184    };
185    // report it to the user
186    eprintln!("{location}");
187    // we only support installing from a single artifact
188    let mut sources = location.sources()?;
189    let mut source = sources.pop().context("no artifacts found")?;
190    if !sources.is_empty() {
191        bail!("found multiple artifacts");
192    }
193    if source.signature.is_none() && location.require_signature() {
194        if config.insecure {
195            eprintln!("Signature not found; skipping verification as requested");
196        } else {
197            bail!("--insecure not specified and signature not found");
198        }
199    }
200
201    // open output; ensure it's a block device and we have exclusive access
202    let mut dest = OpenOptions::new()
203        .read(true)
204        .write(true)
205        .open(device)
206        .with_context(|| format!("opening {device}"))?;
207    if !dest
208        .metadata()
209        .with_context(|| format!("getting metadata for {device}"))?
210        .file_type()
211        .is_block_device()
212    {
213        bail!("{} is not a block device", device);
214    }
215    ensure_exclusive_access(device)
216        .with_context(|| format!("checking for exclusive access to {device}"))?;
217
218    // save partitions that we plan to keep
219    let saved = SavedPartitions::new_from_disk(&mut dest, &save_partitions)
220        .with_context(|| format!("saving partitions from {device}"))?;
221
222    // get reference to partition table
223    // For kpartx partitioning, this will conditionally call kpartx -d
224    // when dropped
225    let mut table = Disk::new(device)?
226        .get_partition_table()
227        .with_context(|| format!("getting partition table for {device}"))?;
228
229    // copy and postprocess disk image
230    // On failure, clear and reread the partition table to prevent the disk
231    // from accidentally being used.
232    dest.rewind().with_context(|| format!("seeking {device}"))?;
233    if let Err(err) = write_disk(
234        &config,
235        &mut source,
236        &mut dest,
237        &mut *table,
238        &saved,
239        ignition,
240        network_config,
241    ) {
242        // log the error so the details aren't dropped if we encounter
243        // another error during cleanup
244        eprintln!("\nError: {err:?}\n");
245
246        // clean up
247        if config.preserve_on_error {
248            eprintln!("Preserving partition table as requested");
249            if saved.is_saved() {
250                // The user asked to preserve the damaged partition table
251                // for debugging.  We also have saved partitions, and those
252                // may or may not be in the damaged table depending where we
253                // failed.  Preserve the saved partitions by writing them to
254                // a file in /tmp and telling the user about it.  Hey, it's
255                // a debug flag.
256                stash_saved_partitions(&mut dest, &saved)?;
257            }
258        } else {
259            reset_partition_table(&config, &mut dest, &mut *table, &saved)?;
260        }
261
262        // return a generic error so our exit status is right
263        bail!("install failed");
264    }
265
266    // Because grub picks /boot by label and the OS picks /boot, we can end up racing/flapping
267    // between picking a /boot partition on startup. So check amount of filesystems labeled 'boot'
268    // and warn user if it's not only one
269    match get_filesystems_with_label("boot", true) {
270        Ok(pts) => {
271            if pts.len() > 1 {
272                let rootdev = fs::canonicalize(device)
273                    .unwrap_or_else(|_| PathBuf::from(device))
274                    .to_string_lossy()
275                    .to_string();
276                let pts = pts
277                    .iter()
278                    .filter(|pt| !pt.contains(&rootdev))
279                    .collect::<Vec<_>>();
280                eprintln!("\nNote: detected other devices with a filesystem labeled `boot`:");
281                for pt in pts {
282                    eprintln!("  - {pt}");
283                }
284                eprintln!("The installed OS may not work correctly if there are multiple boot filesystems.
285Before rebooting, investigate whether these filesystems are needed and consider
286wiping them with `wipefs -a`.\n"
287                );
288            }
289        }
290        Err(e) => eprintln!("checking filesystems labeled 'boot': {e:?}"),
291    }
292
293    eprintln!("Install complete.");
294    Ok(())
295}
296
297fn parse_partition_filters(labels: &[&str], indexes: &[&str]) -> Result<Vec<PartitionFilter>> {
298    use PartitionFilter::*;
299    let mut filters: Vec<PartitionFilter> = Vec::new();
300
301    // partition label globs
302    for glob in labels {
303        let filter = Label(
304            glob::Pattern::new(glob)
305                .with_context(|| format!("couldn't parse label glob '{glob}'"))?,
306        );
307        filters.push(filter);
308    }
309
310    // partition index ranges
311    let parse_index = |i: &str| -> Result<Option<NonZeroU32>> {
312        match i {
313            "" => Ok(None), // open end of range
314            _ => Ok(Some(
315                NonZeroU32::new(
316                    i.parse()
317                        .with_context(|| format!("couldn't parse partition index '{i}'"))?,
318                )
319                .context("partition index cannot be zero")?,
320            )),
321        }
322    };
323    for range in indexes {
324        let parts: Vec<&str> = range.split('-').collect();
325        let filter = match parts.len() {
326            1 => Index(parse_index(parts[0])?, parse_index(parts[0])?),
327            2 => Index(parse_index(parts[0])?, parse_index(parts[1])?),
328            _ => bail!("couldn't parse partition index range '{}'", range),
329        };
330        match filter {
331            Index(None, None) => bail!(
332                "both ends of partition index range '{}' cannot be open",
333                range
334            ),
335            Index(Some(x), Some(y)) if x > y => bail!(
336                "start of partition index range '{}' cannot be greater than end",
337                range
338            ),
339            _ => filters.push(filter),
340        };
341    }
342    Ok(filters)
343}
344
345fn ensure_exclusive_access(device: &str) -> Result<()> {
346    let mut parts = Disk::new(device)?.get_busy_partitions()?;
347    if parts.is_empty() {
348        return Ok(());
349    }
350    parts.sort_unstable_by_key(|p| p.path.to_string());
351    eprintln!("Partitions in use on {device}:");
352    for part in parts {
353        if let Some(mountpoint) = part.mountpoint.as_ref() {
354            eprintln!("    {} mounted on {}", part.path, mountpoint);
355        }
356        if part.swap {
357            eprintln!("    {} is swap device", part.path);
358        }
359        for holder in part.get_holders()? {
360            eprintln!("    {} in use by {}", part.path, holder);
361        }
362    }
363    bail!("found busy partitions");
364}
365
366/// Copy the image source to the target disk and do all post-processing.
367/// If this function fails, the caller should wipe the partition table
368/// to ensure the user doesn't boot from a partially-written disk.
369fn write_disk(
370    config: &InstallConfig,
371    source: &mut ImageSource,
372    dest: &mut File,
373    table: &mut dyn PartTable,
374    saved: &SavedPartitions,
375    ignition: Option<File>,
376    network_config: Option<&str>,
377) -> Result<()> {
378    let device = config.dest_device.as_deref().expect("device missing");
379
380    // Get sector size of destination, for comparing with image
381    let sector_size = get_sector_size(dest)?;
382
383    // copy the image
384    #[allow(clippy::match_bool, clippy::match_single_binding)]
385    let image_copy = match is_dasd(device, Some(dest))? {
386        #[cfg(target_arch = "s390x")]
387        true => s390x::image_copy_s390x,
388        _ => image_copy_default,
389    };
390    write_image(
391        source,
392        dest,
393        Path::new(device),
394        image_copy,
395        true,
396        Some(saved),
397        Some(sector_size),
398        VerifyKeys::Production,
399    )?;
400    table.reread()?;
401
402    // postprocess
403    if ignition.is_some()
404        || config.firstboot_args.is_some()
405        || !config.append_karg.is_empty()
406        || !config.delete_karg.is_empty()
407        || config.platform.is_some()
408        || !config.console.is_empty()
409        || network_config.is_some()
410        || cfg!(target_arch = "s390x")
411    {
412        let mount = Disk::new(device)?.mount_partition_by_label("boot", mount::MsFlags::empty())?;
413        if let Some(ignition) = ignition.as_ref() {
414            write_ignition(mount.mountpoint(), &config.ignition_hash, ignition)
415                .context("writing Ignition configuration")?;
416        }
417        if let Some(platform) = config.platform.as_ref() {
418            write_platform(mount.mountpoint(), platform).context("writing platform ID")?;
419        }
420        if config.platform.is_some() || !config.console.is_empty() {
421            write_console(
422                mount.mountpoint(),
423                config.platform.as_deref(),
424                &config.console,
425            )
426            .context("configuring console")?;
427        }
428        if let Some(firstboot_args) = config.firstboot_args.as_ref() {
429            write_firstboot_kargs(mount.mountpoint(), firstboot_args)
430                .context("writing firstboot kargs")?;
431        }
432        if !config.append_karg.is_empty() || !config.delete_karg.is_empty() {
433            eprintln!("Modifying kernel arguments");
434
435            Console::maybe_warn_on_kargs(&config.append_karg, "--append-karg", "--console");
436            visit_bls_entry_options(mount.mountpoint(), |orig_options: &str| {
437                KargsEditor::new()
438                    .append(config.append_karg.as_slice())
439                    .delete(config.delete_karg.as_slice())
440                    .maybe_apply_to(orig_options)
441            })
442            .context("deleting and appending kargs")?;
443        }
444        if let Some(network_config) = network_config.as_ref() {
445            copy_network_config(mount.mountpoint(), network_config)?;
446        }
447        #[cfg(target_arch = "s390x")]
448        {
449            s390x::zipl(
450                mount.mountpoint(),
451                None,
452                None,
453                s390x::ZiplSecexMode::Disable,
454                None,
455            )?;
456            s390x::chreipl(device)?;
457            if config.secure_ipl {
458                s390x::set_loaddev(device)?;
459            }
460        }
461    }
462
463    // detect any latent write errors
464    dest.sync_all().context("syncing data to disk")?;
465
466    Ok(())
467}
468
469/// Write the Ignition config.
470fn write_ignition(
471    mountpoint: &Path,
472    digest_in: &Option<IgnitionHash>,
473    mut config_in: &File,
474) -> Result<()> {
475    eprintln!("Writing Ignition config");
476
477    // Verify configuration digest, if any.
478    if let Some(digest) = &digest_in {
479        digest
480            .validate(&mut config_in)
481            .context("failed to validate Ignition configuration digest")?;
482        config_in
483            .rewind()
484            .context("rewinding Ignition configuration file")?;
485    };
486
487    // make parent directory
488    let mut config_dest = mountpoint.to_path_buf();
489    config_dest.push("ignition");
490    if !config_dest.is_dir() {
491        fs::create_dir_all(&config_dest).with_context(|| {
492            format!(
493                "creating Ignition config directory {}",
494                config_dest.display()
495            )
496        })?;
497        // Ignition data may contain secrets; restrict to root
498        fs::set_permissions(&config_dest, Permissions::from_mode(0o700)).with_context(|| {
499            format!(
500                "setting file mode for Ignition directory {}",
501                config_dest.display()
502            )
503        })?;
504    }
505
506    // do the copy
507    config_dest.push("config.ign");
508    let mut config_out = OpenOptions::new()
509        .write(true)
510        .create_new(true)
511        .open(&config_dest)
512        .with_context(|| {
513            format!(
514                "opening destination Ignition config {}",
515                config_dest.display()
516            )
517        })?;
518    // Ignition config may contain secrets; restrict to root
519    fs::set_permissions(&config_dest, Permissions::from_mode(0o600)).with_context(|| {
520        format!(
521            "setting file mode for destination Ignition config {}",
522            config_dest.display()
523        )
524    })?;
525    io::copy(&mut config_in, &mut config_out).context("writing Ignition config")?;
526
527    Ok(())
528}
529
530/// Write first-boot kernel arguments.
531fn write_firstboot_kargs(mountpoint: &Path, args: &str) -> Result<()> {
532    eprintln!("Writing first-boot kernel arguments");
533
534    // write the arguments
535    let mut config_dest = mountpoint.to_path_buf();
536    config_dest.push("ignition.firstboot");
537    // if the file doesn't already exist, fail, since our assumptions
538    // are wrong
539    let mut config_out = OpenOptions::new()
540        .append(true)
541        .open(&config_dest)
542        .with_context(|| format!("opening first-boot file {}", config_dest.display()))?;
543    let contents = format!("set ignition_network_kcmdline=\"{args}\"\n");
544    config_out
545        .write_all(contents.as_bytes())
546        .context("writing first-boot kernel arguments")?;
547
548    Ok(())
549}
550
551#[derive(Clone, Default, Deserialize)]
552struct PlatformSpec {
553    #[serde(default)]
554    grub_commands: Vec<String>,
555    #[serde(default)]
556    kernel_arguments: Vec<String>,
557}
558
559/// Override the platform ID.
560fn write_platform(mountpoint: &Path, platform: &str) -> Result<()> {
561    // early return if setting the platform to the default value, since
562    // otherwise we'll think we failed to set it
563    if platform == "metal" {
564        return Ok(());
565    }
566    eprintln!("Setting platform to {platform}");
567
568    // We assume that we will only install from metal images and that the
569    // bootloader configs will always set ignition.platform.id.
570    visit_bls_entry_options(mountpoint, |orig_options: &str| {
571        let new_options = KargsEditor::new()
572            .replace(&[format!("ignition.platform.id=metal={platform}")])
573            .apply_to(orig_options)
574            .context("setting platform ID argument")?;
575        if orig_options == new_options {
576            bail!("couldn't locate platform ID");
577        }
578        Ok(Some(new_options))
579    })?;
580    Ok(())
581}
582
583/// Configure console kernel arguments and GRUB commands.
584fn write_console(mountpoint: &Path, platform: Option<&str>, consoles: &[Console]) -> Result<()> {
585    // read platforms table
586    let platforms = match fs::read_to_string(mountpoint.join("coreos/platforms.json")) {
587        Ok(json) => serde_json::from_str::<HashMap<String, PlatformSpec>>(&json)
588            .context("parsing platform table")?,
589        // no table for this image?
590        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(),
591        Err(e) => return Err(e).context("reading platform table"),
592    };
593
594    let mut kargs = Vec::new();
595    let mut grub_commands = Vec::new();
596    if !consoles.is_empty() {
597        // custom console settings completely override platform-specific
598        // defaults
599        let mut grub_terminals = Vec::new();
600        for console in consoles {
601            kargs.push(console.karg());
602            if let Some(cmd) = console.grub_command() {
603                grub_commands.push(cmd);
604            }
605            grub_terminals.push(console.grub_terminal());
606        }
607        grub_terminals.sort_unstable();
608        grub_terminals.dedup();
609        for direction in ["input", "output"] {
610            grub_commands.push(format!("terminal_{direction} {}", grub_terminals.join(" ")));
611        }
612    } else if let Some(platform) = platform {
613        // platform-specific defaults
614        if platform == "metal" {
615            // we're just being asked to apply the defaults which are already
616            // applied
617            return Ok(());
618        }
619        let spec = platforms.get(platform).cloned().unwrap_or_default();
620        kargs.extend(spec.kernel_arguments);
621        grub_commands.extend(spec.grub_commands);
622    } else {
623        // nothing to do and the caller shouldn't have called us
624        unreachable!();
625    }
626
627    // set kargs, removing any metal-specific ones
628    let metal_spec = platforms.get("metal").cloned().unwrap_or_default();
629    visit_bls_entry_options(mountpoint, |orig_options: &str| {
630        KargsEditor::new()
631            .append(&kargs)
632            .delete(&metal_spec.kernel_arguments)
633            .maybe_apply_to(orig_options)
634            .context("setting platform kernel arguments")
635    })?;
636
637    // set grub commands
638    if grub_commands != metal_spec.grub_commands {
639        // prefer the new grub2/console.cfg, but fallback to grub2/grub.cfg
640        let mut name = "grub2/console.cfg";
641        let mut path = mountpoint.join(name);
642        if !path.exists() {
643            name = "grub2/grub.cfg";
644            path = mountpoint.join(name);
645        }
646        let grub_cfg = fs::read_to_string(&path).with_context(|| format!("reading {name}"))?;
647        let new_grub_cfg = update_grub_cfg_console_settings(&grub_cfg, &grub_commands)
648            .with_context(|| format!("updating {name}"))?;
649        fs::write(&path, new_grub_cfg).with_context(|| format!("writing {name}"))?;
650    }
651    Ok(())
652}
653
654/// Rewrite the grub.cfg CONSOLE-SETTINGS block to use the specified GRUB
655/// commands, and return the result.
656fn update_grub_cfg_console_settings(grub_cfg: &str, commands: &[String]) -> Result<String> {
657    let mut new_commands = commands.join("\n");
658    if !new_commands.is_empty() {
659        new_commands.push('\n');
660    }
661    let re = Regex::new(GRUB_CFG_CONSOLE_SETTINGS_RE).unwrap();
662    if !re.is_match(grub_cfg) {
663        bail!("missing substitution marker in grub.cfg");
664    }
665    Ok(re
666        .replace(grub_cfg, |caps: &Captures| {
667            format!(
668                "{}{}{}",
669                caps.name("prefix").expect("didn't match prefix").as_str(),
670                new_commands,
671                caps.name("suffix").expect("didn't match suffix").as_str()
672            )
673        })
674        .into_owned())
675}
676
677/// Copy networking config if asked to do so
678fn copy_network_config(mountpoint: &Path, net_config_src: &str) -> Result<()> {
679    eprintln!("Copying networking configuration from {net_config_src}");
680
681    // get the path to the destination directory
682    let net_config_dest = mountpoint.join("coreos-firstboot-network");
683
684    // make the directory if it doesn't exist
685    fs::create_dir_all(&net_config_dest).with_context(|| {
686        format!(
687            "creating destination networking config directory {}",
688            net_config_dest.display()
689        )
690    })?;
691
692    // copy files from source to destination directories
693    for entry in fs::read_dir(net_config_src)
694        .with_context(|| format!("reading directory {net_config_src}"))?
695    {
696        let entry = entry.with_context(|| format!("reading directory {net_config_src}"))?;
697        let srcpath = entry.path();
698        let destpath = net_config_dest.join(entry.file_name());
699        if srcpath.is_file() {
700            eprintln!("Copying {} to installed system", srcpath.display());
701            fs::copy(&srcpath, destpath).context("Copying networking config")?;
702        }
703    }
704
705    Ok(())
706}
707
708/// Clear the partition table and restore saved partitions.  For use after
709/// a failure.
710fn reset_partition_table(
711    config: &InstallConfig,
712    dest: &mut File,
713    table: &mut dyn PartTable,
714    saved: &SavedPartitions,
715) -> Result<()> {
716    eprintln!("Resetting partition table");
717    let device = config.dest_device.as_deref().expect("device missing");
718
719    if is_dasd(device, Some(dest))? {
720        // Don't write out a GPT, since the backup GPT may overwrite
721        // something we're not allowed to touch.  Just clear the first MiB
722        // of disk.
723        dest.rewind().context("seeking to start of disk")?;
724        let zeroes = [0u8; 1024 * 1024];
725        dest.write_all(&zeroes)
726            .context("clearing primary partition table")?;
727    } else {
728        // Write a new GPT including any saved partitions.
729        saved
730            .overwrite(dest)
731            .context("restoring saved partitions")?;
732    }
733
734    // Finish writeback and reread the partition table.
735    dest.sync_all().context("syncing partition table to disk")?;
736    table.reread()?;
737
738    Ok(())
739}
740
741// Preserve saved partitions by writing them to a file in /tmp and reporting
742// the path.
743fn stash_saved_partitions(disk: &mut File, saved: &SavedPartitions) -> Result<()> {
744    let mut stash = tempfile::Builder::new()
745        .prefix("coreos-installer-partitions.")
746        .tempfile()
747        .context("creating partition stash file")?;
748    let path = stash.path().to_owned();
749    eprintln!("Storing saved partition entries to {}", path.display());
750    let len = disk.seek(SeekFrom::End(0)).context("seeking disk")?;
751    stash
752        .as_file()
753        .set_len(len)
754        .with_context(|| format!("extending partition stash file {}", path.display()))?;
755    saved
756        .overwrite(stash.as_file_mut())
757        .with_context(|| format!("stashing saved partitions to {}", path.display()))?;
758    stash
759        .keep()
760        .with_context(|| format!("retaining saved partition stash in {}", path.display()))?;
761    Ok(())
762}
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767
768    #[test]
769    fn test_parse_partition_filters() {
770        use PartitionFilter::*;
771
772        let g = |v| Label(glob::Pattern::new(v).unwrap());
773        let i = |v| Some(NonZeroU32::new(v).unwrap());
774
775        assert_eq!(
776            parse_partition_filters(&["foo", "z*b?", ""], &["1", "7-7", "2-4", "-3", "4-"])
777                .unwrap(),
778            vec![
779                g("foo"),
780                g("z*b?"),
781                g(""),
782                Index(i(1), i(1)),
783                Index(i(7), i(7)),
784                Index(i(2), i(4)),
785                Index(None, i(3)),
786                Index(i(4), None)
787            ]
788        );
789
790        let bad_globs = vec![("***", "couldn't parse label glob '***'")];
791        for (glob, err) in bad_globs {
792            assert_eq!(
793                &parse_partition_filters(&["f", glob, "z*"], &["7-", "34"])
794                    .unwrap_err()
795                    .to_string(),
796                err
797            );
798        }
799
800        let bad_ranges = vec![
801            ("", "both ends of partition index range '' cannot be open"),
802            ("-", "both ends of partition index range '-' cannot be open"),
803            ("--", "couldn't parse partition index range '--'"),
804            ("0", "partition index cannot be zero"),
805            ("-2-3", "couldn't parse partition index range '-2-3'"),
806            ("23q", "couldn't parse partition index '23q'"),
807            ("23-45.7", "couldn't parse partition index '45.7'"),
808            ("0x7", "couldn't parse partition index '0x7'"),
809            (
810                "9-7",
811                "start of partition index range '9-7' cannot be greater than end",
812            ),
813        ];
814        for (range, err) in bad_ranges {
815            assert_eq!(
816                &parse_partition_filters(&["f", "z*"], &["7-", range, "34"])
817                    .unwrap_err()
818                    .to_string(),
819                err
820            );
821        }
822    }
823
824    #[test]
825    fn test_update_grub_cfg() {
826        let base_cfgs = vec![
827            // no existing commands
828            "a\nb\n# CONSOLE-SETTINGS-START\n# CONSOLE-SETTINGS-END\nc\nd",
829            // one existing command
830            "a\nb\n# CONSOLE-SETTINGS-START\nas df\n# CONSOLE-SETTINGS-END\nc\nd",
831            // multiple existing commands
832            "a\nb\n# CONSOLE-SETTINGS-START\nas df\nghjkl\n# CONSOLE-SETTINGS-END\nc\nd",
833        ];
834        for cfg in base_cfgs {
835            // no new commands
836            assert_eq!(
837                update_grub_cfg_console_settings(cfg, &[]).unwrap(),
838                "a\nb\n# CONSOLE-SETTINGS-START\n# CONSOLE-SETTINGS-END\nc\nd"
839            );
840            // one new command
841            assert_eq!(
842                update_grub_cfg_console_settings(cfg, &["first".into()]).unwrap(),
843                "a\nb\n# CONSOLE-SETTINGS-START\nfirst\n# CONSOLE-SETTINGS-END\nc\nd"
844            );
845            // multiple new commands
846            assert_eq!(
847                update_grub_cfg_console_settings(cfg, &["first".into(), "sec ond".into(), "third".into()]).unwrap(),
848                "a\nb\n# CONSOLE-SETTINGS-START\nfirst\nsec ond\nthird\n# CONSOLE-SETTINGS-END\nc\nd"
849            );
850        }
851
852        // missing substitution marker
853        update_grub_cfg_console_settings("a\nb\nc\nd", &[]).unwrap_err();
854    }
855}