Repository navigation
feat(run-spaceapp): support rootless Podman as a container engine (#63) #324
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b4bf30d
8b65f2c
17895c8
0d9daa6
e018e1d
d990f43
5c9423b
73ea683
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,8 @@ import { | |
| removeCredential, | ||
| removeUser, | ||
| removeWorkspace, | ||
| podmanUnshareChownArgs, | ||
| resolveContainerEngine, | ||
| resolveInstallAccessMode, | ||
| resolveInstallProfile, | ||
| resolveSpaceAppHome, | ||
|
|
@@ -50,6 +52,7 @@ import { | |
| } from "./index.mjs"; | ||
| import { inspectStoragePlan, storageChecks } from "./storage.mjs"; | ||
| import { | ||
| detectContainerEngine, | ||
| ensureDockerAvailable, | ||
| prepareDockerCliPath, | ||
| windowsPowerShellArgs | ||
|
|
@@ -81,6 +84,7 @@ const SETUP_STATUS_TIMEOUT_MS = 10_000; | |
| const trustedCommands = new Set([ | ||
| "codesign", | ||
| "docker", | ||
| "podman", | ||
| "hdiutil", | ||
| "open", | ||
| "powershell.exe", | ||
|
|
@@ -293,7 +297,8 @@ export async function run(argv, { | |
| plan: false, | ||
| nonInteractive: false, | ||
| logFile: null, | ||
| answersFile: null | ||
| answersFile: null, | ||
| engine: env?.SPACEAPP_CONTAINER_ENGINE | ||
| }; | ||
| const filteredArgv = []; | ||
| for (let i = 0; i < argv.length; i++) { | ||
|
|
@@ -314,6 +319,10 @@ export async function run(argv, { | |
| globalFlags.answersFile = argv[++i]; | ||
| } else if (arg.startsWith("--answers=")) { | ||
| globalFlags.answersFile = arg.slice("--answers=".length); | ||
| } else if (arg === "--engine" && i + 1 < argv.length) { | ||
| globalFlags.engine = argv[++i]; | ||
| } else if (arg.startsWith("--engine=")) { | ||
| globalFlags.engine = arg.slice("--engine=".length); | ||
|
Comment on lines
+322
to
+325
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pass the selected engine into installation. For
|
||
| } else { | ||
| filteredArgv.push(arg); | ||
| } | ||
|
|
@@ -344,7 +353,7 @@ export async function run(argv, { | |
| if (globalFlags.localImages) { | ||
| const originalExecute = execute; | ||
| execute = async (spec, io) => { | ||
| if (spec.command === "docker" && spec.args?.includes("compose")) { | ||
| if ((spec.command === "docker" || spec.command === "podman") && spec.args?.includes("compose")) { | ||
| if (spec.args.at(-1) === "pull") { | ||
| stdout.write("Local images only: skipping registry downloads; missing images will fail startup.\n"); | ||
| return 0; | ||
|
|
@@ -424,9 +433,10 @@ export async function run(argv, { | |
| if (commandNeedsRuntimeFiles(command, args)) { | ||
| await writeRuntimeFiles(root, config); | ||
| } | ||
| const activeEngine = resolveContainerEngine(globalFlags.engine, config.containerEngine); | ||
| const runtimeExecute = (spec, io) => executeWithDockerDiagnostics( | ||
| execute, | ||
| spec, | ||
| activeEngine === "podman" && spec.command === "docker" ? { ...spec, command: "podman" } : spec, | ||
|
Comment on lines
+436
to
+439
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Refuse runtime engine overrides that differ from the saved engine. When an installation has saved Docker,
|
||
| io, | ||
| { platform, stderr } | ||
| ); | ||
|
|
@@ -1811,7 +1821,7 @@ async function workspaceCommand(rawArgs, { root, config, platform, stdin, stdout | |
| await saveConfig(root, updated); | ||
| await writeRuntimeFiles(root, updated); | ||
| stdout.write(`Workspace registered: ${identity}\n`); | ||
| if (platform === "linux" && !readOnly) await writeAccessHint(identity, stdout); | ||
| if (platform === "linux" && !readOnly) await writeAccessHint(identity, stdout, { containerEngine: resolveContainerEngine(updated.containerEngine) }); | ||
| return applyWorkspaces(updated); | ||
| } | ||
| if (action === "remove") { | ||
|
|
@@ -1836,9 +1846,18 @@ async function workspaceCommand(rawArgs, { root, config, platform, stdin, stdout | |
|
|
||
| // The CLIs run as uid 10001 in Docker; on Linux a bind-mounted folder keeps its host owner, so they cannot write to it. | ||
| const CLI_UID = 10001; | ||
| async function writeAccessHint(hostPath, stdout) { | ||
| async function writeAccessHint(hostPath, stdout, { containerEngine = "docker" } = {}) { | ||
| const info = await stat(hostPath); | ||
| if (info.uid === CLI_UID || (info.mode & 0o002) !== 0) return; | ||
| if (containerEngine === "podman") { | ||
| const args = podmanUnshareChownArgs(hostPath, { uid: CLI_UID, gid: CLI_UID }); | ||
| stdout.write( | ||
| `The AI CLIs run as uid ${CLI_UID} inside rootless Podman. Map the workspace ownership in your user namespace without root:\n` + | ||
| ` podman ${args.slice(0, -1).join(" ")} ${JSON.stringify(args.at(-1))}\n` + | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shell-quote the printed workspace path.
|
||
| "(or add --read-only if the AI CLIs should only read)\n" | ||
|
Comment on lines
+1855
to
+1857
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Account for SELinux labels in Podman workspace guidance. On an SELinux-enforcing host, changing UID/GID ownership alone does not make a bind mount accessible to a container. The workspace Compose renderer emits these mounts without a relabel option, so a user can follow this new command and still receive permission errors. Add a Podman-compatible shared label strategy to the generated mounts, and explain it alongside the ownership guidance. (raw.githubusercontent.com)
|
||
| ); | ||
| return; | ||
| } | ||
| const owner = process.getuid?.() ?? info.uid; | ||
| stdout.write( | ||
| `The AI CLIs run as uid ${CLI_UID} and cannot write to this folder yet. Give them access without changing its owner:\n` + | ||
|
|
@@ -2434,10 +2453,13 @@ async function doctor({ | |
| ...(storagePlan ? storageChecks(storagePlan, detectedResources) : []) | ||
| ]; | ||
| const dockerResults = []; | ||
| const existingForDoctor = await loadConfig(root).catch(() => null); | ||
| const doctorEngine = existingForDoctor?.containerEngine === "podman" || process.env.SPACEAPP_CONTAINER_ENGINE === "podman" ? "podman" : "docker"; | ||
| const engineLabel = doctorEngine === "podman" ? "Podman" : "Docker"; | ||
| for (const probe of [ | ||
| { name: "Docker CLI", command: "docker", args: ["--version"] }, | ||
| { name: "Docker Compose", command: "docker", args: ["compose", "version"] }, | ||
| { name: "Docker Engine", command: "docker", args: ["info"] } | ||
| { name: `${engineLabel} CLI`, command: doctorEngine, args: ["--version"] }, | ||
| { name: `${engineLabel} Compose`, command: doctorEngine, args: ["compose", "version"] }, | ||
| { name: `${engineLabel} Engine`, command: doctorEngine, args: ["info"] } | ||
|
Comment on lines
+2456
to
+2462
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use the resolved engine for doctor probes.
|
||
| ]) { | ||
| const code = dockerReady | ||
| ? 0 | ||
|
|
@@ -2655,6 +2677,7 @@ function spawnTrustedCommand(spec, options) { | |
| switch (spec.command) { | ||
| case "codesign": return spawn("codesign", args, options); | ||
| case "docker": return spawn("docker", args, options); | ||
| case "podman": return spawn("podman", args, options); | ||
| case "hdiutil": return spawn("hdiutil", args, options); | ||
| case "open": return spawn("open", args, options); | ||
| case "powershell.exe": | ||
|
|
@@ -2791,6 +2814,7 @@ Usage: ${UNIVERSAL_COMMAND} <command> | |
| uninstall [--purge-data] Remove containers; keep data by default | ||
|
|
||
| Options: | ||
| --engine <auto|docker|podman> Select container engine (or set SPACEAPP_CONTAINER_ENGINE) | ||
| --plan Dry-run mode: print actions without executing | ||
| --json Output structured JSON where supported | ||
| --log-file <file> Append all execution logs to specified file | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -81,6 +81,7 @@ const PROFILE_RUNTIME_SETTINGS = Object.freeze({ | |
| }) | ||
| }); | ||
| export const INSTALL_PROFILES = Object.freeze(["small", "medium", "large", "light", "standard"]); | ||
| export const CONTAINER_ENGINES = Object.freeze(["docker", "podman"]); | ||
| export const PROFILE_REQUIREMENTS = Object.freeze({ | ||
| small: { cpuCount: 4, memoryBytes: 7 * 1024 ** 3, freshDiskBytes: 6.5 * 1024 ** 3 }, | ||
| medium: { cpuCount: 4, memoryBytes: 11 * 1024 ** 3, freshDiskBytes: 8 * 1024 ** 3 }, | ||
|
|
@@ -131,6 +132,7 @@ const CONFIG_KEYS = new Set([ | |
| "workspaces", | ||
| "companionsEnabled", | ||
| "optionalModules", | ||
| "containerEngine", | ||
| "installId", | ||
| "users" | ||
| ]); | ||
|
|
@@ -197,6 +199,23 @@ export function resolveInstallAccessMode(requestedMode, existingMode = "isolated | |
| throw new Error("Install access mode must be isolated or host-root."); | ||
| } | ||
|
|
||
| export function resolveContainerEngine(requestedEngine, existingEngine = "docker") { | ||
| if (requestedEngine === undefined || requestedEngine === "auto") { | ||
| return existingEngine ?? "docker"; | ||
| } | ||
| if (requestedEngine === "docker" || requestedEngine === "podman") { | ||
| return requestedEngine; | ||
| } | ||
| throw new Error("Container engine must be auto, docker, or podman."); | ||
| } | ||
|
|
||
| export function podmanUnshareChownArgs(hostPath, { uid = 10001, gid = 10001 } = {}) { | ||
| if (typeof hostPath !== "string" || !isAbsolute(hostPath)) { | ||
| throw new Error("Host path for rootless Podman UID mapping must be absolute."); | ||
| } | ||
| return ["unshare", "chown", "-R", `${uid}:${gid}`, resolve(hostPath)]; | ||
| } | ||
|
|
||
| export function versionTuple(version) { | ||
| if (typeof version !== "string") { | ||
| return null; | ||
|
|
@@ -311,13 +330,15 @@ export function createDefaultConfig({ | |
| version, | ||
| profile = "light", | ||
| accessMode = "isolated", | ||
| companionsEnabled = false | ||
| companionsEnabled = false, | ||
| containerEngine | ||
| }) { | ||
| assertVersion(version); | ||
| if (!INSTALL_PROFILES.includes(profile)) { | ||
| throw new Error("Default config requires a resolved installation profile."); | ||
| } | ||
| const resolvedAccessMode = resolveInstallAccessMode(accessMode); | ||
| const resolvedEngine = containerEngine === undefined ? undefined : resolveContainerEngine(containerEngine); | ||
| return { | ||
| schemaVersion: CONFIG_SCHEMA_VERSION, | ||
| version, | ||
|
|
@@ -328,6 +349,7 @@ export function createDefaultConfig({ | |
| profile, | ||
| accessMode: resolvedAccessMode, | ||
| companionsEnabled, | ||
| ...(resolvedEngine ? { containerEngine: resolvedEngine } : {}), | ||
| optionalModules: "none", | ||
| workspaces: [] | ||
| }; | ||
|
|
@@ -373,6 +395,9 @@ export function validateConfig(config) { | |
| if (config.optionalModules !== undefined && !OPTIONAL_MODULES_PATTERN.test(config.optionalModules)) { | ||
| throw new Error("optionalModules must be all, none or a comma-separated list of module names."); | ||
| } | ||
| if (config.containerEngine !== undefined && config.containerEngine !== "docker" && config.containerEngine !== "podman") { | ||
| throw new Error("containerEngine must be docker or podman."); | ||
| } | ||
|
Comment on lines
+398
to
+400
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reject host-root access with Podman.
|
||
| // Optional: only installations whose owner said Yes to telemetry have one. | ||
| if (config.installId !== undefined && (typeof config.installId !== "string" || !/^[A-Za-z0-9_-]{8,64}$/.test(config.installId))) { | ||
| throw new Error("installId must be 8-64 letters, digits, '-' or '_'."); | ||
|
|
@@ -909,7 +934,11 @@ export function composeCommand(action, root, options = {}) { | |
| if (!selected) { | ||
| throw new Error(`Unsupported Compose action "${action}".`); | ||
| } | ||
| return { command: "docker", args: [...base, ...selected] }; | ||
| const engine = options.containerEngine ?? options.engine ?? "docker"; | ||
| if (engine !== "docker" && engine !== "podman") { | ||
| throw new Error(`Unsupported container engine "${engine}".`); | ||
| } | ||
| return { command: engine, args: [...base, ...selected] }; | ||
| } | ||
|
|
||
| export function composeProjectName(root) { | ||
|
|
@@ -984,7 +1013,8 @@ export async function prepareInstallation(root, { | |
| version, | ||
| profile, | ||
| accessMode, | ||
| companionsEnabled | ||
| companionsEnabled, | ||
| containerEngine | ||
| }) { | ||
| validateHome(root); | ||
| await mkdir(root, { recursive: true, mode: 0o700 }); | ||
|
|
@@ -1027,6 +1057,12 @@ export async function prepareInstallation(root, { | |
| if (companionsEnabled !== undefined && config.companionsEnabled !== companionsEnabled) { | ||
| config = { ...config, companionsEnabled }; | ||
| } | ||
| if (containerEngine !== undefined) { | ||
| const resolvedEngine = resolveContainerEngine(containerEngine, config.containerEngine); | ||
| if (config.containerEngine !== resolvedEngine) { | ||
| config = { ...config, containerEngine: resolvedEngine }; | ||
| } | ||
| } | ||
| validateConfig(config); | ||
|
|
||
| const postgresPasswordPath = join(root, "secrets", "postgres-password"); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -136,6 +136,10 @@ export async function ensureDockerAvailable({ | |
| scheduleResume = scheduleWindowsInstallResume, | ||
| confirmRestart = confirmWindowsRestart | ||
| }) { | ||
| const preferredEngine = installArgs?.requestedEngine ?? env?.SPACEAPP_CONTAINER_ENGINE; | ||
| if (preferredEngine === "podman") { | ||
| return ensureLinuxPodman({ env, stdin, stdout, stderr, execute, sleep }); | ||
| } | ||
| if (await dockerIsReady(execute)) { | ||
| return { code: 0, reexecuted: false }; | ||
| } | ||
|
|
@@ -654,6 +658,73 @@ async function dockerCliAndComposeAreAvailable(execute) { | |
| return true; | ||
| } | ||
|
|
||
|
|
||
| export async function podmanCliAndComposeAreAvailable(execute) { | ||
| for (const spec of [ | ||
| { command: "podman", args: ["--version"] }, | ||
| { command: "podman", args: ["compose", "version"] } | ||
| ]) { | ||
| if (await execute(spec, { stdout: null, stderr: null }) !== 0) { | ||
| return false; | ||
| } | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| export async function podmanIsReady(execute) { | ||
| for (const spec of [ | ||
| { command: "podman", args: ["--version"] }, | ||
| { command: "podman", args: ["compose", "version"] }, | ||
| { command: "podman", args: ["info"] } | ||
| ]) { | ||
| if (await execute(spec, { stdout: null, stderr: null }) !== 0) { | ||
| return false; | ||
| } | ||
| } | ||
| return true; | ||
|
Comment on lines
+674
to
+684
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| export async function detectContainerEngine({ | ||
| execute, | ||
| env = process.env, | ||
| preferredEngine | ||
| } = {}) { | ||
| const requested = preferredEngine ?? env?.SPACEAPP_CONTAINER_ENGINE ?? "auto"; | ||
| if (requested === "docker") return "docker"; | ||
| if (requested === "podman") return "podman"; | ||
| if (requested !== "auto") { | ||
| throw new Error("Container engine must be auto, docker, or podman."); | ||
| } | ||
| if (await dockerIsReady(execute)) return "docker"; | ||
| if (await podmanIsReady(execute)) return "podman"; | ||
| if (await dockerCliAndComposeAreAvailable(execute)) return "docker"; | ||
| if (await podmanCliAndComposeAreAvailable(execute)) return "podman"; | ||
| return "docker"; | ||
| } | ||
|
|
||
| async function ensureLinuxPodman({ | ||
| stdin, | ||
| stdout, | ||
| stderr, | ||
| execute | ||
| }) { | ||
| if (await podmanIsReady(execute)) { | ||
| stdout?.write("Rootless Podman engine is ready.\n"); | ||
| return { code: 0, reexecuted: false }; | ||
| } | ||
| const cliOk = await podmanCliAndComposeAreAvailable(execute); | ||
| if (!cliOk) { | ||
| stderr?.write( | ||
| "Podman and a Compose provider (podman-compose or docker-compose) are required when --engine podman is selected.\n" | ||
| ); | ||
| return { code: 1, reexecuted: false }; | ||
| } | ||
| stderr?.write( | ||
| "Podman CLI is installed, but rootless Podman could not complete 'podman info'. Verify subuid/subgid mappings (/etc/subuid, /etc/subgid).\n" | ||
| ); | ||
| return { code: 1, reexecuted: false }; | ||
| } | ||
|
|
||
| async function dockerIsReady(execute) { | ||
| for (const spec of [ | ||
| { command: "docker", args: ["--version"] }, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Assert the rootless probe result.
This command exits successfully when Podman reports
false, so the job can pass its “Verify rootless Podman” step without a rootless engine. Compare the output withtrueand fail the step otherwise. (docs.podman.io)