refactor: split ecr into library and cli crates

The root package becomes a virtual workspace: `crates/ecr` holds the
library (package name `ecr`) and `crates/ecr-cli` the command line
front-end, which keeps installing the `ecr` binary. No behavior change.

The library must not depend on CLI types, so mount::setup_mounts now
takes the `no_bind` flag instead of a `&Args`.
This commit is contained in:
2026-09-20 23:29:03 +02:00
parent 7137aa15c5
commit b6e5b4f006
19 changed files with 129 additions and 73 deletions
Generated
+11 -1
View File
@@ -302,7 +302,6 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
"clap",
"cpio", "cpio",
"dirs", "dirs",
"flate2", "flate2",
@@ -323,6 +322,17 @@ dependencies = [
"zstd", "zstd",
] ]
[[package]]
name = "ecr-cli"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"dirs",
"ecr",
"tempfile",
]
[[package]] [[package]]
name = "either" name = "either"
version = "1.16.0" version = "1.16.0"
+5 -41
View File
@@ -1,50 +1,14 @@
[package] [workspace]
name = "ecr" resolver = "2"
members = ["crates/ecr", "crates/ecr-cli"]
[workspace.package]
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
rust-version = "1.77" rust-version = "1.77"
description = "Enter chroot environments with Linux namespaces"
license = "MIT" license = "MIT"
authors = ["Valentin Haudiquet"] authors = ["Valentin Haudiquet"]
[dependencies]
# CLI parsing
clap = { version = "4", features = ["derive", "env"] }
# Config parsing
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
# HTTP downloads
reqwest = { version = "0.13", features = ["blocking", "stream"] }
# Tarball extraction
tar = "0.4"
flate2 = "1"
xz2 = "0.1"
zstd = "0.13"
# Unix syscall bindings
nix = { version = "0.31", features = ["fs", "mount", "sched", "signal", "user", "process", "hostname"] }
# Temp directories
tempfile = "3"
# Error handling
anyhow = "1"
# Utilities
dirs = "6"
which = "7"
cpio = "0.4"
base64 = "0.22"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util"] }
futures-util = "0.3"
indicatif = "0.18"
serde_json = "1"
libc = "0.2"
users = "0.11"
[profile.release] [profile.release]
strip = true strip = true
opt-level = "z" opt-level = "z"
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "ecr-cli"
description = "Enter chroot environments with Linux namespaces"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
# The installed binary keeps the `ecr` name even though the CLI package is
# `ecr-cli`; the library package owns the `ecr` name for consumers.
[[bin]]
name = "ecr"
path = "src/main.rs"
[dependencies]
ecr = { path = "../ecr" }
# CLI parsing
clap = { version = "4", features = ["derive", "env"] }
# Error handling
anyhow = "1"
# Interim: used by the CLI's inline cache/extraction orchestration until it
# moves behind the library's rootfs API
dirs = "6"
tempfile = "3"
+7 -27
View File
@@ -1,37 +1,17 @@
mod chroot;
mod cli; mod cli;
mod config;
mod distro;
mod download;
mod extract;
mod kernel;
mod mount;
mod namespace;
mod qemu;
mod qemu_vm;
mod utils;
mod verbose;
/// Print to stderr only when --verbose / -v is active.
#[macro_export]
macro_rules! veprintln {
($($arg:tt)*) => {
if $crate::verbose::is_verbose() {
eprintln!($($arg)*);
}
};
}
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::Parser; use clap::Parser;
use cli::Args; use cli::Args;
use config::Config; use ecr::chroot;
use distro::{ use ecr::config::Config;
use ecr::distro::{
map_arch, parse_image_ref, resolve_distro_url, resolve_distro_version, Distro, ImageSource, map_arch, parse_image_ref, resolve_distro_url, resolve_distro_version, Distro, ImageSource,
}; };
use download::{digest_sidecar, download_image, fetch_oci_digest}; use ecr::download::{digest_sidecar, download_image, fetch_oci_digest};
use extract::extract_tarball; use ecr::extract::extract_tarball;
use ecr::{kernel, mount, namespace, qemu, qemu_vm, utils, veprintln, verbose};
fn main() -> Result<()> { fn main() -> Result<()> {
let args = Args::parse(); let args = Args::parse();
@@ -227,7 +207,7 @@ fn namespace_mode(args: Args, rootfs: std::path::PathBuf, config: Config) -> Res
&rootfs_clone, &rootfs_clone,
&bind_paths_clone, &bind_paths_clone,
&bind_rw_paths_clone, &bind_rw_paths_clone,
&args_clone, args_clone.no_bind,
)?; )?;
// Write resolv.conf with DNS from config // Write resolv.conf with DNS from config
+43
View File
@@ -0,0 +1,43 @@
[package]
name = "ecr"
description = "Ephemeral chroot environments with Linux namespaces"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
# Config parsing
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
# HTTP downloads
reqwest = { version = "0.13", features = ["blocking", "stream"] }
# Tarball extraction
tar = "0.4"
flate2 = "1"
xz2 = "0.1"
zstd = "0.13"
# Unix syscall bindings
nix = { version = "0.31", features = ["fs", "mount", "sched", "signal", "user", "process", "hostname"] }
# Temp directories
tempfile = "3"
# Error handling
anyhow = "1"
# Utilities
dirs = "6"
which = "7"
cpio = "0.4"
base64 = "0.22"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util"] }
futures-util = "0.3"
indicatif = "0.18"
serde_json = "1"
libc = "0.2"
users = "0.11"
+30
View File
@@ -0,0 +1,30 @@
//! ecr — ephemeral chroot environments with Linux namespaces.
//!
//! This crate is the library behind the `ecr` CLI. It resolves distro and
//! OCI image references, downloads them through a content cache, extracts
//! the rootfs into a scratch directory, and runs commands inside
//! unprivileged user/PID/mount/UTS namespaces — or boots it in a QEMU VM
//! (see the `qemu_vm` module).
pub mod chroot;
pub mod config;
pub mod distro;
pub mod download;
pub mod extract;
pub mod kernel;
pub mod mount;
pub mod namespace;
pub mod qemu;
pub mod qemu_vm;
pub mod utils;
pub mod verbose;
/// Print to stderr only when verbose mode is active (see [`verbose::set`]).
#[macro_export]
macro_rules! veprintln {
($($arg:tt)*) => {
if $crate::verbose::is_verbose() {
eprintln!($($arg)*);
}
};
}
+2 -4
View File
@@ -21,15 +21,13 @@ fn escape_overlay_path(path: &Path) -> Result<String> {
Ok(s.replace('\\', "\\\\").replace(',', "\\,")) Ok(s.replace('\\', "\\\\").replace(',', "\\,"))
} }
use crate::cli::Args;
/// Setup all required mounts inside the chroot /// Setup all required mounts inside the chroot
/// Returns a TempDir that must be kept alive for the duration of the chroot /// Returns a TempDir that must be kept alive for the duration of the chroot
pub fn setup_mounts( pub fn setup_mounts(
rootfs: &Path, rootfs: &Path,
bind_paths: &[std::path::PathBuf], bind_paths: &[std::path::PathBuf],
bind_rw_paths: &[std::path::PathBuf], bind_rw_paths: &[std::path::PathBuf],
args: &Args, no_bind: bool,
) -> Result<Vec<TempDir>> { ) -> Result<Vec<TempDir>> {
// Keep all overlay temp dirs alive // Keep all overlay temp dirs alive
let mut overlay_temps: Vec<TempDir> = Vec::new(); let mut overlay_temps: Vec<TempDir> = Vec::new();
@@ -60,7 +58,7 @@ pub fn setup_mounts(
} }
// Setup overlay mounts for bind paths (read-only via overlay) // Setup overlay mounts for bind paths (read-only via overlay)
if !args.no_bind { if !no_bind {
for bind_path in bind_paths { for bind_path in bind_paths {
// Skip if this path is also in bind_rw (bind_rw takes precedence) // Skip if this path is also in bind_rw (bind_rw takes precedence)
if !bind_rw_paths.contains(bind_path) { if !bind_rw_paths.contains(bind_path) {
+3
View File
@@ -42,6 +42,9 @@ pub enum Arch {
impl Arch { impl Arch {
/// Get the architecture from a string (any common naming convention) /// Get the architecture from a string (any common naming convention)
/// Not a FromStr impl: unrecognized names map to Arch::Unknown rather
/// than an error, by design.
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Self { pub fn from_str(s: &str) -> Self {
match s { match s {
"amd64" | "x86_64" | "x64" => Arch::Amd64, "amd64" | "x86_64" | "x64" => Arch::Amd64,