max / alloy
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 files changed,
+518 insertions,
-21 deletions
| @@ -5,8 +5,18 @@ | |||
| 5 | 5 | //! not run, and it cannot run one it does not show. docs/CONSOLE.md — "the | |
| 6 | 6 | //! console is not trying to hide the CLI, it's trying to make the CLI | |
| 7 | 7 | //! approachable" — is enforced structurally rather than by remembering to log. | |
| 8 | + | //! | |
| 9 | + | //! [`Invocation`] is the common case and [`Effect`] is the general one. Almost | |
| 10 | + | //! everything the console does is running someone else's CLI, but not quite | |
| 11 | + | //! everything: the `workspace` export wrapper is a file Alloy writes, because | |
| 12 | + | //! that level has no `distrobox-export` to call (wiki `alloy-package-ux`, "Why | |
| 13 | + | //! `workspace` is podman directly"). Rather than let that one action slip the | |
| 14 | + | //! log by not being a command, [`Effect`] widens what the log can describe. | |
| 15 | + | //! The promise is that the console shows what it did, and a write is something | |
| 16 | + | //! it did. | |
| 8 | 17 | ||
| 9 | 18 | use std::collections::VecDeque; | |
| 19 | + | use std::path::{Path, PathBuf}; | |
| 10 | 20 | use std::process::Command; | |
| 11 | 21 | ||
| 12 | 22 | use alloy_tui::{LogEntry, Severity}; | |
| @@ -192,10 +202,131 @@ | |||
| 192 | 202 | } | |
| 193 | 203 | } | |
| 194 | 204 | ||
| 205 | + | /// Something the console does, which is usually but not always a command. | |
| 206 | + | /// | |
| 207 | + | /// Backends return these rather than performing them, for the same reason | |
| 208 | + | /// [`Invocation`] is returned rather than run: it keeps the log's coverage | |
| 209 | + | /// structural, and it keeps the backends testable on a machine with none of | |
| 210 | + | /// the tools installed. See the module docs for why the enum exists at all. | |
| 211 | + | #[derive(Debug, Clone)] | |
| 212 | + | pub enum Effect { | |
| 213 | + | /// Run a command. | |
| 214 | + | Run(Invocation), | |
| 215 | + | /// Write a file, replacing whatever was there. | |
| 216 | + | /// | |
| 217 | + | /// `mode` rather than a bool for the executable bit: every write this has | |
| 218 | + | /// so far is a wrapper script, but spelling the permission out keeps the | |
| 219 | + | /// variant from quietly meaning "and also chmod +x". | |
| 220 | + | Write { | |
| 221 | + | path: PathBuf, | |
| 222 | + | contents: String, | |
| 223 | + | mode: u32, | |
| 224 | + | }, | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | impl Effect { | |
| 228 | + | /// The one-line description the log shows. | |
| 229 | + | /// | |
| 230 | + | /// A [`Effect::Run`] displays as its argv, which the user can paste into a | |
| 231 | + | /// shell. A [`Effect::Write`] cannot round-trip that way — the contents are | |
| 232 | + | /// a whole file — so it names the verb and the path instead. Being honest | |
| 233 | + | /// about the shape beats contorting a heredoc into the pane. | |
| 234 | + | pub fn display(&self) -> String { | |
| 235 | + | match self { | |
| 236 | + | Effect::Run(invocation) => invocation.display(), | |
| 237 | + | Effect::Write { path, .. } => format!("write {}", contract_home(path)), | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | /// Perform it, recording what was done and whether it worked. | |
| 242 | + | pub fn apply(&self, log: &mut CommandLog) -> Result<()> { | |
| 243 | + | match self { | |
| 244 | + | Effect::Run(invocation) => invocation.run(log).map(drop), | |
| 245 | + | Effect::Write { | |
| 246 | + | path, | |
| 247 | + | contents, | |
| 248 | + | mode, | |
| 249 | + | } => { | |
| 250 | + | let result = write_file(path, contents, *mode); | |
| 251 | + | log.record( | |
| 252 | + | self.display(), | |
| 253 | + | if result.is_ok() { Severity::Healthy } else { Severity::Error }, | |
| 254 | + | ); | |
| 255 | + | result | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | fn write_file(path: &Path, contents: &str, mode: u32) -> Result<()> { | |
| 262 | + | use std::os::unix::fs::PermissionsExt; | |
| 263 | + | ||
| 264 | + | // The parent is `~/.local/bin` on a fresh account that has never had one. | |
| 265 | + | // Creating it is part of the job; failing because the directory the write | |
| 266 | + | // was always going to need does not exist yet would be a footgun with an | |
| 267 | + | // obvious fix nobody should have to find. | |
| 268 | + | if let Some(parent) = path.parent() { | |
| 269 | + | std::fs::create_dir_all(parent) | |
| 270 | + | .with_context(|| format!("failed to create {}", contract_home(parent)))?; | |
| 271 | + | } | |
| 272 | + | std::fs::write(path, contents) | |
| 273 | + | .with_context(|| format!("failed to write {}", contract_home(path)))?; | |
| 274 | + | std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) | |
| 275 | + | .with_context(|| format!("failed to chmod {}", contract_home(path))) | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | /// A path with the home directory folded back to `~`. | |
| 279 | + | /// | |
| 280 | + | /// Only for display. The log pane is narrow and every path this writes lives | |
| 281 | + | /// under the home directory, so spelling it out costs a fifth of the line to | |
| 282 | + | /// say something the reader already knows. | |
| 283 | + | fn contract_home(path: &Path) -> String { | |
| 284 | + | let shown = path.display().to_string(); | |
| 285 | + | let Some(home) = std::env::var_os("HOME") else { | |
| 286 | + | return shown; | |
| 287 | + | }; | |
| 288 | + | let home = home.to_string_lossy(); | |
| 289 | + | if home.is_empty() { | |
| 290 | + | return shown; | |
| 291 | + | } | |
| 292 | + | match shown.strip_prefix(home.as_ref()) { | |
| 293 | + | Some(rest) => format!("~{rest}"), | |
| 294 | + | None => shown, | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | ||
| 195 | 298 | #[cfg(test)] | |
| 196 | 299 | mod tests { | |
| 197 | 300 | use super::*; | |
| 198 | 301 | ||
| 302 | + | // A write cannot round-trip into a shell the way an argv can — the contents | |
| 303 | + | // are a whole file — so it names the verb and the path instead of pretending | |
| 304 | + | // to be a command. | |
| 305 | + | #[test] | |
| 306 | + | fn a_write_effect_describes_itself_as_a_write() { | |
| 307 | + | let effect = Effect::Write { | |
| 308 | + | path: PathBuf::from("/opt/thing/rg"), | |
| 309 | + | contents: "#!/bin/sh\n".to_string(), | |
| 310 | + | mode: 0o755, | |
| 311 | + | }; | |
| 312 | + | assert_eq!(effect.display(), "write /opt/thing/rg"); | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | // Every path this writes is under the home directory, and the log pane is | |
| 316 | + | // narrow enough that spelling it out costs a fifth of the line. | |
| 317 | + | #[test] | |
| 318 | + | fn a_written_path_under_home_is_shown_with_a_tilde() { | |
| 319 | + | let Some(home) = std::env::var_os("HOME") else { | |
| 320 | + | return; | |
| 321 | + | }; | |
| 322 | + | let effect = Effect::Write { | |
| 323 | + | path: PathBuf::from(home).join(".local/bin/rg"), | |
| 324 | + | contents: String::new(), | |
| 325 | + | mode: 0o755, | |
| 326 | + | }; | |
| 327 | + | assert_eq!(effect.display(), "write ~/.local/bin/rg"); | |
| 328 | + | } | |
| 329 | + | ||
| 199 | 330 | #[test] | |
| 200 | 331 | fn display_round_trips_a_plain_command() { | |
| 201 | 332 | let inv = Invocation::new("nmcli").args(["-t", "-f", "DEVICE,TYPE", "device", "status"]); |
| @@ -35,9 +35,9 @@ | |||
| 35 | 35 | //! | |
| 36 | 36 | //! Distrobox is a shell script over podman, so `workspace` is not a second | |
| 37 | 37 | //! runtime — it is the same one with the wrapper's opinions left off. The cost | |
| 38 | - | //! is that `workspace` has no `distrobox-export` and Alloy owes it a wrapper of | |
| 39 | - | //! its own, which is not written yet. See wiki `alloy-package-ux`, "Why | |
| 40 | - | //! `workspace` is podman directly". | |
| 38 | + | //! is that `workspace` has no `distrobox-export`, so Alloy writes that wrapper | |
| 39 | + | //! itself: see [`wrapper`]. See also wiki `alloy-package-ux`, "Why `workspace` | |
| 40 | + | //! is podman directly". | |
| 41 | 41 | //! | |
| 42 | 42 | //! # Backends build commands, they do not run them | |
| 43 | 43 | //! | |
| @@ -64,7 +64,7 @@ | |||
| 64 | 64 | use ratatui::text::{Line, Span}; | |
| 65 | 65 | use serde::Deserialize; | |
| 66 | 66 | ||
| 67 | - | use crate::cli::{CommandLog, Invocation}; | |
| 67 | + | use crate::cli::{CommandLog, Effect, Invocation}; | |
| 68 | 68 | use crate::shell::{Confirm, Flow, View, block_title, truncate}; | |
| 69 | 69 | ||
| 70 | 70 | /// Ticks between background refreshes. | |
| @@ -332,10 +332,7 @@ | |||
| 332 | 332 | /// The box spec file. | |
| 333 | 333 | /// | |
| 334 | 334 | /// Unknown keys are ignored rather than rejected so a spec written against a | |
| 335 | - | /// later Alloy still yields its levels. `export` is parsed nowhere yet: at | |
| 336 | - | /// `host` it is `distrobox-export`'s job, and at `workspace` it is a wrapper | |
| 337 | - | /// Alloy has to write itself (wiki `alloy-package-ux`, "Why `workspace` is | |
| 338 | - | /// podman directly"), which is its own piece of work. | |
| 335 | + | /// later Alloy still yields its levels. | |
| 339 | 336 | #[derive(Deserialize, Default)] | |
| 340 | 337 | struct SpecFile { | |
| 341 | 338 | #[serde(default)] | |
| @@ -367,6 +364,21 @@ | |||
| 367 | 364 | /// is right when only one remote carries the app and an error worth seeing | |
| 368 | 365 | /// when several do. | |
| 369 | 366 | remote: Option<String>, | |
| 367 | + | /// Which of the box's binaries reach the host PATH. | |
| 368 | + | #[serde(default)] | |
| 369 | + | export: Export, | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | /// The `export` table: what a box puts on the host PATH. | |
| 373 | + | /// | |
| 374 | + | /// A table rather than a bare list because `bin` is not the only thing a box | |
| 375 | + | /// can export — `distrobox-export` also does `--app` for desktop entries — and | |
| 376 | + | /// a spec that spells `export = ["rg"]` today has nowhere to put the second | |
| 377 | + | /// kind tomorrow without breaking every file already written. | |
| 378 | + | #[derive(Debug, Default, Deserialize)] | |
| 379 | + | struct Export { | |
| 380 | + | #[serde(default)] | |
| 381 | + | bin: Vec<String>, | |
| 370 | 382 | } | |
| 371 | 383 | ||
| 372 | 384 | impl SpecBox { | |
| @@ -377,6 +389,18 @@ | |||
| 377 | 389 | }) | |
| 378 | 390 | } | |
| 379 | 391 | ||
| 392 | + | /// The binaries this box exports, or an error saying it declares none. | |
| 393 | + | /// | |
| 394 | + | /// An error rather than an empty slice: `e` on a box with nothing to export | |
| 395 | + | /// would otherwise report success having done nothing, and the spec file is | |
| 396 | + | /// exactly where the user would go looking for why. | |
| 397 | + | fn bins(&self, name: &str) -> Result<&[String]> { | |
| 398 | + | if self.export.bin.is_empty() { | |
| 399 | + | anyhow::bail!("box `{name}` declares no `export.bin`"); | |
| 400 | + | } | |
| 401 | + | Ok(&self.export.bin) | |
| 402 | + | } | |
| 403 | + | ||
| 380 | 404 | /// The app id this box wants, or an error naming what the level requires. | |
| 381 | 405 | fn app(&self, name: &str) -> Result<&str> { | |
| 382 | 406 | self.app.as_deref().with_context(|| { | |
| @@ -416,6 +440,19 @@ | |||
| 416 | 440 | /// so one bad entry does not cost every other box its declared marker. | |
| 417 | 441 | fn create(&self, name: &str, spec: &SpecBox) -> Result<Invocation>; | |
| 418 | 442 | ||
| 443 | + | /// Put the box's declared binaries on the host PATH. | |
| 444 | + | /// | |
| 445 | + | /// The one verb whose two levels do genuinely different things, which is | |
| 446 | + | /// why it returns [`Effect`] rather than [`Invocation`]: `host` has | |
| 447 | + | /// `distrobox-export` to call, and `workspace` has no such tool because it | |
| 448 | + | /// is not distrobox, so Alloy writes the wrapper itself. Both still keep | |
| 449 | + | /// the "return, do not perform" rule, so both are testable here. | |
| 450 | + | /// | |
| 451 | + | /// A `Vec` because exporting three binaries is three effects, and the log | |
| 452 | + | /// should say so — one line per file that appeared on the PATH, not one | |
| 453 | + | /// line claiming an export happened. | |
| 454 | + | fn export(&self, name: &str, spec: &SpecBox) -> Result<Vec<Effect>>; | |
| 455 | + | ||
| 419 | 456 | /// Start `boxed`, or `None` when the concept does not apply. | |
| 420 | 457 | fn start(&self, boxed: &Box) -> Option<Invocation>; | |
| 421 | 458 | ||
| @@ -544,6 +581,55 @@ | |||
| 544 | 581 | } | |
| 545 | 582 | } | |
| 546 | 583 | ||
| 584 | + | /// `distrobox-export` at `host`, an Alloy-written wrapper at `workspace`. | |
| 585 | + | /// | |
| 586 | + | /// The split is the same one `create` makes and for the same reason: only | |
| 587 | + | /// `host` went through distrobox, so only `host` has distrobox's export | |
| 588 | + | /// tool. `distrobox-export` runs inside the container, so from here it is | |
| 589 | + | /// reached through `distrobox enter -- `, which is the documented host-side | |
| 590 | + | /// idiom for it. | |
| 591 | + | /// | |
| 592 | + | /// `/usr/bin/{bin}` is an assumption, and a flagged one: `--bin` wants an | |
| 593 | + | /// absolute path inside the container, the spec names a command rather than | |
| 594 | + | /// a path, and resolving it properly means asking the box where the command | |
| 595 | + | /// lives. Distrobox is not installed on the dev box, so this path is argv | |
| 596 | + | /// only — see the module docs on parsers checked against nothing but their | |
| 597 | + | /// own fixtures, which is the same trap. | |
| 598 | + | fn export(&self, name: &str, spec: &SpecBox) -> Result<Vec<Effect>> { | |
| 599 | + | let bins = spec.bins(name)?; | |
| 600 | + | let dir = export_dir()?; | |
| 601 | + | match spec.level { | |
| 602 | + | Level::Host => Ok(bins | |
| 603 | + | .iter() | |
| 604 | + | .map(|bin| { | |
| 605 | + | Effect::Run(Invocation::new("distrobox").args([ | |
| 606 | + | "enter", | |
| 607 | + | name, | |
| 608 | + | "--", | |
| 609 | + | "distrobox-export", | |
| 610 | + | "--bin", | |
| 611 | + | &format!("/usr/bin/{bin}"), | |
| 612 | + | "--export-path", | |
| 613 | + | &dir.to_string_lossy(), | |
| 614 | + | ])) | |
| 615 | + | }) | |
| 616 | + | .collect()), | |
| 617 | + | Level::Workspace => bins | |
| 618 | + | .iter() | |
| 619 | + | .map(|bin| { | |
| 620 | + | Ok(Effect::Write { | |
| 621 | + | path: dir.join(bin), | |
| 622 | + | contents: wrapper(name, bin, &spec.mounts)?, | |
| 623 | + | // Executable, or it is a file on the PATH that the shell | |
| 624 | + | // will not run — the whole point of putting it there. | |
| 625 | + | mode: 0o755, | |
| 626 | + | }) | |
| 627 | + | }) | |
| 628 | + | .collect(), | |
| 629 | + | Level::Sandboxed => unreachable!("the dial routes sandboxed to flatpak"), | |
| 630 | + | } | |
| 631 | + | } | |
| 632 | + | ||
| 547 | 633 | fn start(&self, boxed: &Box) -> Option<Invocation> { | |
| 548 | 634 | (!boxed.state.is_running()) | |
| 549 | 635 | .then(|| Invocation::new("podman").args(["start", &boxed.name])) | |
| @@ -602,10 +688,19 @@ | |||
| 602 | 688 | /// expansion of its own — an unexpanded `~` would silently create a directory | |
| 603 | 689 | /// with that literal name rather than mounting the home path meant. | |
| 604 | 690 | fn bind(mount: &str) -> Result<String> { | |
| 605 | - | let (path, mode) = match mount.strip_suffix(":ro") { | |
| 606 | - | Some(path) => (path, ":ro"), | |
| 607 | - | None => (mount, ""), | |
| 608 | - | }; | |
| 691 | + | let path = mount_path(mount)?; | |
| 692 | + | let mode = if mount.ends_with(":ro") { ":ro" } else { "" }; | |
| 693 | + | Ok(format!("{path}:{path}{mode}")) | |
| 694 | + | } | |
| 695 | + | ||
| 696 | + | /// The host path a mount entry names, expanded and checked. | |
| 697 | + | /// | |
| 698 | + | /// Split out from [`bind`] because the export wrapper wants the path without | |
| 699 | + | /// podman's `source:target` framing: it compares the caller's working directory | |
| 700 | + | /// against the same list, and it has to be the same expansion or the two | |
| 701 | + | /// disagree about what `~/code` means. | |
| 702 | + | fn mount_path(mount: &str) -> Result<String> { | |
| 703 | + | let path = mount.strip_suffix(":ro").unwrap_or(mount); | |
| 609 | 704 | ||
| 610 | 705 | let path = match path.strip_prefix("~/") { | |
| 611 | 706 | Some(rest) => { | |
| @@ -620,7 +715,97 @@ | |||
| 620 | 715 | if !path.starts_with('/') { | |
| 621 | 716 | anyhow::bail!("mount `{mount}` must be an absolute path or start with `~/`"); | |
| 622 | 717 | } | |
| 623 | - | Ok(format!("{path}:{path}{mode}")) | |
| 718 | + | Ok(path) | |
| 719 | + | } | |
| 720 | + | ||
| 721 | + | /// Where export wrappers land: `~/.local/bin`. | |
| 722 | + | /// | |
| 723 | + | /// Fedora puts it on the default PATH, and it is the path distrobox's own | |
| 724 | + | /// `--export-path` examples use, so `host` and `workspace` exports land in the | |
| 725 | + | /// same directory and the user has one place to look. | |
| 726 | + | fn export_dir() -> Result<std::path::PathBuf> { | |
| 727 | + | let home = std::env::var("HOME").context("exporting needs HOME set")?; | |
| 728 | + | Ok(std::path::PathBuf::from(home).join(".local").join("bin")) | |
| 729 | + | } | |
| 730 | + | ||
| 731 | + | /// The host-PATH wrapper for one binary in a `workspace` box. | |
| 732 | + | /// | |
| 733 | + | /// This is what `distrobox-export --bin` would have written if `workspace` were | |
| 734 | + | /// distrobox, and the reason Alloy owes it is that the level is podman directly | |
| 735 | + | /// (wiki `alloy-package-ux`, "Why `workspace` is podman directly"). It re-enters | |
| 736 | + | /// the box, so running the wrapper is running the command under the level's | |
| 737 | + | /// isolation — putting it on the PATH moves the convenience out, not the access. | |
| 738 | + | /// | |
| 739 | + | /// Three things it does beyond forwarding arguments: | |
| 740 | + | /// | |
| 741 | + | /// - **Starts the box first.** A `workspace` box is created stopped and stops | |
| 742 | + | /// again on reboot, and a wrapper that only works when the user happens to | |
| 743 | + | /// have pressed `s` is a wrapper that looks broken. | |
| 744 | + | /// - **Picks a working directory from the mounts.** Without this the command | |
| 745 | + | /// runs in the container's home wherever the user invoked it, so `rg pattern` | |
| 746 | + | /// in a mounted project silently searches an empty directory and reports no | |
| 747 | + | /// matches — a wrong answer, which is worse than an error. The mounts are | |
| 748 | + | /// bound at the same path in and out, so a `$PWD` under one of them is valid | |
| 749 | + | /// inside; anything else falls back to the box's home. | |
| 750 | + | /// - **Only asks for a TTY when it has one.** A wrapper on the PATH will be | |
| 751 | + | /// piped, and `podman exec -it` on a pipe does not fail — it hangs, waiting | |
| 752 | + | /// for a terminal that is never coming. Measured on fw13 by generating the | |
| 753 | + | /// wrapper with an unconditional `-it` and piping into it. A command that | |
| 754 | + | /// hangs forever is the worst of the failure modes available here, since the | |
| 755 | + | /// user has nothing to read and no reason to suspect the wrapper. | |
| 756 | + | fn wrapper(name: &str, bin: &str, mounts: &[String]) -> Result<String> { | |
| 757 | + | let box_name = quote(name)?; | |
| 758 | + | let command = quote(bin)?; | |
| 759 | + | ||
| 760 | + | let workdir = if mounts.is_empty() { | |
| 761 | + | format!("workdir={}", quote(WORKSPACE_HOME)?) | |
| 762 | + | } else { | |
| 763 | + | let arms = mounts | |
| 764 | + | .iter() | |
| 765 | + | .map(|mount| { | |
| 766 | + | let path = quote(&mount_path(mount)?)?; | |
| 767 | + | Ok(format!("{path}|{path}/*")) | |
| 768 | + | }) | |
| 769 | + | .collect::<Result<Vec<_>>>()? | |
| 770 | + | .join("|"); | |
| 771 | + | format!( | |
| 772 | + | "case \"$PWD\" in\n\ | |
| 773 | + | {arms}) workdir=\"$PWD\" ;;\n\ | |
| 774 | + | *) workdir={} ;;\n\ | |
| 775 | + | esac", | |
| 776 | + | quote(WORKSPACE_HOME)? | |
| 777 | + | ) | |
| 778 | + | }; | |
| 779 | + | ||
| 780 | + | Ok(format!( | |
| 781 | + | "#!/bin/sh\n\ | |
| 782 | + | # Generated by alloy. Rewritten on every export; edits here are lost.\n\ | |
| 783 | + | # Runs `{bin}` inside the `{name}` workspace box.\n\ | |
| 784 | + | \n\ | |
| 785 | + | podman start {box_name} >/dev/null || exit\n\ | |
| 786 | + | \n\ | |
| 787 | + | {workdir}\n\ | |
| 788 | + | \n\ | |
| 789 | + | if [ -t 0 ]; then\n\ | |
| 790 | + | \x20 exec podman exec -it --workdir \"$workdir\" {box_name} {command} \"$@\"\n\ | |
| 791 | + | fi\n\ | |
| 792 | + | exec podman exec -i --workdir \"$workdir\" {box_name} {command} \"$@\"\n" | |
| 793 | + | )) | |
| 794 | + | } | |
| 795 | + | ||
| 796 | + | /// A spec value as a single shell word. | |
| 797 | + | /// | |
| 798 | + | /// The wrapper is the one place a spec string becomes shell source rather than | |
| 799 | + | /// an argv element, so it is the one place quoting can go wrong. Single quotes | |
| 800 | + | /// make every other metacharacter inert, which leaves the single quote itself: | |
| 801 | + | /// rejected rather than escaped, because a container name or a command name | |
| 802 | + | /// containing one is a typo every time and `'\''` in a generated file is worth | |
| 803 | + | /// nobody's confusion. | |
| 804 | + | fn quote(value: &str) -> Result<String> { | |
| 805 | + | if value.contains('\'') { | |
| 806 | + | anyhow::bail!("`{value}` cannot be exported: a single quote is not allowed here"); | |
| 807 | + | } | |
| 808 | + | Ok(format!("'{value}'")) | |
| 624 | 809 | } | |
| 625 | 810 | ||
| 626 | 811 | /// Map podman's state word onto [`BoxState`], keeping anything unrecognized. | |
| @@ -741,6 +926,18 @@ | |||
| 741 | 926 | Ok(invocation.arg(app)) | |
| 742 | 927 | } | |
| 743 | 928 | ||
| 929 | + | /// Nothing to do, and saying so rather than succeeding silently. | |
| 930 | + | /// | |
| 931 | + | /// Flatpak already puts an app on the host: `flatpak install` writes the | |
| 932 | + | /// desktop entry and an `/var/lib/flatpak/exports/bin` launcher, both on the | |
| 933 | + | /// default PATH. An Alloy wrapper on top would be a second launcher for the | |
| 934 | + | /// same app, which is the two-tools-one-job problem the dial exists to end. | |
| 935 | + | /// The error names where the app already is, so a user who pressed `e` | |
| 936 | + | /// looking for it stops looking. | |
| 937 | + | fn export(&self, name: &str, _spec: &SpecBox) -> Result<Vec<Effect>> { | |
| 938 | + | anyhow::bail!("flatpak already exports `{name}`; it is on the PATH as its app id") | |
| 939 | + | } | |
| 940 | + | ||
| 744 | 941 | // A sandboxed box is one app. It is installed or it is not; there is no | |
| 745 | 942 | // container to start and stop, and offering the verbs would imply a | |
| 746 | 943 | // lifecycle that does not exist at this level. | |
| @@ -1007,19 +1204,57 @@ | |||
| 1007 | 1204 | if boxed.state != BoxState::Absent { | |
| 1008 | 1205 | anyhow::bail!("{} already exists", boxed.name); | |
| 1009 | 1206 | } | |
| 1010 | - | let name = boxed | |
| 1011 | - | .declared | |
| 1012 | - | .as_deref() | |
| 1013 | - | .context("only a declared box can be created")?; | |
| 1014 | - | let (_, spec) = self | |
| 1015 | - | .spec | |
| 1016 | - | .resolve(name) | |
| 1017 | - | .context("the box left the spec since it was read")?; | |
| 1207 | + | let (name, spec) = self.declared_spec(boxed, "created")?; | |
| 1018 | 1208 | self.backends[index].create(name, spec)?.run(log).map(drop) | |
| 1019 | 1209 | }); | |
| 1020 | 1210 | self.finish(result, log); | |
| 1021 | 1211 | } | |
| 1022 | 1212 | ||
| 1213 | + | /// The spec entry behind a row. | |
| 1214 | + | /// | |
| 1215 | + | /// Both spec-reading verbs need the same two lookups, and both can fail the | |
| 1216 | + | /// same two ways: the row is ad-hoc and has no entry, or the file changed | |
| 1217 | + | /// under a list that was read before it did. `verb` puts the caller's word | |
| 1218 | + | /// in the first message, since "only a declared box can be created" and the | |
| 1219 | + | /// same sentence about exporting are the two halves of one rule. | |
| 1220 | + | fn declared_spec(&self, boxed: &Box, verb: &str) -> Result<(&str, &SpecBox)> { | |
| 1221 | + | let name = boxed | |
| 1222 | + | .declared | |
| 1223 | + | .as_deref() | |
| 1224 | + | .with_context(|| format!("only a declared box can be {verb}"))?; | |
| 1225 | + | self.spec | |
| 1226 | + | .resolve(name) | |
| 1227 | + | .context("the box left the spec since it was read") | |
| 1228 | + | } | |
| 1229 | + | ||
| 1230 | + | /// Put the selected box's declared binaries on the host PATH. | |
| 1231 | + | /// | |
| 1232 | + | /// Every effect runs even though each is logged separately, and the first | |
| 1233 | + | /// failure stops the rest: exporting three binaries is three files, and a | |
| 1234 | + | /// user who sees two `write` lines and an error knows exactly how far it | |
| 1235 | + | /// got. Re-running after fixing the spec is safe, since both backends | |
| 1236 | + | /// overwrite rather than append. | |
| 1237 | + | fn export(&mut self, log: &mut CommandLog) { | |
| 1238 | + | let Some(backend) = self.selected_backend() else { | |
| 1239 | + | return; | |
| 1240 | + | }; | |
| 1241 | + | let result = backend.and_then(|(index, boxed)| { | |
| 1242 | + | // The `host` path enters the box and the `workspace` wrapper starts | |
| 1243 | + | // it, and neither can act on a box that was never created. Same | |
| 1244 | + | // check and same wording as `toggle`, for the same reason: the state | |
| 1245 | + | // is the console's fact, not the backend's. | |
| 1246 | + | if boxed.state == BoxState::Absent { | |
| 1247 | + | anyhow::bail!("{} does not exist yet — press c to create it", boxed.name); | |
| 1248 | + | } | |
| 1249 | + | let (name, spec) = self.declared_spec(boxed, "exported")?; | |
| 1250 | + | for effect in self.backends[index].export(name, spec)? { | |
| 1251 | + | effect.apply(log)?; | |
| 1252 | + | } | |
| 1253 | + | Ok(()) | |
| 1254 | + | }); | |
| 1255 | + | self.finish(result, log); | |
| 1256 | + | } | |
| 1257 | + | ||
| 1023 | 1258 | /// Start a stopped box, stop a running one. | |
| 1024 | 1259 | /// | |
| 1025 | 1260 | /// One key rather than two because the states are exclusive and the row | |
| @@ -1196,6 +1431,7 @@ | |||
| 1196 | 1431 | hint("j/k", "select"), | |
| 1197 | 1432 | hint("s", "start/stop"), | |
| 1198 | 1433 | hint("c", "create"), | |
| 1434 | + | hint("e", "export"), | |
| 1199 | 1435 | hint("enter", "shell"), | |
| 1200 | 1436 | hint("x", "remove"), | |
| 1201 | 1437 | hint("r", "refresh"), | |
| @@ -1280,6 +1516,7 @@ | |||
| 1280 | 1516 | KeyCode::Char('k') | KeyCode::Up => self.cursor.prev(), | |
| 1281 | 1517 | KeyCode::Char('s') => self.toggle(log), | |
| 1282 | 1518 | KeyCode::Char('c') => self.create(log), | |
| 1519 | + | KeyCode::Char('e') => self.export(log), | |
| 1283 | 1520 | KeyCode::Char('x') => return self.confirm_remove(), | |
| 1284 | 1521 | KeyCode::Char('r') => self.refresh(log), | |
| 1285 | 1522 | KeyCode::Enter => return self.enter(log), | |
| @@ -1356,6 +1593,7 @@ | |||
| 1356 | 1593 | app: None, | |
| 1357 | 1594 | mounts: Vec::new(), | |
| 1358 | 1595 | remote: None, | |
| 1596 | + | export: Export::default(), | |
| 1359 | 1597 | }, | |
| 1360 | 1598 | ) | |
| 1361 | 1599 | }) | |
| @@ -1560,6 +1798,173 @@ | |||
| 1560 | 1798 | assert!(Flatpak.stop(chromium).is_none()); | |
| 1561 | 1799 | } | |
| 1562 | 1800 | ||
| 1801 | + | // ---- exporting ---- | |
| 1802 | + | ||
| 1803 | + | /// A spec covering every shape the export verb branches on. | |
| 1804 | + | fn export_spec() -> Spec { | |
| 1805 | + | Spec::parse( | |
| 1806 | + | r#" | |
| 1807 | + | [box.dev] | |
| 1808 | + | level = "host" | |
| 1809 | + | image = "registry.fedoraproject.org/fedora-toolbox:43" | |
| 1810 | + | export = { bin = ["rg", "fd"] } | |
| 1811 | + | ||
| 1812 | + | [box.scratch] | |
| 1813 | + | level = "workspace" | |
| 1814 | + | image = "registry.fedoraproject.org/fedora-toolbox:43" | |
| 1815 | + | mounts = ["/srv/thing", "/srv/read-only:ro"] | |
| 1816 | + | export = { bin = ["cargo"] } | |
| 1817 | + | ||
| 1818 | + | [box.solo] | |
| 1819 | + | level = "workspace" | |
| 1820 | + | image = "alpine" | |
| 1821 | + | export = { bin = ["hx"] } | |
| 1822 | + | ||
| 1823 | + | [box.bare] | |
| 1824 | + | level = "workspace" | |
| 1825 | + | image = "alpine" | |
| 1826 | + | ||
| 1827 | + | [box.chromium] | |
| 1828 | + | level = "sandboxed" | |
| 1829 | + | app = "org.chromium.Chromium" | |
| 1830 | + | "#, | |
| 1831 | + | ) | |
| 1832 | + | .unwrap() | |
| 1833 | + | } | |
| 1834 | + | ||
| 1835 | + | fn exports(backend: &dyn Backend, spec: &Spec, name: &str) -> Result<Vec<Effect>> { | |
| 1836 | + | let (_, entry) = spec.resolve(name).unwrap(); | |
| 1837 | + | backend.export(name, entry) | |
| 1838 | + | } | |
| 1839 | + | ||
| 1840 | + | /// The written file for a one-binary box, which is most of these tests. | |
| 1841 | + | fn wrapper_of(spec: &Spec, name: &str) -> String { | |
| 1842 | + | let effects = exports(&Podman, spec, name).unwrap(); | |
| 1843 | + | match effects.as_slice() { | |
| 1844 | + | [Effect::Write { contents, .. }] => contents.clone(), | |
| 1845 | + | other => panic!("expected one write, got {} effects", other.len()), | |
| 1846 | + | } | |
| 1847 | + | } | |
| 1848 | + | ||
| 1849 | + | // `host` kept distrobox, so it kept distrobox's export tool. Argv only: | |
| 1850 | + | // distrobox is not installed on the dev box, and this is exactly the kind of | |
| 1851 | + | // path the module docs warn about shipping on fixtures alone. | |
| 1852 | + | #[test] | |
| 1853 | + | fn a_host_export_calls_distrobox_export_once_per_binary() { | |
| 1854 | + | let spec = export_spec(); | |
| 1855 | + | let effects = exports(&Podman, &spec, "dev").unwrap(); | |
| 1856 | + | let lines: Vec<String> = effects.iter().map(Effect::display).collect(); | |
| 1857 | + | ||
| 1858 | + | let dir = export_dir().unwrap(); | |
| 1859 | + | let dir = dir.to_string_lossy(); | |
| 1860 | + | assert_eq!( | |
| 1861 | + | lines, | |
| 1862 | + | vec![ | |
| 1863 | + | format!("distrobox enter dev -- distrobox-export --bin /usr/bin/rg --export-path {dir}"), | |
| 1864 | + | format!("distrobox enter dev -- distrobox-export --bin /usr/bin/fd --export-path {dir}"), | |
| 1865 | + | ] | |
| 1866 | + | ); | |
| 1867 | + | } | |
| 1868 | + | ||
| 1869 | + | // The level with no incumbent: there is no `distrobox-export` here, so the | |
| 1870 | + | // effect is a file Alloy writes rather than a command it runs. This is the | |
| 1871 | + | // reason `Effect` is an enum at all. | |
| 1872 | + | #[test] | |
| 1873 | + | fn a_workspace_export_writes_an_executable_wrapper_per_binary() { | |
| 1874 | + | let spec = export_spec(); | |
| 1875 | + | let effects = exports(&Podman, &spec, "scratch").unwrap(); | |
| 1876 | + | ||
| 1877 | + | let [Effect::Write { path, mode, .. }] = effects.as_slice() else { | |
| 1878 | + | panic!("workspace exports are writes, not commands"); | |
| 1879 | + | }; | |
| 1880 | + | assert!(path.ends_with(".local/bin/cargo"), "landed at {}", path.display()); | |
| 1881 | + | assert_eq!(*mode, 0o755, "a wrapper the shell will not run is not on the PATH"); | |
| 1882 | + | } | |
| 1883 | + | ||
| 1884 | + | // The wrapper is what makes the level's promise hold once the binary is on | |
| 1885 | + | // the host PATH: running it runs the command inside the box. | |
| 1886 | + | #[test] | |
| 1887 | + | fn the_wrapper_re_enters_the_box_and_forwards_its_arguments() { | |
| 1888 | + | let script = wrapper_of(&export_spec(), "scratch"); | |
| 1889 | + | ||
| 1890 | + | assert!(script.starts_with("#!/bin/sh\n"), "{script}"); | |
| 1891 | + | assert!( | |
| 1892 | + | script.contains("podman start 'scratch' >/dev/null || exit"), | |
| 1893 | + | "a box created stopped has to be started, or the wrapper looks broken:\n{script}" | |
| 1894 | + | ); | |
| 1895 | + | assert!( | |
| 1896 | + | script.contains("exec podman exec -i --workdir \"$workdir\" 'scratch' 'cargo' \"$@\""), | |
| 1897 | + | "{script}" | |
| 1898 | + | ); | |
| 1899 | + | assert!( | |
| 1900 | + | script.contains("if [ -t 0 ]; then"), | |
| 1901 | + | "a wrapper on the PATH gets piped, and `-it` on a pipe fails:\n{script}" | |
| 1902 | + | ); | |
| 1903 | + | } | |
| 1904 | + | ||
| 1905 | + | // Without this the command runs in the box's home wherever it was invoked, | |
| 1906 | + | // so `cargo` in a mounted project silently acts on an empty directory. A | |
| 1907 | + | // wrong answer is worse than an error, and the mounts are bound at the same | |
| 1908 | + | // path in and out, so the caller's `$PWD` is testable against them. | |
| 1909 | + | #[test] | |
| 1910 | + | fn the_wrapper_works_in_a_mounted_directory_and_falls_back_to_the_box_home() { | |
| 1911 | + | let script = wrapper_of(&export_spec(), "scratch"); | |
| 1912 | + | ||
| 1913 | + | assert!( | |
| 1914 | + | script.contains("'/srv/thing'|'/srv/thing'/*|'/srv/read-only'|'/srv/read-only'/*)"), | |
| 1915 | + | "every mount, and everything under it, is a valid working directory:\n{script}" | |
| 1916 | + | ); | |
| 1917 | + | assert!(script.contains("workdir=\"$PWD\""), "{script}"); | |
| 1918 | + | assert!( | |
| 1919 | + | script.contains("*) workdir='/root' ;;"), | |
| 1920 | + | "anywhere else is not visible inside the box:\n{script}" | |
| 1921 | + | ); | |
| 1922 | + | } | |
| 1923 | + | ||
| 1924 | + | // A `case` with no patterns before the default is not valid shell, so the | |
| 1925 | + | // no-mounts box takes a plain assignment rather than an empty branch. | |
| 1926 | + | #[test] | |
| 1927 | + | fn a_box_with_no_mounts_gets_a_fixed_working_directory() { | |
| 1928 | + | let script = wrapper_of(&export_spec(), "solo"); |
Lines truncated