diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index bf9406d..f952700 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -18,7 +18,7 @@ import { computeRequestTimeoutMs } from './backups/request-timeout'; import type { Environment } from './db'; import { getSetting } from './db'; import { getAdditionalVolumeBinds, dedupeVolumesForRecreate } from './mount-dedupe'; -import { resolveNanoCpusConflict } from './hostconfig-recreate'; +import { resolveNanoCpusConflict, resolvePodmanUsernsMode } from './hostconfig-recreate'; import { rebaseEnvOntoImage, rebaseLabelsOntoImage, rebaseCommand, describeEnvRebase, describeLabelRebase, type ImageEnvLabels } from './container-env-merge'; import { encodeRegistryAuth } from './registry-auth'; import { isSystemContainer, classifyEmptyDigestImage, localDigestIsIndexChild } from './scheduler/tasks/update-utils'; @@ -2093,6 +2093,20 @@ export async function recreateContainerFromInspect( log?.('Dropped CpuPeriod/CpuQuota — they conflict with NanoCpus at create time (kept NanoCpus)'); } + // Podman lowers `--userns keep-id` to UsernsMode:"private" in inspect, which create then + // rejects without inline mappings (#1409). Restore the original intent from the annotation + // so keep-id survives the recreate; Docker (UsernsMode != "private") is untouched. + const usernsFix = resolvePodmanUsernsMode(createConfig.HostConfig?.UsernsMode, config.Annotations); + if (usernsFix && createConfig.HostConfig) { + if ('mode' in usernsFix) { + createConfig.HostConfig.UsernsMode = usernsFix.mode; + log?.(`Restored UsernsMode "${usernsFix.mode}" from Podman annotation (was "private")`); + } else { + delete createConfig.HostConfig.UsernsMode; + log?.('Dropped UsernsMode "private" — Podman rejects it without inline UID/GID mappings'); + } + } + // container: mode shares the network namespace — Docker rejects // networking-related fields on the dependent container since they're // owned by the network provider container @@ -2305,6 +2319,14 @@ export async function createContainerFromMetadata( delete createConfig.HostConfig.MemorySwappiness; } + // Podman keep-id -> UsernsMode:"private" in inspect, rejected on create (#1409). + // Restore the intent from the annotation; Docker (UsernsMode != "private") untouched. + const usernsFix = resolvePodmanUsernsMode(createConfig.HostConfig?.UsernsMode, config.Annotations); + if (usernsFix && createConfig.HostConfig) { + if ('mode' in usernsFix) createConfig.HostConfig.UsernsMode = usernsFix.mode; + else delete createConfig.HostConfig.UsernsMode; + } + // container: mode: clean up conflicting network fields if (networkMode.startsWith('container:')) { delete createConfig.Hostname; diff --git a/src/lib/server/hostconfig-recreate.ts b/src/lib/server/hostconfig-recreate.ts index d6f20e7..c2e6506 100644 --- a/src/lib/server/hostconfig-recreate.ts +++ b/src/lib/server/hostconfig-recreate.ts @@ -32,3 +32,29 @@ export function resolveNanoCpusConflict(hostConfig: CpuHostConfig | null | undef delete hostConfig.CpuQuota; return true; } + +const PODMAN_USERNS_ANNOTATION = 'io.podman.annotations.userns'; + +/** + * Podman lowers `--userns keep-id`/`auto`/`nomap` to `UsernsMode: "private"` plus generated + * `IDMappings` in inspect, losing the original intent. Replaying `UsernsMode: "private"` to + * create WITHOUT inline mappings is rejected ("must provide at least one UID or GID mapping + * to configure a user namespace", #1409). Podman keeps the real intent in the + * `io.podman.annotations.userns` annotation, and `create --userns keep-id` regenerates the + * mappings - so restore the annotation value to keep keep-id working across a recreate. + * + * Returns the corrected UsernsMode: the annotation value ("keep-id", "auto", + * "keep-id:uid=1000,gid=1000", ...) when present, `undefined` to STRIP a bare "private" with + * no recorded intent, or `null` for "leave UsernsMode untouched". Only the lowered "private" + * form is ever changed, so Docker (UsernsMode "", "host", "container:x", ...) is never + * touched - no Docker regression. + */ +export function resolvePodmanUsernsMode( + usernsMode: string | undefined | null, + annotations: Record | undefined | null +): { mode: string } | { strip: true } | null { + if (usernsMode !== 'private') return null; + const intent = annotations?.[PODMAN_USERNS_ANNOTATION]?.trim(); + if (intent) return { mode: intent }; + return { strip: true }; +}