blue_build/commands/
generate_iso.rs

1use std::{
2    env, fs,
3    path::{self, Path, PathBuf},
4};
5
6use blue_build_recipe::Recipe;
7use blue_build_utils::{
8    constants::{ARCHIVE_SUFFIX, BB_SKIP_VALIDATION},
9    string_vec,
10    traits::CowCollecter,
11};
12use bon::Builder;
13use clap::{Args, Subcommand, ValueEnum};
14use miette::{Context, IntoDiagnostic, Result, bail};
15use oci_distribution::Reference;
16use tempfile::TempDir;
17
18use blue_build_process_management::{
19    drivers::{Driver, DriverArgs, RunDriver, opts::RunOpts},
20    run_volumes,
21};
22
23use super::{BlueBuildCommand, build::BuildCommand};
24
25#[derive(Clone, Debug, Builder, Args)]
26pub struct GenerateIsoCommand {
27    #[command(subcommand)]
28    command: GenIsoSubcommand,
29
30    /// The directory to save the resulting ISO file.
31    #[arg(short, long)]
32    #[builder(into)]
33    output_dir: Option<PathBuf>,
34
35    /// The variant of the installer to use.
36    ///
37    /// The Kinoite variant will ask for a user
38    /// and password before installing the OS.
39    /// This version is the most stable and is
40    /// recommended.
41    ///
42    /// The Silverblue variant will ask for a user
43    /// and password on first boot after the OS
44    /// is installed.
45    ///
46    /// The Server variant is the basic installer
47    /// and will ask to setup a user at install time.
48    #[arg(short = 'V', long, default_value = "kinoite")]
49    variant: GenIsoVariant,
50
51    /// The url to the secure boot public key.
52    ///
53    /// Defaults to one of UBlue's public key.
54    /// It's recommended to change this if your base
55    /// image is not from UBlue.
56    #[arg(
57        long,
58        default_value = "https://github.com/ublue-os/bazzite/raw/main/secure_boot.der"
59    )]
60    #[builder(into)]
61    secure_boot_url: String,
62
63    /// The enrollment password for the secure boot
64    /// key.
65    ///
66    /// Default's to UBlue's enrollment password.
67    /// It's recommended to change this if your base
68    /// image is not from UBlue.
69    #[arg(long, default_value = "universalblue")]
70    #[builder(into)]
71    enrollment_password: String,
72
73    /// The name of your ISO image file.
74    #[arg(long)]
75    #[builder(into)]
76    iso_name: Option<String>,
77
78    /// The location to temporarily store files
79    /// while building. If unset, it will use `/tmp`.
80    #[arg(long)]
81    tempdir: Option<PathBuf>,
82
83    #[clap(flatten)]
84    #[builder(default)]
85    drivers: DriverArgs,
86}
87
88#[derive(Debug, Clone, Subcommand)]
89pub enum GenIsoSubcommand {
90    /// Build an ISO from a remote image.
91    Image {
92        /// The image ref to create the iso from.
93        #[arg()]
94        image: String,
95    },
96    /// Build an ISO from a recipe.
97    ///
98    /// This will build the image locally first
99    /// before creating the ISO. This is a long
100    /// process.
101    Recipe {
102        /// The path to the recipe file for your image.
103        #[arg()]
104        recipe: PathBuf,
105
106        /// Skips validation of the recipe file.
107        #[arg(long, env = BB_SKIP_VALIDATION)]
108        skip_validation: bool,
109    },
110}
111
112#[derive(Debug, Default, Clone, Copy, ValueEnum)]
113pub enum GenIsoVariant {
114    #[default]
115    Kinoite,
116    Silverblue,
117    Server,
118}
119
120impl std::fmt::Display for GenIsoVariant {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        write!(
123            f,
124            "{}",
125            match *self {
126                Self::Server => "Server",
127                Self::Silverblue => "Silverblue",
128                Self::Kinoite => "Kinoite",
129            }
130        )
131    }
132}
133
134impl BlueBuildCommand for GenerateIsoCommand {
135    fn try_run(&mut self) -> Result<()> {
136        Driver::init(self.drivers);
137
138        let image_out_dir = if let Some(ref dir) = self.tempdir {
139            TempDir::new_in(dir).into_diagnostic()?
140        } else {
141            TempDir::new().into_diagnostic()?
142        };
143
144        let output_dir = if let Some(output_dir) = self.output_dir.clone() {
145            if output_dir.exists() && !output_dir.is_dir() {
146                bail!("The '--output-dir' arg must be a directory");
147            }
148
149            if !output_dir.exists() {
150                fs::create_dir(&output_dir).into_diagnostic()?;
151            }
152
153            path::absolute(output_dir).into_diagnostic()?
154        } else {
155            env::current_dir().into_diagnostic()?
156        };
157
158        if let GenIsoSubcommand::Recipe {
159            recipe,
160            skip_validation,
161        } = &self.command
162        {
163            BuildCommand::builder()
164                .recipe(vec![recipe.clone()])
165                .archive(image_out_dir.path())
166                .maybe_tempdir(self.tempdir.clone())
167                .skip_validation(*skip_validation)
168                .build()
169                .try_run()?;
170        }
171
172        let iso_name = self.iso_name.as_ref().map_or("deploy.iso", String::as_str);
173        let iso_path = output_dir.join(iso_name);
174
175        if iso_path.exists() {
176            fs::remove_file(iso_path).into_diagnostic()?;
177        }
178
179        self.build_iso(iso_name, &output_dir, image_out_dir.path())
180    }
181}
182
183impl GenerateIsoCommand {
184    fn build_iso(&self, iso_name: &str, output_dir: &Path, image_out_dir: &Path) -> Result<()> {
185        let mut args = string_vec![
186            format!("VARIANT={}", self.variant),
187            format!("ISO_NAME=build/{iso_name}"),
188            "DNF_CACHE=/cache/dnf",
189            format!("SECURE_BOOT_KEY_URL={}", self.secure_boot_url),
190            format!("ENROLLMENT_PASSWORD={}", self.enrollment_password),
191        ];
192        let mut vols = run_volumes![
193            output_dir.display().to_string() => "/build-container-installer/build",
194            "dnf-cache" => "/cache/dnf/",
195        ];
196
197        match &self.command {
198            GenIsoSubcommand::Image { image } => {
199                let image: Reference = image
200                    .parse()
201                    .into_diagnostic()
202                    .with_context(|| format!("Unable to parse image reference {image}"))?;
203                let (image_repo, image_name) = {
204                    let registry = image.resolve_registry();
205                    let repo = image.repository();
206                    let image = format!("{registry}/{repo}");
207
208                    let mut image_parts = image.split('/').collect::<Vec<_>>();
209                    let image_name = image_parts.pop().unwrap(); // Should be at least 2 elements
210                    let image_repo = image_parts.join("/");
211                    (image_repo, image_name.to_string())
212                };
213
214                args.extend([
215                    format!("IMAGE_NAME={image_name}",),
216                    format!("IMAGE_REPO={image_repo}"),
217                    format!("IMAGE_TAG={}", image.tag().unwrap_or("latest")),
218                    format!(
219                        "VERSION={}",
220                        Driver::get_os_version().oci_ref(&image).call()?
221                    ),
222                ]);
223            }
224            GenIsoSubcommand::Recipe {
225                recipe,
226                skip_validation: _,
227            } => {
228                let recipe = Recipe::parse(recipe)?;
229
230                args.extend([
231                    format!(
232                        "IMAGE_SRC=oci-archive:/img_src/{}.{ARCHIVE_SUFFIX}",
233                        recipe.name.replace('/', "_"),
234                    ),
235                    format!(
236                        "VERSION={}",
237                        Driver::get_os_version()
238                            .oci_ref(&recipe.base_image_ref()?)
239                            .call()?,
240                    ),
241                ]);
242                vols.extend(run_volumes![
243                    image_out_dir.display().to_string() => "/img_src/",
244                ]);
245            }
246        }
247
248        // Currently testing local tarball builds
249        let opts = RunOpts::builder()
250            .image("ghcr.io/jasonn3/build-container-installer")
251            .privileged(true)
252            .remove(true)
253            .args(args.collect_cow_vec())
254            .volumes(vols)
255            .build();
256
257        let status = Driver::run(&opts)?;
258
259        if !status.success() {
260            bail!("Failed to create ISO");
261        }
262        Ok(())
263    }
264}