run container as host uid:gid, robust /stop, project paths as full agent paths
Nightly Build / build (push) Successful in 6m31s

This commit is contained in:
2026-07-21 10:47:12 +01:00
parent 1db34b22ec
commit c8e4cb4384
20 changed files with 395 additions and 217 deletions
+97 -8
View File
@@ -27,8 +27,12 @@ use core_api::user_fs::{ProjectMount, SharedMount, UserFs};
use crate::db;
/// Our runtime image tag. Built once from the embedded [`Dockerfile`].
const IMAGE_TAG: &str = "skald-runtime";
/// Our runtime image tag. Built once from the embedded [`Dockerfile`]. The version
/// suffix is the image cache-buster: [`ContainerManager::ensure_image`] rebuilds only
/// when the tag is absent, so **bump it whenever the [`Dockerfile`] changes** (e.g.
/// `v2` added `sudo` + a NOPASSWD sudoers for the non-root container user). Old tags
/// linger as orphaned images (harmless).
const IMAGE_TAG: &str = "skald-runtime:v2";
/// The embedded Dockerfile — the source of truth, so the image can be built with
/// no files shipped alongside the binary (binary-first).
@@ -53,6 +57,19 @@ pub fn container_name(user_id: &str) -> String {
format!("skald-{user_id}")
}
/// The host process's own `(uid, gid)`, or `None` on non-unix. We run each container
/// as this uid:gid (blueprint §6 UID coherence) so files created inside the container
/// and by the host-side fs-tools share ownership on the bind mounts. On non-unix we
/// fall back to the image default (root) and skip `--user`.
#[cfg(unix)]
fn host_uid_gid() -> Option<(u32, u32)> {
Some((unsafe { libc::getuid() }, unsafe { libc::getgid() }))
}
#[cfg(not(unix))]
fn host_uid_gid() -> Option<(u32, u32)> {
None
}
/// Builds the [`UserFs`] view for a user: private home + the shared folders they
/// belong to, plus the container those mount into. Host paths are absolute
/// (anchored at the process working directory), as Docker bind mounts require.
@@ -159,36 +176,62 @@ impl ContainerManager {
Ok(())
}
/// Ensures the user's container exists and is running. Creates the host
/// directories, the container (if missing) with the right bind mounts, and
/// starts it (if stopped). Idempotent — a no-op when already running.
/// Ensures the user's container exists, runs as the host uid:gid, and is started.
/// Creates the host directories, the container (if missing) with the right bind
/// mounts + `--user`, and starts it (if stopped). Self-healing: a container whose
/// `--user` no longer matches the host uid:gid (e.g. an old root container from a
/// previous binary) is torn down and recreated. Idempotent — a no-op when a
/// matching container is already running.
pub async fn ensure(&self, user_id: &str) -> Result<()> {
let fs = build_user_fs(&self.system, user_id).await?;
// Host directories must exist before the mount, or Docker creates them
// root-owned with surprising modes.
// root-owned with surprising modes. Created by the host process, so they are
// owned by the host uid:gid the container runs as — the mounts are writable.
for (host, _container, _w) in fs.mounts() {
std::fs::create_dir_all(&host)
.with_context(|| format!("failed to create host dir {}", host.display()))?;
}
let name = &fs.container_name;
let want_user = host_uid_gid().map(|(uid, gid)| format!("{uid}:{gid}"));
match container_state(name).await {
ContainerState::Running => return Ok(()),
ContainerState::Stopped => {
// Reuse only if it runs as the expected user; otherwise recreate below.
ContainerState::Running if user_matches(name, &want_user).await => return Ok(()),
ContainerState::Stopped if user_matches(name, &want_user).await => {
docker(&["start", name]).await.context("docker start failed")?;
return Ok(());
}
ContainerState::Absent => {}
// Present but with a stale `--user` (e.g. an old root container): tear it
// down. The container holds no durable state — everything is in the bind
// mounts — so a recreate is safe.
_ => {
let _ = docker(&["rm", "-f", name]).await;
}
}
let mut args: Vec<String> = vec![
"create".into(),
// `--init` runs tini as pid 1 so orphaned/killed processes are reaped —
// otherwise `execute_cmd`'s /stop reaper (and any command that leaves
// orphans) would accumulate zombies under the idle `sleep infinity`.
"--init".into(),
"--name".into(),
name.clone(),
"--workdir".into(),
fs.container_home.to_string_lossy().into_owned(),
];
// Run as the host uid:gid for bind-mount ownership coherence (§6). HOME is set
// explicitly because the passwd entry that resolves this uid is injected only
// *after* create (see below), so Docker would otherwise default HOME to "/".
if let Some(user) = &want_user {
args.push("--user".into());
args.push(user.clone());
args.push("-e".into());
args.push(format!("HOME={}", fs.container_home.to_string_lossy()));
}
for (host, container, writable) in fs.mounts() {
let mut spec = format!("{}:{}", host.display(), container.display());
if !writable {
@@ -204,6 +247,14 @@ impl ContainerManager {
let argv: Vec<&str> = args.iter().map(String::as_str).collect();
docker(&argv).await.context("docker create failed")?;
docker(&["start", name]).await.context("docker start failed")?;
// Give the non-root container user a passwd/group entry so `sudo` (NOPASSWD,
// baked into the image) can resolve it. Persists in the container's writable
// layer for its lifetime; re-done on recreate. Best-effort.
if let Some((uid, gid)) = host_uid_gid() {
ensure_container_user(name, uid, gid).await;
}
tracing::info!(user = %user_id, container = %name, "user container created and started");
Ok(())
}
@@ -279,6 +330,44 @@ async fn container_state(name: &str) -> ContainerState {
}
}
/// Reads a container's configured `--user` (`docker inspect .Config.User`). Empty for a
/// container created without `--user` (i.e. root).
async fn container_user(name: &str) -> String {
docker(&["inspect", "-f", "{{.Config.User}}", name])
.await
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
/// Whether a container's `--user` matches what we want. `want == None` (non-unix, no
/// `--user` requested) matches anything so we never churn a container needlessly.
async fn user_matches(name: &str, want: &Option<String>) -> bool {
match want {
None => true,
Some(w) => &container_user(name).await == w,
}
}
/// Gives the container's runtime `uid`/`gid` a passwd + shadow (+ group) entry, so
/// tools that resolve the invoking user work despite the arbitrary numeric uid — and
/// so `sudo` succeeds (without a shadow entry PAM's account phase fails with "account
/// validation failure" even under NOPASSWD). The shadow password is `*` (login
/// disabled, account valid); the group is added only when its gid is otherwise unused.
/// Runs as root inside the container (`-u 0`, which overrides the container's `--user`),
/// idempotent (keyed on the passwd entry), best-effort.
async fn ensure_container_user(name: &str, uid: u32, gid: u32) {
let script = format!(
"if ! getent passwd {uid} >/dev/null 2>&1; then \
getent group {gid} >/dev/null 2>&1 || echo 'skald:x:{gid}:' >> /etc/group; \
echo 'skald:x:{uid}:{gid}:skald:/root:/bin/sh' >> /etc/passwd; \
echo 'skald:*:19000:0:99999:7:::' >> /etc/shadow; \
fi"
);
if let Err(e) = docker(&["exec", "-u", "0", name, "sh", "-c", &script]).await {
tracing::warn!(container = %name, error = %e, "failed to inject container passwd entry (sudo may not resolve the user)");
}
}
/// Runs `docker <args>`, returning trimmed stdout on success or an error carrying
/// stderr. `stdin` is closed so a build never blocks waiting for input.
async fn docker(args: &[&str]) -> Result<String> {