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}