blue_build/commands/
switch.rs

1use std::{
2    path::{Path, PathBuf},
3    time::Duration,
4};
5
6use blue_build_process_management::{
7    drivers::{Driver, DriverArgs},
8    logging::CommandLogging,
9};
10use blue_build_recipe::Recipe;
11use blue_build_utils::{
12    constants::{
13        ARCHIVE_SUFFIX, BB_SKIP_VALIDATION, LOCAL_BUILD, OCI_ARCHIVE, OSTREE_UNVERIFIED_IMAGE,
14        SUDO_ASKPASS,
15    },
16    has_env_var, running_as_root,
17};
18use bon::Builder;
19use clap::Args;
20use comlexr::cmd;
21use indicatif::ProgressBar;
22use log::{debug, trace};
23use miette::{IntoDiagnostic, Result, bail};
24use tempfile::TempDir;
25
26use crate::{commands::build::BuildCommand, rpm_ostree_status::RpmOstreeStatus};
27
28use super::BlueBuildCommand;
29
30#[derive(Default, Clone, Debug, Builder, Args)]
31pub struct SwitchCommand {
32    /// The recipe file to build an image.
33    #[arg()]
34    recipe: PathBuf,
35
36    /// Reboot your system after
37    /// the update is complete.
38    #[arg(short, long)]
39    #[builder(default)]
40    reboot: bool,
41
42    /// The location to temporarily store files
43    /// while building. If unset, it will use `/tmp`.
44    #[arg(long)]
45    tempdir: Option<PathBuf>,
46
47    /// Skips validation of the recipe file.
48    #[arg(long, env = BB_SKIP_VALIDATION)]
49    #[builder(default)]
50    skip_validation: bool,
51
52    #[clap(flatten)]
53    #[builder(default)]
54    drivers: DriverArgs,
55}
56
57impl BlueBuildCommand for SwitchCommand {
58    fn try_run(&mut self) -> Result<()> {
59        trace!("SwitchCommand::try_run()");
60
61        Driver::init(self.drivers);
62
63        let status = RpmOstreeStatus::try_new()?;
64        trace!("{status:?}");
65
66        if status.transaction_in_progress() {
67            bail!("There is a transaction in progress. Please cancel it using `rpm-ostree cancel`");
68        }
69
70        let tempdir = if let Some(ref dir) = self.tempdir {
71            TempDir::new_in(dir).into_diagnostic()?
72        } else {
73            TempDir::new().into_diagnostic()?
74        };
75        trace!("{tempdir:?}");
76
77        BuildCommand::builder()
78            .recipe([self.recipe.clone()])
79            .archive(tempdir.path())
80            .maybe_tempdir(self.tempdir.clone())
81            .skip_validation(self.skip_validation)
82            .build()
83            .try_run()?;
84
85        let recipe = Recipe::parse(&self.recipe)?;
86        let image_file_name = format!(
87            "{}.{ARCHIVE_SUFFIX}",
88            recipe.name.to_lowercase().replace('/', "_")
89        );
90        let temp_file_path = tempdir.path().join(&image_file_name);
91        let archive_path = Path::new(LOCAL_BUILD).join(&image_file_name);
92
93        Self::clean_local_build_dir()?;
94        Self::move_archive(&temp_file_path, &archive_path)?;
95
96        // We drop the tempdir ahead of time so that the directory
97        // can be cleaned out.
98        drop(tempdir);
99
100        self.switch(&archive_path, &status)
101    }
102}
103
104impl SwitchCommand {
105    fn switch(&self, archive_path: &Path, status: &RpmOstreeStatus<'_>) -> Result<()> {
106        trace!(
107            "SwitchCommand::switch({}, {status:#?})",
108            archive_path.display()
109        );
110
111        let status = if status.is_booted_on_archive(archive_path)
112            || status.is_staged_on_archive(archive_path)
113        {
114            let command = cmd!("rpm-ostree", "upgrade", if self.reboot => "--reboot");
115
116            trace!("{command:?}");
117            command
118        } else {
119            let image_ref = format!(
120                "{OSTREE_UNVERIFIED_IMAGE}:{OCI_ARCHIVE}:{path}",
121                path = archive_path.display()
122            );
123
124            let command = cmd!(
125                "rpm-ostree",
126                "rebase",
127                &image_ref,
128                if self.reboot => "--reboot",
129            );
130
131            trace!("{command:?}");
132            command
133        }
134        .build_status(
135            format!("{}", archive_path.display()),
136            "Switching to new image",
137        )
138        .into_diagnostic()?;
139
140        if !status.success() {
141            bail!("Failed to switch to new image!");
142        }
143        Ok(())
144    }
145
146    fn move_archive(from: &Path, to: &Path) -> Result<()> {
147        trace!(
148            "SwitchCommand::move_archive({}, {})",
149            from.display(),
150            to.display()
151        );
152
153        let progress = ProgressBar::new_spinner();
154        progress.enable_steady_tick(Duration::from_millis(100));
155        progress.set_message(format!("Moving image archive to {}...", to.display()));
156
157        let status = {
158            let c = cmd!(
159                if running_as_root() {
160                    "mv"
161                } else {
162                    "sudo"
163                },
164                if !running_as_root() && has_env_var(SUDO_ASKPASS) => [
165                    "-A",
166                    "-p",
167                    format!("Password needed to move {from:?} to {to:?}"),
168                ],
169                if !running_as_root() => "mv",
170                from,
171                to,
172            );
173            trace!("{c:?}");
174            c
175        }
176        .status()
177        .into_diagnostic()?;
178
179        progress.finish_and_clear();
180
181        if !status.success() {
182            bail!(
183                "Failed to move archive from {from} to {to}",
184                from = from.display(),
185                to = to.display()
186            );
187        }
188
189        Ok(())
190    }
191
192    fn clean_local_build_dir() -> Result<()> {
193        trace!("SwitchCommand::clean_local_build_dir()");
194
195        let local_build_path = Path::new(LOCAL_BUILD);
196
197        if local_build_path.exists() {
198            debug!("Cleaning out build dir {LOCAL_BUILD}");
199
200            let mut command = {
201                let c = cmd!(
202                    if running_as_root() {
203                        "ls"
204                    } else {
205                        "sudo"
206                    },
207                    if !running_as_root() && has_env_var(SUDO_ASKPASS) => [
208                        "-A",
209                        "-p",
210                        format!("Password required to list files in {LOCAL_BUILD}"),
211                    ],
212                    if !running_as_root() => "ls",
213                    LOCAL_BUILD
214                );
215                trace!("{c:?}");
216                c
217            };
218            let output =
219                String::from_utf8(command.output().into_diagnostic()?.stdout).into_diagnostic()?;
220
221            trace!("{output}");
222
223            let files = output
224                .lines()
225                .filter(|line| line.ends_with(ARCHIVE_SUFFIX))
226                .map(|file| local_build_path.join(file).display().to_string())
227                .collect::<Vec<_>>();
228
229            if !files.is_empty() {
230                let progress = ProgressBar::new_spinner();
231                progress.enable_steady_tick(Duration::from_millis(100));
232                progress.set_message("Removing old image archive files...");
233
234                let status = {
235                    let c = cmd!(
236                        if running_as_root() {
237                            "rm"
238                        } else {
239                            "sudo"
240                        },
241                        if !running_as_root() && has_env_var(SUDO_ASKPASS) => [
242                            "-A",
243                            "-p",
244                            format!("Password required to remove files: {files:?}"),
245                        ],
246                        if !running_as_root() => "rm",
247                        "-f",
248                        for files,
249                    );
250                    trace!("{c:?}");
251                    c
252                }
253                .status()
254                .into_diagnostic()?;
255
256                progress.finish_and_clear();
257
258                if !status.success() {
259                    bail!("Failed to clean out archives in {LOCAL_BUILD}");
260                }
261            }
262        } else {
263            debug!(
264                "Creating build output dir at {}",
265                local_build_path.display()
266            );
267
268            let status = {
269                let c = cmd!(
270                    if running_as_root() {
271                        "mkdir"
272                    } else {
273                        "sudo"
274                    },
275                    if !running_as_root() && has_env_var(SUDO_ASKPASS) => [
276                        "-A",
277                        "-p",
278                        format!("Password needed to create directory {local_build_path:?}"),
279                    ],
280                    if !running_as_root() => "mkdir",
281                    "-p",
282                    local_build_path,
283                );
284                trace!("{c:?}");
285                c
286            }
287            .status()
288            .into_diagnostic()?;
289
290            if !status.success() {
291                bail!("Failed to create directory {LOCAL_BUILD}");
292            }
293        }
294
295        Ok(())
296    }
297}