Skip to main content

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