fix(orchestrator): map container uids into the subuid range in the chown fallback

chown_for_rootless_container prefers `podman unshare chown` (which maps
container uid N through the userns), but when that failed once it fell
back to `sudo chown -R <literal>` — writing e.g. host uid 999 for
container uid 999 and reporting success. Host-999 maps to nobody inside
the userns, so the app could not open its own data while everything
claimed the chown worked: botfights on framework-pt crash-looped every
10s on SQLITE_CANTOPEN over a data dir the daemon itself had just
"fixed".

The sudo fallback now translates container ids (1..99999) to
subuid_base + id - 1 (fleet base 100000; container root maps to the
service user, 1000). Already-mapped ids and uid 0 pass through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-05 23:48:36 -04:00
co-authored by Claude Fable 5
parent 8469af5f4e
commit d8748ee7ba
@@ -272,10 +272,15 @@ fn build_fingerprint_stamp_path(data_dir: &Path, tag: &str) -> PathBuf {
}
async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
let uid = uid_gid
let (uid, gid) = uid_gid
.split_once(':')
.and_then(|(uid, _)| uid.parse::<u32>().ok())
.unwrap_or(0);
.map(|(u, g)| {
(
u.parse::<u32>().unwrap_or(0),
g.parse::<u32>().unwrap_or(0),
)
})
.unwrap_or((0, 0));
if uid > 0 && uid < 100_000 {
let output = tokio::process::Command::new("podman")
@@ -288,9 +293,22 @@ async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
}
}
let status = host_sudo(&["chown", "-R", uid_gid, path])
// Host-side fallback. A CONTAINER-namespace id must be translated into
// the subuid range first: `sudo chown 999` writes literal host uid 999,
// which maps to nobody inside the userns — the app then can't open its
// own files while the chown reported success (botfights SQLITE_CANTOPEN
// crash-loop, framework-pt 2026-08-06). Container uid N (N>=1) lives at
// subuid_base + N - 1; the fleet provisions base 100000. uid 0 and
// already-mapped ids (>=100000) pass through untouched.
let host_uid_gid = if uid > 0 && uid < 100_000 {
let map = |id: u32| if id == 0 { 1000 } else { 100_000 + id - 1 };
format!("{}:{}", map(uid), map(gid))
} else {
uid_gid.to_string()
};
let status = host_sudo(&["chown", "-R", &host_uid_gid, path])
.await
.with_context(|| format!("sudo chown -R {uid_gid} {path}"))?;
.with_context(|| format!("sudo chown -R {host_uid_gid} {path}"))?;
if status.success() {
return Ok(());
}