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