Skip to main content

libcoreinst/cmdline/
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
15//! Struct definition and support code for install subcommand.
16
17use anyhow::{Context, Result};
18use clap::Parser;
19use reqwest::Url;
20use serde::{Deserialize, Serialize};
21use serde_with::{serde_as, skip_serializing_none, DisplayFromStr};
22use std::default::Default;
23use std::ffi::OsStr;
24use std::fs::OpenOptions;
25
26use crate::io::IgnitionHash;
27
28use super::console::Console;
29use super::serializer;
30use super::types::*;
31use super::Cmd;
32
33// Args are listed in --help in the order declared in these structs/enums.
34// Please keep the entire help text to 80 columns.
35
36const ADVANCED: &str = "Advanced Options";
37
38// As a special case, this struct supports Serialize and Deserialize for
39// config file parsing.  Here are the rules.  Build or test should fail if
40// you break anything too badly.
41// - Defaults cannot be specified using #[arg(default_value = "x")]
42//   because serde won't see them otherwise.  Instead, use
43//   #[arg(default_value_t)], implement Default, and derive Clone and
44//   PartialEq for the type.  (For string-typed defaults, you can use
45//   DefaultedString<T> where T is a custom type implementing
46//   DefaultString.)
47// - Add #[serde(skip_serializing_if = "is_default")] for all fields that
48//   are not Option<T>.
49// - Custom types used in fields should implement Display and FromStr, then
50//   implement Serialize/Deserialize by deriving SerializeDisplay/
51//   DeserializeFromStr.
52// - reqwest::Url doesn't implement Serialize/Deserialize, but does implement
53//   Display and FromStr, so use #[serde_as(as = "Option<DisplayFromStr>")].
54// - Use #[serde(skip)] for any option that shouldn't be supported in config
55//   files.
56#[serde_as]
57#[skip_serializing_none]
58#[derive(Debug, Default, Parser, Serialize, Deserialize, PartialEq, Eq)]
59#[serde(rename_all = "kebab-case", default, deny_unknown_fields)]
60#[command(args_override_self = true)]
61pub struct InstallConfig {
62    /// YAML config file with install options
63    ///
64    /// Load additional config options from the specified YAML config file.
65    /// Later config files override earlier ones, and command-line options
66    /// override config files.
67    ///
68    /// Config file keys are long option names without the leading "--".
69    /// Values are strings for non-repeatable options, arrays of strings for
70    /// repeatable options, and "true" for flags.  The destination device
71    /// can be specified with the "dest-device" key.
72    #[serde(skip)]
73    #[arg(short, long, value_name = "path")]
74    pub config_file: Vec<String>,
75
76    // ways to specify the image source
77    /// Fedora CoreOS stream
78    ///
79    /// The name of the Fedora CoreOS stream to install, such as "stable",
80    /// "testing", or "next".
81    #[arg(short, long, value_name = "name")]
82    #[arg(conflicts_with_all = ["image_file", "image_url"])]
83    pub stream: Option<String>,
84    /// Manually specify the image URL
85    ///
86    /// coreos-installer appends ".sig" to find the GPG signature for the
87    /// image, which must exist and be valid.  A missing signature can be
88    /// ignored with --insecure.
89    #[serde_as(as = "Option<DisplayFromStr>")]
90    #[arg(short = 'u', long, value_name = "URL")]
91    #[arg(conflicts_with_all = ["stream", "image_file"])]
92    pub image_url: Option<Url>,
93    /// Manually specify a local image file
94    ///
95    /// coreos-installer appends ".sig" to find the GPG signature for the
96    /// image, which must exist and be valid.  A missing signature can be
97    /// ignored with --insecure.
98    #[arg(short = 'f', long, value_name = "path")]
99    #[arg(conflicts_with_all = ["stream", "image_url"])]
100    pub image_file: Option<String>,
101
102    // postprocessing options
103    /// Embed an Ignition config from a file
104    ///
105    /// Embed the specified Ignition config in the installed system.
106    // deprecated long name from <= 0.1.2
107    #[arg(short, long, alias = "ignition", value_name = "path")]
108    #[arg(conflicts_with = "ignition_url")]
109    pub ignition_file: Option<String>,
110    /// Embed an Ignition config from a URL
111    ///
112    /// Immediately fetch the Ignition config from the URL and embed it in
113    /// the installed system.
114    #[serde_as(as = "Option<DisplayFromStr>")]
115    #[arg(short = 'I', long, value_name = "URL")]
116    #[arg(conflicts_with = "ignition_file")]
117    pub ignition_url: Option<Url>,
118    /// Digest (type-value) of the Ignition config
119    ///
120    /// Verify that the Ignition config matches the specified digest,
121    /// formatted as <type>-<hexvalue>.  <type> can be sha256 or sha512.
122    #[arg(long, value_name = "digest")]
123    pub ignition_hash: Option<IgnitionHash>,
124    /// Target CPU architecture
125    ///
126    /// Create an install disk for a different CPU architecture than the
127    /// host.
128    #[serde(skip_serializing_if = "is_default")]
129    #[arg(short, long, default_value_t, value_name = "name")]
130    pub architecture: DefaultedString<Architecture>,
131    /// Override the Ignition platform ID
132    ///
133    /// Install a system that will run on the specified cloud or
134    /// virtualization platform, such as "vmware".
135    #[arg(short, long, value_name = "name")]
136    pub platform: Option<String>,
137    /// Kernel and bootloader console
138    ///
139    /// Set the kernel and bootloader console, using the same syntax as the
140    /// parameter to the "console=" kernel argument.
141    #[serde(skip_serializing_if = "is_default")]
142    #[arg(long, value_name = "spec")]
143    pub console: Vec<Console>,
144    /// Additional kernel args for the first boot
145    // This used to be for configuring networking from the cmdline, but it has
146    // been obsoleted by the nicer `--copy-network` approach. We still need it
147    // for now though. It's used at least by `coreos-installer.service`.
148    #[serde(skip)]
149    #[arg(long, hide = true, value_name = "args")]
150    pub firstboot_args: Option<String>,
151    /// Append default kernel arg
152    ///
153    /// Add a kernel argument to the installed system.
154    #[serde(skip_serializing_if = "is_default")]
155    #[arg(long, value_name = "arg")]
156    pub append_karg: Vec<String>,
157    /// Delete default kernel arg
158    ///
159    /// Delete a default kernel argument from the installed system.
160    #[serde(skip_serializing_if = "is_default")]
161    #[arg(long, value_name = "arg")]
162    pub delete_karg: Vec<String>,
163    /// Copy network config from install environment
164    ///
165    /// Copy NetworkManager keyfiles from the install environment to the
166    /// installed system.
167    #[serde(skip_serializing_if = "is_default")]
168    #[arg(short = 'n', long)]
169    pub copy_network: bool,
170    /// Override NetworkManager keyfile dir for -n
171    ///
172    /// Specify the path to NetworkManager keyfiles to be copied with
173    /// --copy-network.
174    ///
175    /// [default: /etc/NetworkManager/system-connections/]
176    #[serde(skip_serializing_if = "is_default")]
177    #[arg(long, value_name = "path", default_value_t)]
178    // showing the default converts every option to multiline help
179    #[arg(hide_default_value = true)]
180    pub network_dir: DefaultedString<NetworkDir>,
181    /// Save partitions with this label glob
182    ///
183    /// Preserve any existing partitions on the destination device whose
184    /// partition label (not filesystem label) matches the specified glob
185    /// pattern.  Multiple patterns can be specified in multiple options, or
186    /// in a single option separated by commas.
187    ///
188    /// Saved partitions will be renumbered if necessary.  If partitions
189    /// overlap with the install image, or installation fails for any other
190    /// reason, the specified partitions will still be preserved.
191    #[serde(skip_serializing_if = "is_default")]
192    #[arg(long, value_name = "lx")]
193    // Allow argument multiple times, but one value each.  Allow "a,b" in
194    // one argument.
195    #[arg(value_delimiter = ',')]
196    pub save_partlabel: Vec<String>,
197    /// Save partitions with this number or range
198    ///
199    /// Preserve any existing partitions on the destination device whose
200    /// partition number matches the specified value or range.  Ranges can
201    /// be bounded on both ends ("5-7", inclusive) or one end ("5-" or "-7").
202    /// Multiple numbers or ranges can be specified in multiple options, or
203    /// in a single option separated by commas.
204    ///
205    /// Saved partitions will be renumbered if necessary.  If partitions
206    /// overlap with the install image, or installation fails for any other
207    /// reason, the specified partitions will still be preserved.
208    #[serde(skip_serializing_if = "is_default")]
209    #[arg(long, value_name = "id")]
210    // Allow argument multiple times, but one value each.  Allow "1-5,7" in
211    // one argument.
212    #[arg(value_delimiter = ',')]
213    // Allow ranges like "-2".
214    #[arg(allow_hyphen_values = true)]
215    pub save_partindex: Vec<String>,
216
217    // obscure options without short names
218    /// Force offline installation
219    #[serde(skip_serializing_if = "is_default")]
220    #[arg(long, help_heading = ADVANCED)]
221    pub offline: bool,
222    /// Allow unsigned image
223    ///
224    /// Allow the signature to be absent.  Does not allow an existing
225    /// signature to be invalid.
226    #[serde(skip_serializing_if = "is_default")]
227    #[arg(long, help_heading = ADVANCED)]
228    pub insecure: bool,
229    /// Allow Ignition URL without HTTPS or hash
230    #[serde(skip_serializing_if = "is_default")]
231    #[arg(long, help_heading = ADVANCED)]
232    pub insecure_ignition: bool,
233    /// Base URL for CoreOS stream metadata
234    ///
235    /// Override the base URL for fetching CoreOS stream metadata.
236    /// The default is "https://builds.coreos.fedoraproject.org/streams/".
237    #[serde_as(as = "Option<DisplayFromStr>")]
238    #[arg(long, value_name = "URL", help_heading = ADVANCED)]
239    pub stream_base_url: Option<Url>,
240    /// Don't clear partition table on error
241    ///
242    /// If installation fails, coreos-installer normally clears the
243    /// destination's partition table to prevent booting from invalid
244    /// boot media.  Skip clearing the partition table as a debugging aid.
245    #[serde(skip_serializing_if = "is_default")]
246    #[arg(long, help_heading = ADVANCED)]
247    pub preserve_on_error: bool,
248    /// Fetch retries, or "infinite"
249    ///
250    /// Number of times to retry network fetches, or the string "infinite"
251    /// to retry indefinitely.
252    #[serde(skip_serializing_if = "is_default")]
253    #[arg(long, value_name = "N", default_value_t, help_heading = ADVANCED)]
254    pub fetch_retries: FetchRetries,
255    /// Enable IBM Secure IPL
256    #[serde(skip_serializing_if = "is_default")]
257    #[arg(long, help_heading = ADVANCED)]
258    pub secure_ipl: bool,
259
260    // positional args
261    /// Destination device
262    ///
263    /// Path to the device node for the destination disk.  The beginning of
264    /// the device will be overwritten without further confirmation.
265    #[arg(required_unless_present = "config_file")]
266    pub dest_device: Option<String>,
267}
268
269impl InstallConfig {
270    pub fn expand_config_files(self) -> Result<Self> {
271        if self.config_file.is_empty() {
272            return Ok(self);
273        }
274
275        let mut args = self
276            .config_file
277            .iter()
278            .map(|path| {
279                serde_yaml::from_reader::<_, InstallConfig>(
280                    OpenOptions::new()
281                        .read(true)
282                        .open(path)
283                        .with_context(|| format!("opening config file {path}"))?,
284                )
285                .with_context(|| format!("parsing config file {path}"))?
286                .to_args()
287                .with_context(|| format!("serializing config file {path}"))
288            })
289            .collect::<Result<Vec<Vec<_>>>>()?
290            .into_iter()
291            .flatten()
292            .chain(
293                self.to_args()
294                    .context("serializing command-line arguments")?,
295            )
296            .collect::<Vec<_>>();
297
298        // If firstboot-args is defined, add it manually
299        if let Some(firstboot_args) = &self.firstboot_args {
300            args.push("--firstboot-args".to_string());
301            args.push(firstboot_args.clone());
302        }
303
304        println!("Running with arguments: {}", args.join(" "));
305        Self::from_args(&args)
306    }
307
308    fn from_args<T: AsRef<OsStr>>(args: &[T]) -> Result<Self> {
309        match Cmd::try_parse_from(
310            vec![
311                std::env::args_os().next().expect("no program name"),
312                "install".into(),
313            ]
314            .into_iter()
315            .chain(args.iter().map(<_>::into)),
316        )
317        .context("reprocessing command-line arguments")?
318        {
319            Cmd::Install(c) => Ok(c),
320            _ => unreachable!(),
321        }
322    }
323
324    fn to_args(&self) -> Result<Vec<String>> {
325        serializer::to_args(self)
326    }
327}
328
329#[cfg(test)]
330mod test {
331    use super::*;
332    use std::io::Write;
333    use std::num::NonZeroU32;
334    use std::str::FromStr;
335    use tempfile::NamedTempFile;
336
337    /// Check that full InstallConfig serializes as expected
338    #[test]
339    fn serialize_full_install_config() {
340        let config = InstallConfig {
341            // skipped
342            config_file: vec!["a".into(), "b".into()],
343            stream: Some("c".into()),
344            image_url: Some(Url::parse("http://example.com/d").unwrap()),
345            image_file: Some("e".into()),
346            ignition_file: Some("f".into()),
347            ignition_url: Some(Url::parse("http://example.com/g").unwrap()),
348            ignition_hash: Some(
349                IgnitionHash::from_str(
350                    "sha256-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
351                )
352                .unwrap(),
353            ),
354            architecture: DefaultedString::<Architecture>::from_str("h").unwrap(),
355            platform: Some("i".into()),
356            console: vec![
357                Console::from_str("ttyS0").unwrap(),
358                Console::from_str("ttyS1,115200n8").unwrap(),
359            ],
360            // skipped
361            firstboot_args: Some("j".into()),
362            append_karg: vec!["k".into(), "l".into()],
363            delete_karg: vec!["m".into(), "n".into()],
364            copy_network: true,
365            network_dir: DefaultedString::<NetworkDir>::from_str("o").unwrap(),
366            save_partlabel: vec!["p".into(), "q".into()],
367            save_partindex: vec!["r".into(), "s".into()],
368            offline: true,
369            insecure: true,
370            insecure_ignition: true,
371            stream_base_url: Some(Url::parse("http://example.com/t").unwrap()),
372            preserve_on_error: true,
373            fetch_retries: FetchRetries::from_str("3").unwrap(),
374            secure_ipl: true,
375            dest_device: Some("u".into()),
376        };
377        let expected = vec![
378            "--stream",
379            "c",
380            "--image-url",
381            "http://example.com/d",
382            "--image-file",
383            "e",
384            "--ignition-file",
385            "f",
386            "--ignition-url",
387            "http://example.com/g",
388            "--ignition-hash",
389            "sha256-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
390            "--architecture",
391            "h",
392            "--platform",
393            "i",
394            "--console",
395            // we round-trip to an equivalent but not identical value
396            "ttyS0,9600n8",
397            "--console",
398            "ttyS1,115200n8",
399            "--append-karg",
400            "k",
401            "--append-karg",
402            "l",
403            "--delete-karg",
404            "m",
405            "--delete-karg",
406            "n",
407            "--copy-network",
408            "--network-dir",
409            "o",
410            "--save-partlabel",
411            "p",
412            "--save-partlabel",
413            "q",
414            "--save-partindex",
415            "r",
416            "--save-partindex",
417            "s",
418            "--offline",
419            "--insecure",
420            "--insecure-ignition",
421            "--stream-base-url",
422            "http://example.com/t",
423            "--preserve-on-error",
424            "--fetch-retries",
425            "3",
426            "--secure-ipl",
427            "u",
428        ];
429        assert_eq!(config.to_args().unwrap(), expected);
430    }
431
432    /// Test that full config file deserializes as expected
433    #[test]
434    fn parse_full_install_config_file() {
435        let mut f = NamedTempFile::new().unwrap();
436        f.as_file_mut()
437            .write_all(
438                r#"
439image-url: http://example.com/d
440ignition-url: http://example.com/g
441ignition-hash: sha256-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
442architecture: h
443platform: i
444console: [ttyS0, "ttyS1,115200n8"]
445append-karg: [k, l]
446delete-karg: [m, n]
447copy-network: true
448network-dir: o
449save-partlabel: [p, q]
450save-partindex: [r, s]
451offline: true
452insecure: true
453insecure-ignition: true
454stream-base-url: http://example.com/t
455preserve-on-error: true
456fetch-retries: 3
457dest-device: u
458"#
459                .as_bytes(),
460            )
461            .unwrap();
462        let expected = InstallConfig {
463            // skipped
464            config_file: Vec::new(),
465            // conflict
466            stream: None,
467            image_url: Some(Url::parse("http://example.com/d").unwrap()),
468            // conflict
469            image_file: None,
470            // conflict
471            ignition_file: None,
472            ignition_url: Some(Url::parse("http://example.com/g").unwrap()),
473            ignition_hash: Some(
474                IgnitionHash::from_str(
475                    "sha256-e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
476                )
477                .unwrap(),
478            ),
479            architecture: DefaultedString::<Architecture>::from_str("h").unwrap(),
480            platform: Some("i".into()),
481            console: vec![
482                Console::from_str("ttyS0").unwrap(),
483                Console::from_str("ttyS1,115200n8").unwrap(),
484            ],
485            // skipped
486            firstboot_args: None,
487            append_karg: vec!["k".into(), "l".into()],
488            delete_karg: vec!["m".into(), "n".into()],
489            copy_network: true,
490            network_dir: DefaultedString::<NetworkDir>::from_str("o").unwrap(),
491            save_partlabel: vec!["p".into(), "q".into()],
492            save_partindex: vec!["r".into(), "s".into()],
493            offline: true,
494            insecure: true,
495            insecure_ignition: true,
496            stream_base_url: Some(Url::parse("http://example.com/t").unwrap()),
497            preserve_on_error: true,
498            fetch_retries: FetchRetries::from_str("3").unwrap(),
499            secure_ipl: false,
500            dest_device: Some("u".into()),
501        };
502        let config = InstallConfig::from_args(&["--config-file", f.path().to_str().unwrap()])
503            .unwrap()
504            .expand_config_files()
505            .unwrap();
506        assert_eq!(expected, config);
507    }
508
509    /// Check that default InstallConfig serializes to empty arg list
510    #[test]
511    fn serialize_default_install_config_args() {
512        let config = InstallConfig::default();
513        let expected: Vec<String> = Vec::new();
514        assert_eq!(config.to_args().unwrap(), expected);
515    }
516
517    /// Check that default InstallConfig serializes to empty YAML doc
518    #[test]
519    fn serialize_default_install_config_yaml() {
520        let config = InstallConfig::default();
521        assert_eq!(
522            // serde_yaml 0.8 prefixes output with "---\n"; 0.9 doesn't
523            serde_yaml::to_string(&config).unwrap().replace("---\n", ""),
524            "{}\n"
525        );
526    }
527
528    /// Check that minimal install config file serializes to minimal arg list
529    #[test]
530    fn serialize_empty_install_config_file() {
531        let config: InstallConfig = serde_yaml::from_str("dest-device: foo").unwrap();
532        assert_eq!(config.to_args().unwrap(), vec!["foo"]);
533    }
534
535    /// Check that empty command line serializes to empty arg list
536    #[test]
537    fn serialize_empty_command_line() {
538        let expected = ["/dev/missing"];
539        let config = InstallConfig::from_args(&expected).unwrap();
540        assert_eq!(config.to_args().unwrap(), expected);
541    }
542
543    /// Test multiple config files overlapping with command-line arguments
544    #[test]
545    fn install_config_file_overlapping_field() {
546        let mut f1 = NamedTempFile::new().unwrap();
547        f1.as_file_mut()
548            .write_all(b"append-karg: [a, b]\nfetch-retries: 1")
549            .unwrap();
550        let mut f2 = NamedTempFile::new().unwrap();
551        f2.as_file_mut()
552            .write_all(b"append-karg: [c, d]\nfetch-retries: 2\ndest-device: /dev/missing")
553            .unwrap();
554        let config = InstallConfig::from_args(&[
555            "--append-karg",
556            "e",
557            "--fetch-retries",
558            "0",
559            "--config-file",
560            f2.path().to_str().unwrap(),
561            "--config-file",
562            f1.path().to_str().unwrap(),
563            "--append-karg",
564            "f",
565            "--fetch-retries",
566            "3",
567        ])
568        .unwrap()
569        .expand_config_files()
570        .unwrap();
571        assert_eq!(config.append_karg, ["c", "d", "a", "b", "e", "f"]);
572        assert_eq!(
573            config.fetch_retries,
574            FetchRetries::Finite(NonZeroU32::new(3).unwrap())
575        );
576
577        // multiple target devices are not allowed
578        InstallConfig::from_args(&[
579            "--config-file",
580            f2.path().to_str().unwrap(),
581            "/dev/also-missing",
582        ])
583        .unwrap()
584        .expand_config_files()
585        .unwrap_err();
586    }
587
588    /// Test that firstboot-args is manually added to args list when defined
589    #[test]
590    fn test_firstboot_args_manually_added() {
591        let mut f = NamedTempFile::new().unwrap();
592        f.as_file_mut().write_all(b"dest-device: /dev/sda").unwrap();
593
594        let config = InstallConfig::from_args(&[
595            "--config-file",
596            f.path().to_str().unwrap(),
597            "--firstboot-args",
598            "ip=dhcp",
599        ])
600        .unwrap();
601
602        // Verify firstboot-args is defined
603        assert!(config.firstboot_args.is_some());
604        assert_eq!(config.firstboot_args.as_ref().unwrap(), "ip=dhcp");
605
606        // Test expand_config_files to verify manual addition
607        let expanded = config.expand_config_files().unwrap();
608
609        // Should still have firstboot-args
610        assert!(expanded.firstboot_args.is_some());
611        assert_eq!(expanded.firstboot_args.unwrap(), "ip=dhcp");
612    }
613}