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}