Skip to main content

cnf_lib/environment/
toolbx.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// SPDX-FileCopyrightText: (C) 2023 Andreas Hartmann <hartan@7x.de>
3// This file is part of cnf-lib, available at <https://gitlab.com/hartang/rust/cnf>
4
5//! # Toolbx Environment Handler
6//!
7//! This module handles command-not-found errors that occur while executing inside a Toolbx
8//! container. Currently, this means that commands are forwarded to the host using `flatpak-spawn`.
9//! If `flatpak-spawn` isn't present, an error is thrown instead.
10use super::prelude::*;
11
12use std::path::Path;
13
14const TOOLBX_ENV: &str = "/run/.toolboxenv";
15const CONTAINER_ENV: &str = "/run/.containerenv";
16const OS_RELEASE: &str = "/etc/os-release";
17
18/// Environment for a Toolbx container
19#[derive(PartialEq, Eq, Debug, PartialOrd, Ord, Serialize, Deserialize)]
20pub struct Toolbx {
21    name: String,
22}
23
24impl fmt::Display for Toolbx {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        write!(f, "toolbx '{}'", self.name)
27    }
28}
29
30impl Toolbx {
31    /// Spawn a Toolbx container with a given name.
32    ///
33    /// Checks if the toolbx container exists and starts it, if necessary. If `None` is given as
34    /// name, will try to determine the default toolbx name and start that instead. Returns an
35    /// error if unsuccessful.
36    pub fn new(name: Option<String>) -> Result<Toolbx, NewToolbxError> {
37        let name = match name {
38            Some(name) if !name.is_empty() => name,
39            _ => match Toolbx::default_name() {
40                Ok(toolbx_name) => toolbx_name,
41                Err(e) => return Err(NewToolbxError::UnknownDefault(e)),
42            },
43        };
44
45        // Do an optimistic start:
46        // - If the container exists and isn't started, it will be started
47        // - If the container exists and is started, nothing happens
48        // - If the container doesn't exist, we get an error and report that
49        let ret = Self { name: name.clone() };
50        ret.start()
51            .map_err(|e| NewToolbxError::CannotStart { source: e, name })?;
52        Ok(ret)
53    }
54
55    /// Starts a given toolbx container.
56    ///
57    /// This function is automatically called by `new()` above and should only ever be called when
58    /// creating a `Toolbx` object without using the constructor. This is currently the case when
59    /// executing aliases in `cnf`, as the `Toolbx` instance is deserialized from the config in
60    /// that case.
61    pub fn start(&self) -> Result<(), StartToolbxError> {
62        let output = std::process::Command::new("podman")
63            .args(["start", &self.name])
64            .output()
65            .map_err(|e| match e.kind() {
66                std::io::ErrorKind::NotFound => StartToolbxError::NeedPodman,
67                _ => StartToolbxError::IoError(e),
68            })?;
69        if output.status.success() {
70            // All good
71            Ok(())
72        } else {
73            let matcher = OutputMatcher::new(&output);
74            if matcher.starts_with("Error: no container with name or ID")
75                && matcher.contains("found: no such container")
76            {
77                Err(StartToolbxError::NonExistent(self.name.clone()))
78            } else {
79                Err(StartToolbxError::Podman(output))
80            }
81        }
82    }
83
84    /// Get the Toolbx container currently executing CNF.
85    ///
86    /// Will return an error if the current execution environment isn't Toolbx.
87    pub fn current() -> Result<Toolbx, CurrentToolbxError> {
88        if !detect() {
89            return Err(CurrentToolbxError::NotAToolbx);
90        }
91
92        let content = std::fs::read_to_string(CONTAINER_ENV).map_err(|e| {
93            CurrentToolbxError::Environment {
94                env_file: CONTAINER_ENV.to_string(),
95                source: e,
96            }
97        })?;
98        let name = content
99            .lines()
100            .find(|line| line.contains("name=\""))
101            .ok_or_else(|| CurrentToolbxError::Name(CONTAINER_ENV.to_string()))?
102            .trim_start_matches("name=\"")
103            .trim_end_matches('"');
104
105        Ok(Toolbx {
106            name: name.to_string(),
107        })
108    }
109
110    /// Get the name of this toolbx container.
111    pub fn name(&self) -> &str {
112        &self.name
113    }
114
115    /// Get the name of the default toolbx to lookup/execute commands in.
116    ///
117    /// The default toolbx container name is assembled from the contents of `/etc/os-release`.
118    pub fn default_name() -> Result<String, DefaultToolbxError> {
119        // Construct default toolbox name by hand. Format is $ID-toolbox-$VERSION_ID, with ID
120        // and VERSION_ID taken from /etc/os-release. See here:
121        // https://containertoolbx.org/distros/
122        debug!("Determining default toolbx name via {}", OS_RELEASE);
123
124        let content =
125            std::fs::read_to_string(OS_RELEASE).map_err(|e| DefaultToolbxError::UnknownOs {
126                file: OS_RELEASE.to_string(),
127                source: e,
128            })?;
129        let id = content
130            .lines()
131            .find(|line| line.starts_with("ID="))
132            .map(|line| line.trim_start_matches("ID=").trim_matches('"'))
133            .ok_or(DefaultToolbxError::Id)?;
134        let version_id = content
135            .lines()
136            .find(|line| line.starts_with("VERSION_ID="))
137            .map(|line| line.trim_start_matches("VERSION_ID=").trim_matches('"'))
138            .ok_or(DefaultToolbxError::VersionId)?;
139
140        Ok(format!("{}-toolbox-{}", id, version_id))
141    }
142}
143
144#[async_trait]
145impl environment::IsEnvironment for Toolbx {
146    type Err = Error;
147
148    async fn exists(&self) -> bool {
149        if detect() {
150            true
151        } else if let Environment::Host(host) = environment::current() {
152            // The result in this case is indeed `Infallible`, but switching an `if-let` for an
153            // `unwrap` is outright stupid IMO.
154            #[allow(irrefutable_let_patterns)]
155            if let Ok(mut cmd) = host
156                .execute(crate::environment::cmd!("toolbox", "--version"))
157                .await
158            {
159                cmd.stdout(std::process::Stdio::null())
160                    .stderr(std::process::Stdio::null())
161                    .status()
162                    .await
163                    .map(|status| status.success())
164                    .unwrap_or(false)
165            } else {
166                false
167            }
168        } else {
169            false
170        }
171    }
172
173    async fn execute(&self, command: CommandLine) -> Result<Command, Self::Err> {
174        debug!("preparing execution: {}", command);
175        let mut cmd: Command;
176
177        match environment::current() {
178            Environment::Distrobox(_) => {
179                return Err(Error::Unimplemented(
180                    "running in a toolbx from a distrobox".to_string(),
181                ));
182            }
183            Environment::Toolbx(t) => {
184                if self == &t {
185                    // This is the toolbx container we are currently running in
186                    // We expect toolbx containers to *always* run a unix OS, or at least something
187                    // that has `sudo`.
188                    if command.get_privileged() {
189                        cmd = Command::new("sudo");
190                        if !command.get_interactive() {
191                            cmd.arg("-n");
192                        }
193
194                        cmd.arg(command.command());
195                    } else {
196                        cmd = Command::new(command.command());
197                    }
198
199                    cmd.args(command.args());
200                } else {
201                    return Err(Error::Unimplemented(
202                        "running in a toolbx from another toolbx".to_string(),
203                    ));
204                }
205            }
206            Environment::Host(_) => {
207                cmd = Command::new("toolbox");
208
209                cmd.args(["run", "-c"]);
210                // Can't run command in toolbx if we don't have one
211                cmd.arg(&self.name);
212
213                // This is the real command we're looking for (with arguments)
214                if command.get_privileged() {
215                    cmd.args(["sudo", "-S", "-E"]);
216                    // NOTE: We ignore `get_interactive` here. because toolbox seems to do weird
217                    // things regarding sudo. When adding the `-n` flag to request non-interactive
218                    // auth, sudo will fail, requiring a pssword. However, factually running `sudo`
219                    // in a toolbx container *does not* require a password under normal
220                    // circumstances. Just ignoring interactivity here solves this issue (but don't
221                    // ask me why).
222                }
223
224                cmd.arg(command.command()).args(command.args());
225            }
226            #[cfg(test)]
227            Environment::Mock(_) => unimplemented!(),
228        }
229
230        trace!("full command: {:?}", cmd);
231        Ok(cmd)
232    }
233}
234
235/// Detect if the current execution environment is a Toolbx container.
236///
237/// Checks for the presence of the `.toolboxenv` files.
238pub fn detect() -> bool {
239    Path::new(TOOLBX_ENV).exists()
240}
241
242/// Errors related to starting concrete Toolbx instances.
243#[derive(Debug, ThisError, Display)]
244pub enum StartToolbxError {
245    /// working with toolbx containers requires the 'podman' executable
246    NeedPodman,
247
248    /// podman exited with non-zero code: {0:#?}
249    Podman(std::process::Output),
250
251    /// no toolbx with name {0} exists
252    NonExistent(String),
253
254    /// unknown I/O error occured
255    IoError(#[from] std::io::Error),
256}
257
258/// Errors related to starting a named Toolbx instance.
259#[derive(Debug, ThisError, Display)]
260pub enum NewToolbxError {
261    /// failed to determine default toolbx name
262    UnknownDefault(#[from] DefaultToolbxError),
263
264    /// failed to start toolbx container with name '{name}': {source}
265    CannotStart {
266        /// Underlying error source.
267        source: StartToolbxError,
268        /// Name of the Toolbx that failed to start.
269        name: String,
270    },
271}
272
273/// Errors related to distrobox as environment that launched `cnf`.
274#[derive(Debug, ThisError, Display)]
275pub enum CurrentToolbxError {
276    /// cannot read toolbx info from environment file '{env_file}': {source}
277    Environment {
278        /// environment file that couldn't be read.
279        env_file: String,
280        /// Error from trying to read the environment file.
281        source: std::io::Error,
282    },
283
284    /// program currently isn't run from a toolbx
285    NotAToolbx,
286
287    /// failed to read toolbx name from environment file '{0}'
288    Name(String),
289}
290
291/// Errors related to the configured default distrobox container.
292#[derive(Debug, ThisError, Display)]
293pub enum DefaultToolbxError {
294    /// failed to read OS information from '{file}': {source}
295    UnknownOs {
296        /// File that couldn't be read.
297        file: String,
298        /// Error from trying to read the file.
299        source: std::io::Error,
300    },
301
302    /// cannot determine OS ID from os-release info
303    Id,
304
305    /// cannot determine OS VERSION_ID from os-release info
306    VersionId,
307}
308
309/// Error type for environment impl.
310#[derive(Debug, ThisError, Display)]
311pub enum Error {
312    /// cannot determine current working directory
313    UnknownCwd(#[from] std::io::Error),
314
315    /// not implemented: {0}
316    Unimplemented(String),
317}