Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# composefs-run

**NOTE**: This is currently a proof of concept, and not intended for
production use

A minimal container runner that runs OCI containers directly from
[composefs-rs](https://github.com/containers/composefs-rs)
repositories using [crun](https://github.com/containers/crun), without
Expand All @@ -14,12 +17,11 @@ tracking.
- **Rootless mode**: FUSE-based composefs + unprivileged overlayfs
(`userxattr`), user namespace with subuid/subgid mapping,
[pasta](https://passt.top/) networking
- SELinux labeling with MCS category separation
- Seccomp profiles (default from containers-common, or from image labels)
- Minimal host state: transient overlay in `/var/tmp`, tmpfs-backed bundle,
no global database
- Default to transient overlays, persistent overlay via `--overlay-dir`
- OCI poststop hooks for automatic cleanup
- Minimal host state, containers are just child processes. There is no
equivalent of podman ps/rm etc.
- No detached mode, always assumes `-i`.
- By default, writable overlays are transient, stored in /var/tmp.
This can be overridden with `--overlay-dir`

### Quick start

Expand Down
12 changes: 6 additions & 6 deletions composefs-run/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ pub(crate) struct CleanupArgs {
/// Allocated container IP (for netavark teardown + IPAM release)
#[clap(long)]
container_ip: Option<std::net::IpAddr>,

/// PID of the pasta process to kill on cleanup
#[clap(long)]
pasta_pid: Option<i32>,
}

#[derive(Clone, Debug)]
Expand Down Expand Up @@ -309,14 +313,10 @@ pub(crate) struct Cli {
#[clap(short = 't', long)]
tty: bool,

/// Keep stdin open
#[clap(short = 'i', long)]
/// Accepted for compatibility, ignored
#[clap(short = 'i', long, hide = true)]
interactive: bool,

/// Write the container init PID to this file
#[clap(long)]
pidfile: Option<PathBuf>,

/// Add a host device to the container (HOST_PATH[:CONTAINER_PATH[:PERMISSIONS]])
#[clap(long, value_parser = clap::value_parser!(DeviceSpec))]
device: Vec<DeviceSpec>,
Expand Down
96 changes: 27 additions & 69 deletions composefs-run/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ pub fn run(
}

let mut container_ip = None;
let mut pasta_pid = None;
let netns_path = if network != NetworkMode::Host {
Some(setup_netns(&bundle_dir)?)
} else {
Expand All @@ -109,7 +110,11 @@ pub fn run(
} else if network == NetworkMode::Pasta
&& let Some(ref ns) = netns_path
{
setup_pasta(ns, &cli.publish)?;
pasta_pid = Some(setup_pasta(
ns,
&bundle_dir.join("pasta.pid"),
&cli.publish,
)?);
}

// ── OCI spec + exec ────────────────────────────────────────────────
Expand Down Expand Up @@ -139,28 +144,19 @@ pub fn run(
&network,
netns_path.as_deref(),
container_ip,
pasta_pid,
)?;

let config_json = serde_json::to_string_pretty(&spec)?;
fs::write(bundle_dir.join("config.json"), &config_json)?;

if cli.interactive || tty {
let err = Command::new("crun")
.arg("run")
.arg("--bundle")
.arg(bundle_dir)
.arg(container_id)
.exec();
Err(err).context("Failed to exec crun")
} else {
let image_name = cli.image.as_deref().unwrap_or("container");
run_detached(
&bundle_dir,
container_id,
image_name,
cli.pidfile.as_deref(),
)
}
let err = Command::new("crun")
.arg("run")
.arg("--bundle")
.arg(bundle_dir)
.arg(container_id)
.exec();
Err(err).context("Failed to exec crun")
}

/// OCI poststop hook: unmount and remove the container state directory.
Expand All @@ -178,6 +174,9 @@ pub fn cleanup() -> Result<()> {
);
let rootfs = dir.join("bundle/rootfs");
let _ = rustix::mount::unmount(&rootfs, rustix::mount::UnmountFlags::DETACH);
if let Some(pid) = args.pasta_pid {
unsafe { libc::kill(pid, libc::SIGTERM) };
}
let netns = dir.join("bundle/netns");
if netns.exists() {
if let (Some(id), Some(ip)) = (&args.container_id, args.container_ip) {
Expand All @@ -192,55 +191,6 @@ pub fn cleanup() -> Result<()> {
Ok(())
}

fn journal_stream_fd(identifier: &str) -> Result<std::os::fd::OwnedFd> {
use std::io::Write;
use std::os::unix::net::UnixStream;

let mut stream = UnixStream::connect("/run/systemd/journal/stdout")
.context("Connecting to journal socket")?;

write!(stream, "{identifier}\n\n6\n0\n0\n0\n0\n")?;
stream.flush()?;

Ok(std::os::fd::OwnedFd::from(stream))
}

fn run_detached(
bundle_dir: &Path,
container_id: &str,
image_name: &str,
pidfile: Option<&Path>,
) -> Result<()> {
let journal_fd =
journal_stream_fd(&format!("cfsrun:{image_name}")).context("Creating journal stream fd")?;
let journal_fd2 = rustix::io::dup(&journal_fd)?;

let pidfile_path = match pidfile {
Some(pf) => pf.to_owned(),
None => bundle_dir.join("container.pid"),
};

let status = Command::new("crun")
.arg("run")
.arg("--detach")
.arg("--bundle")
.arg(bundle_dir)
.arg("--pid-file")
.arg(&pidfile_path)
.arg(container_id)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::from(std::fs::File::from(journal_fd)))
.stderr(std::process::Stdio::from(std::fs::File::from(journal_fd2)))
.status()
.context("Failed to run crun")?;
ensure!(status.success(), "crun run --detach failed: {status}");

let pid = fs::read_to_string(&pidfile_path).context("Reading PID file")?;
println!("{}", pid.trim());

Ok(())
}

/// Mount a composefs image via FUSE (rootless).
fn mount_rootfs_with_fuse(
repo_path: &Path,
Expand Down Expand Up @@ -421,14 +371,16 @@ fn setup_netns(bundle_dir: &Path) -> Result<PathBuf> {
Ok(netns_path)
}

fn setup_pasta(netns_path: &Path, publish: &[PortSpec]) -> Result<()> {
fn setup_pasta(netns_path: &Path, pid_file: &Path, publish: &[PortSpec]) -> Result<i32> {
let mut pasta_cmd = Command::new("pasta");
pasta_cmd
.arg("--config-net")
.arg("--dns-forward")
.arg("169.254.1.1")
.arg("--netns")
.arg(netns_path)
.arg("--pid")
.arg(pid_file)
.arg("--quiet");

if publish.is_empty() {
Expand All @@ -449,7 +401,8 @@ fn setup_pasta(netns_path: &Path, publish: &[PortSpec]) -> Result<()> {
String::from_utf8_lossy(&output.stderr)
);

Ok(())
let pid_str = fs::read_to_string(pid_file).context("Reading pasta PID file")?;
pid_str.trim().parse::<i32>().context("Parsing pasta PID")
}

fn create_detached_tmpfs() -> Result<rustix::fd::OwnedFd> {
Expand Down Expand Up @@ -597,6 +550,7 @@ fn build_runtime_spec(
network: &NetworkMode,
netns_path: Option<&Path>,
container_ip: Option<std::net::IpAddr>,
pasta_pid: Option<i32>,
) -> Result<Spec> {
use std::collections::HashSet;

Expand Down Expand Up @@ -995,6 +949,10 @@ fn build_runtime_spec(
hook_args.push("--container-ip".into());
hook_args.push(ip.to_string());
}
if let Some(pid) = pasta_pid {
hook_args.push("--pasta-pid".into());
hook_args.push(pid.to_string());
}
let hook = HookBuilder::default()
.path(self_exe)
.args(hook_args)
Expand Down
2 changes: 1 addition & 1 deletion composefs-run/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ fn repo() -> &'static Path {

fn cfsrun() -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_cfsrun"));
cmd.arg("--repo").arg(repo()).arg("-i");
cmd.arg("--repo").arg(repo());
cmd
}

Expand Down
Loading