Compare commits

...

1 Commits

Author SHA1 Message Date
Nikhil Sonti
3c6f8c679d feat: add podman transport layer abstractions 2026-04-22 10:28:16 -07:00
10 changed files with 1897 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { PodmanApi } from './podman-api'
import { PodmanShell } from './podman-shell'
import type { PodmanTransport } from './podman-transport'
export function createPodmanTransport(
input:
| { mode: 'api'; socketPath: string }
| { mode: 'shell'; limactlPath?: string; vmName: string },
): PodmanTransport {
if (input.mode === 'api') {
return new PodmanApi({ socketPath: input.socketPath })
}
return new PodmanShell({
limactlPath: input.limactlPath,
vmName: input.vmName,
})
}

View File

@@ -0,0 +1,502 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { existsSync } from 'node:fs'
import { readPodmanEnvFile } from './podman-env'
import { buildApiError, PodmanTransportError } from './podman-errors'
import { readMultiplexedStream, readTextStream } from './podman-streams'
import type {
LogFn,
PodmanContainerCreateInput,
PodmanContainerSummary,
PodmanExecInput,
PodmanExecResult,
PodmanTransport,
} from './podman-transport'
type RequestInitWithUnix = RequestInit & { unix?: string }
type UnixFetch = (
input: string | URL | Request,
init?: RequestInitWithUnix,
) => Promise<Response>
interface PodmanApiDeps {
fetch?: UnixFetch
}
interface ExecCreateResponse {
Id?: string
}
interface ExecInspectResponse {
ExitCode?: number
}
interface PodmanApiResponse {
body: Response
version: string
}
export interface PodmanApiConfig {
socketPath: string
}
export class PodmanApi implements PodmanTransport {
private readonly fetchImpl: UnixFetch
private readonly socketPath: string
private versionPromise: Promise<string> | null = null
constructor(config: PodmanApiConfig, deps?: PodmanApiDeps) {
this.fetchImpl = deps?.fetch ?? (globalThis.fetch as UnixFetch)
this.socketPath = config.socketPath
}
async imageExists(image: string): Promise<boolean> {
const response = await this.requestVersioned(
`/images/${encodeName(image)}/exists`,
{ method: 'GET' },
)
if (response.body.status === 204) return true
if (response.body.status === 404) return false
throw await this.buildResponseError(
response.body,
`Failed to check whether image exists: ${image}`,
)
}
async pullImage(image: string, onLog?: LogFn): Promise<void> {
const query = new URLSearchParams({ reference: image })
const response = await this.requestVersioned(`/images/pull?${query}`, {
method: 'POST',
})
await this.assertOk(response.body, `Failed to pull image: ${image}`)
await readTextStream(response.body.body, onLog)
}
async loadImage(
input: { archivePath: string },
onLog?: LogFn,
): Promise<void> {
if (!existsSync(input.archivePath)) {
throw new PodmanTransportError(
`Image archive not found: ${input.archivePath}`,
'not_found',
false,
)
}
const response = await this.requestVersioned('/images/load', {
body: Bun.file(input.archivePath),
headers: { 'Content-Type': 'application/x-tar' },
method: 'POST',
})
await this.assertOk(
response.body,
`Failed to load image: ${input.archivePath}`,
)
await readTextStream(response.body.body, onLog)
}
async createContainer(
input: PodmanContainerCreateInput,
): Promise<{ id: string }> {
const body = await buildContainerCreateBody(input)
const response = await this.requestVersioned('/containers/create', {
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
})
await this.assertOk(
response.body,
`Failed to create container: ${input.name}`,
)
const parsed = (await response.body.json()) as { Id?: string; id?: string }
const id = parsed.Id ?? parsed.id
if (!id) {
throw new PodmanTransportError(
`Podman API returned no container ID for ${input.name}`,
'api_error',
false,
)
}
return { id }
}
async startContainer(name: string): Promise<void> {
const response = await this.requestVersioned(
`/containers/${encodeName(name)}/start`,
{ method: 'POST' },
)
if (response.body.ok || response.body.status === 304) return
throw await this.buildResponseError(
response.body,
`Failed to start container: ${name}`,
)
}
async stopContainer(
name: string,
options?: { ignore?: boolean; timeoutSeconds?: number },
): Promise<void> {
const query = new URLSearchParams()
if (options?.ignore) query.set('ignore', 'true')
if (options?.timeoutSeconds !== undefined) {
query.set('timeout', String(options.timeoutSeconds))
}
const response = await this.requestVersioned(
`/containers/${encodeName(name)}/stop${suffixQuery(query)}`,
{ method: 'POST' },
)
if (response.body.ok || response.body.status === 304) return
if (options?.ignore && response.body.status === 404) return
throw await this.buildResponseError(
response.body,
`Failed to stop container: ${name}`,
)
}
async removeContainer(
name: string,
options?: { force?: boolean; ignore?: boolean },
): Promise<void> {
const query = new URLSearchParams()
if (options?.force) query.set('force', 'true')
if (options?.ignore) query.set('ignore', 'true')
const response = await this.requestVersioned(
`/containers/${encodeName(name)}${suffixQuery(query)}`,
{ method: 'DELETE' },
)
if (response.body.ok) return
if (options?.ignore && response.body.status === 404) return
throw await this.buildResponseError(
response.body,
`Failed to remove container: ${name}`,
)
}
async inspectContainer(name: string): Promise<unknown> {
const response = await this.requestVersioned(
`/containers/${encodeName(name)}/json`,
{ method: 'GET' },
)
await this.assertOk(response.body, `Failed to inspect container: ${name}`)
return response.body.json()
}
async listContainers(options?: {
all?: boolean
}): Promise<PodmanContainerSummary[]> {
const query = new URLSearchParams()
if (options?.all) query.set('all', 'true')
const response = await this.requestVersioned(
`/containers/json${suffixQuery(query)}`,
{ method: 'GET' },
)
await this.assertOk(response.body, 'Failed to list containers')
const parsed = (await response.body.json()) as Array<
Record<string, unknown>
>
return parsed.map((entry) => ({
id: readString(entry.Id, entry.ID, entry.id),
image: readString(entry.Image, entry.image, entry.ImageID),
name: readName(entry.Names, entry.Name, entry.name),
state: readString(entry.State, entry.state, entry.Status),
status: readOptionalString(entry.Status, entry.status),
}))
}
async getLogs(name: string, options?: { tail?: number }): Promise<string[]> {
const lines: string[] = []
const response = await this.requestVersioned(
`/containers/${encodeName(name)}/logs${buildLogsQuery(options)}`,
{ method: 'GET' },
)
await this.assertOk(
response.body,
`Failed to read logs for container: ${name}`,
)
await readTextStream(response.body.body, (line) => lines.push(line))
return lines
}
async tailLogs(name: string, onLog: LogFn): Promise<() => void> {
const controller = new AbortController()
const response = await this.requestVersioned(
`/containers/${encodeName(name)}/logs${buildLogsQuery({ tail: 0 }, true)}`,
{
method: 'GET',
signal: controller.signal,
},
)
await this.assertOk(
response.body,
`Failed to follow logs for container: ${name}`,
)
void readTextStream(response.body.body, onLog).catch((error) => {
if (controller.signal.aborted) return
throw error
})
return () => controller.abort()
}
async exec(
name: string,
input: PodmanExecInput,
onLog?: LogFn,
): Promise<PodmanExecResult> {
const execId = await this.createExec(name, input)
const startResponse = await this.requestVersioned(
`/exec/${encodeName(execId)}/start`,
{
body: JSON.stringify({ Detach: false, Tty: false }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
},
)
await this.assertOk(
startResponse.body,
`Failed to start exec session: ${execId}`,
)
const { stdout, stderr } = await readMultiplexedStream(
startResponse.body.body,
onLog,
onLog,
)
const inspectResponse = await this.requestVersioned(
`/exec/${encodeName(execId)}/json`,
{ method: 'GET' },
)
await this.assertOk(
inspectResponse.body,
`Failed to inspect exec session: ${execId}`,
)
const inspectBody =
(await inspectResponse.body.json()) as ExecInspectResponse
return {
exitCode: inspectBody.ExitCode ?? 0,
stderr,
stdout,
}
}
private async createExec(
name: string,
input: PodmanExecInput,
): Promise<string> {
const response = await this.requestVersioned(
`/containers/${encodeName(name)}/exec`,
{
body: JSON.stringify({
AttachStderr: true,
AttachStdout: true,
Cmd: input.command,
Env: buildEnvList(input.env),
Tty: false,
WorkingDir: input.workingDir,
}),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
},
)
await this.assertOk(
response.body,
`Failed to create exec session for ${name}`,
)
const parsed = (await response.body.json()) as ExecCreateResponse
if (parsed.Id) return parsed.Id
throw new PodmanTransportError(
`Podman API returned no exec ID for container ${name}`,
'api_error',
false,
)
}
private async requestVersioned(
path: string,
init: RequestInitWithUnix,
): Promise<PodmanApiResponse> {
const version = await this.getVersion()
const body = await this.fetchPodman(
`http://localhost/${version}/libpod${path}`,
init,
)
return { body, version }
}
private async getVersion(): Promise<string> {
if (!this.versionPromise) {
this.versionPromise = this.fetchPodman('http://localhost/libpod/_ping', {
method: 'GET',
})
.then((response) => {
if (!response.ok) {
throw new PodmanTransportError(
`Podman API ping failed with status ${response.status}`,
'transport_unavailable',
true,
{ statusCode: response.status },
)
}
const header = response.headers.get('Libpod-API-Version')
return header ? `v${header.replace(/^v/, '')}` : 'v5.0.0'
})
.catch((error) => {
this.versionPromise = null
throw error
})
}
return this.versionPromise
}
private async fetchPodman(
url: string,
init: RequestInitWithUnix,
): Promise<Response> {
try {
return await this.fetchImpl(url, {
...init,
unix: this.socketPath,
})
} catch (error) {
throw new PodmanTransportError(
`Failed to reach Podman API on ${this.socketPath}`,
'transport_unavailable',
true,
undefined,
{ cause: error },
)
}
}
private async assertOk(response: Response, message: string): Promise<void> {
if (response.ok) return
throw await this.buildResponseError(response, message)
}
private async buildResponseError(
response: Response,
message: string,
): Promise<PodmanTransportError> {
const responseBody = await response.text()
return buildApiError(
`${message} (${response.status})`,
response.status,
responseBody,
)
}
}
export async function buildContainerCreateBody(
input: PodmanContainerCreateInput,
): Promise<Record<string, unknown>> {
const env = {
...(await readPodmanEnvFile(input.envFilePath)),
...(input.env ?? {}),
}
return {
command: input.command,
env,
healthconfig: buildHealthConfig(input),
hostadd: input.addHosts,
image: input.image,
mounts: (input.mounts ?? []).map((mount) => ({
ReadOnly: mount.readOnly,
Source: mount.source,
Target: mount.target,
Type: 'bind',
})),
name: input.name,
portmappings: (input.portMappings ?? []).map((mapping) => ({
container_port: mapping.containerPort,
host_ip: mapping.hostIp,
host_port: mapping.hostPort,
protocol: mapping.protocol ?? 'tcp',
})),
restart_policy: input.restartPolicy,
}
}
function buildEnvList(env?: Record<string, string>): string[] {
return Object.entries(env ?? {}).map(([key, value]) => `${key}=${value}`)
}
function buildHealthConfig(
input: PodmanContainerCreateInput,
): Record<string, unknown> | undefined {
if (!input.healthcheck) return undefined
return {
Interval: input.healthcheck.interval,
Retries: input.healthcheck.retries,
Test: input.healthcheck.test,
Timeout: input.healthcheck.timeout,
}
}
function buildLogsQuery(options?: { tail?: number }, follow = false): string {
const query = new URLSearchParams({
stderr: 'true',
stdout: 'true',
})
if (follow) query.set('follow', 'true')
if (options?.tail !== undefined) {
query.set('tail', String(options.tail))
}
return suffixQuery(query)
}
function suffixQuery(query: URLSearchParams): string {
const value = query.toString()
return value ? `?${value}` : ''
}
function encodeName(value: string): string {
return encodeURIComponent(value)
}
function readName(...values: unknown[]): string {
for (const value of values) {
if (Array.isArray(value) && typeof value[0] === 'string') {
return value[0]
}
if (typeof value === 'string' && value.trim()) return value
}
return ''
}
function readOptionalString(...values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value === 'string' && value.trim()) return value
}
return undefined
}
function readString(...values: unknown[]): string {
return readOptionalString(...values) ?? ''
}

View File

@@ -0,0 +1,32 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { readFile } from 'node:fs/promises'
export async function readPodmanEnvFile(
envFilePath?: string,
): Promise<Record<string, string>> {
if (!envFilePath) return {}
const content = await readFile(envFilePath, 'utf8')
return parsePodmanEnvContent(content)
}
export function parsePodmanEnvContent(content: string): Record<string, string> {
const env: Record<string, string> = {}
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const separator = line.indexOf('=')
if (separator <= 0) continue
const key = line.slice(0, separator).trim()
const value = line.slice(separator + 1)
if (!key) continue
env[key] = value
}
return env
}

View File

@@ -0,0 +1,134 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export type PodmanTransportErrorKind =
| 'not_found'
| 'conflict'
| 'timeout'
| 'permission_denied'
| 'transport_unavailable'
| 'command_failed'
| 'api_error'
| 'unknown'
export interface PodmanTransportErrorDetails {
command?: string[]
exitCode?: number
responseBody?: string
statusCode?: number
stderr?: string
stdout?: string
}
export class PodmanTransportError extends Error {
constructor(
message: string,
readonly kind: PodmanTransportErrorKind,
readonly retryable: boolean,
readonly details?: PodmanTransportErrorDetails,
options?: ErrorOptions,
) {
super(message, options)
this.name = 'PodmanTransportError'
}
}
export function buildApiError(
message: string,
statusCode: number,
responseBody: string,
): PodmanTransportError {
return new PodmanTransportError(
message,
getErrorKindForStatus(statusCode),
isRetryableStatus(statusCode),
{
responseBody,
statusCode,
},
)
}
export function buildCommandError(input: {
command: string[]
exitCode: number
stderr: string
stdout?: string
}): PodmanTransportError {
const stderr = input.stderr.trim()
const stdout = input.stdout?.trim()
const message =
stderr ||
stdout ||
`Podman command failed with exit code ${input.exitCode}: ${input.command.join(' ')}`
return new PodmanTransportError(
message,
getErrorKindForOutput(stderr || stdout || ''),
isRetryableExitCode(input.exitCode),
{
command: input.command,
exitCode: input.exitCode,
stderr,
stdout,
},
)
}
export function isPodmanTransportError(
error: unknown,
): error is PodmanTransportError {
return error instanceof PodmanTransportError
}
function getErrorKindForOutput(output: string): PodmanTransportErrorKind {
const text = output.toLowerCase()
if (
text.includes('no such') ||
text.includes('not found') ||
text.includes('does not exist')
) {
return 'not_found'
}
if (text.includes('already exists') || text.includes('is already in use')) {
return 'conflict'
}
if (
text.includes('permission denied') ||
text.includes('operation not permitted')
) {
return 'permission_denied'
}
if (
text.includes('connection refused') ||
text.includes('broken pipe') ||
text.includes('failed to connect') ||
text.includes('i/o timeout') ||
text.includes('timed out')
) {
return 'transport_unavailable'
}
return 'command_failed'
}
function getErrorKindForStatus(statusCode: number): PodmanTransportErrorKind {
if (statusCode === 404) return 'not_found'
if (statusCode === 409) return 'conflict'
if (statusCode === 408) return 'timeout'
if (statusCode === 401 || statusCode === 403) return 'permission_denied'
if (statusCode >= 500) return 'transport_unavailable'
return 'api_error'
}
function isRetryableStatus(statusCode: number): boolean {
return statusCode === 408 || statusCode === 429 || statusCode >= 500
}
function isRetryableExitCode(exitCode: number): boolean {
return exitCode !== 0
}

View File

@@ -0,0 +1,403 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { existsSync } from 'node:fs'
import { buildCommandError, PodmanTransportError } from './podman-errors'
import { readTextStream } from './podman-streams'
import type {
LogFn,
PodmanContainerCreateInput,
PodmanContainerSummary,
PodmanExecInput,
PodmanExecResult,
PodmanTransport,
} from './podman-transport'
type BunSpawn = typeof Bun.spawn
type SpawnOptions = Exclude<Parameters<BunSpawn>[1], undefined>
type SpawnResult = ReturnType<BunSpawn>
type SpawnReadable = ReadableStream<Uint8Array<ArrayBufferLike>> | null
interface PodmanShellDeps {
spawn?: BunSpawn
}
interface ShellCommandResult {
exitCode: number
stderr: string
stdout: string
}
export interface PodmanShellConfig {
limactlPath?: string
vmName: string
}
export class PodmanShell implements PodmanTransport {
private readonly limactlPath: string
private readonly spawn: BunSpawn
private readonly vmName: string
constructor(config: PodmanShellConfig, deps?: PodmanShellDeps) {
this.limactlPath = config.limactlPath ?? 'limactl'
this.spawn = deps?.spawn ?? Bun.spawn
this.vmName = config.vmName
}
async imageExists(image: string): Promise<boolean> {
const result = await this.runPodmanCommand(['image', 'exists', image])
if (result.exitCode === 0) return true
if (result.exitCode === 1 && !result.stderr.trim()) return false
throw this.buildShellError(['image', 'exists', image], result)
}
async pullImage(image: string, onLog?: LogFn): Promise<void> {
const result = await this.runPodmanCommand(['pull', image], {
onOutput: onLog,
})
if (result.exitCode === 0) return
throw this.buildShellError(['pull', image], result)
}
async loadImage(
input: { archivePath: string },
onLog?: LogFn,
): Promise<void> {
if (!existsSync(input.archivePath)) {
throw new PodmanTransportError(
`Image archive not found: ${input.archivePath}`,
'not_found',
false,
)
}
const result = await this.runPodmanCommand(['load'], {
onOutput: onLog,
stdin: Bun.file(input.archivePath),
})
if (result.exitCode === 0) return
throw this.buildShellError(['load'], result)
}
async createContainer(
input: PodmanContainerCreateInput,
): Promise<{ id: string }> {
const args = buildPodmanCreateArgs(input)
const result = await this.runPodmanCommand(args)
if (result.exitCode !== 0) throw this.buildShellError(args, result)
const id = result.stdout.trim()
if (!id) {
throw new PodmanTransportError(
`Podman create returned no container ID for ${input.name}`,
'command_failed',
false,
{
command: buildPodmanShellCommand(this.limactlPath, this.vmName, args),
stdout: result.stdout,
},
)
}
return { id }
}
async startContainer(name: string): Promise<void> {
await this.runRequired(['start', name])
}
async stopContainer(
name: string,
options?: { ignore?: boolean; timeoutSeconds?: number },
): Promise<void> {
const args = ['stop']
if (options?.ignore) args.push('--ignore')
if (options?.timeoutSeconds !== undefined) {
args.push('--time', String(options.timeoutSeconds))
}
args.push(name)
await this.runRequired(args)
}
async removeContainer(
name: string,
options?: { force?: boolean; ignore?: boolean },
): Promise<void> {
const args = ['rm']
if (options?.force) args.push('--force')
if (options?.ignore) args.push('--ignore')
args.push(name)
await this.runRequired(args)
}
async inspectContainer(name: string): Promise<unknown> {
const result = await this.runPodmanCommand([
'inspect',
'--format',
'json',
name,
])
if (result.exitCode !== 0) {
throw this.buildShellError(['inspect', '--format', 'json', name], result)
}
const parsed = JSON.parse(result.stdout) as unknown
if (Array.isArray(parsed)) return parsed[0] ?? null
return parsed
}
async listContainers(options?: {
all?: boolean
}): Promise<PodmanContainerSummary[]> {
const args = ['ps']
if (options?.all) args.push('--all')
args.push('--format', 'json')
const result = await this.runPodmanCommand(args)
if (result.exitCode !== 0) throw this.buildShellError(args, result)
return parseContainerList(result.stdout)
}
async getLogs(name: string, options?: { tail?: number }): Promise<string[]> {
const lines: string[] = []
const args = ['logs']
if (options?.tail !== undefined) {
args.push('--tail', String(options.tail))
}
args.push(name)
const result = await this.runPodmanCommand(args, {
onOutput: (line) => lines.push(line),
})
if (result.exitCode !== 0) throw this.buildShellError(args, result)
return lines
}
async tailLogs(name: string, onLog: LogFn): Promise<() => void> {
const process = this.spawnPodmanCommand(
['logs', '-f', '--tail', '0', name],
{
stderr: 'pipe',
stdout: 'pipe',
},
)
void Promise.all([
readTextStream(getSpawnStream(process.stdout), onLog),
readTextStream(getSpawnStream(process.stderr), onLog),
])
let stopped = false
return () => {
if (stopped) return
stopped = true
process.kill()
}
}
async exec(
name: string,
input: PodmanExecInput,
onLog?: LogFn,
): Promise<PodmanExecResult> {
const args = buildPodmanExecArgs(name, input)
const process = this.spawnPodmanCommand(args, {
stderr: 'pipe',
stdout: 'pipe',
})
const [stdout, stderr, exitCode] = await Promise.all([
readTextStream(getSpawnStream(process.stdout), onLog),
readTextStream(getSpawnStream(process.stderr), onLog),
process.exited,
])
return { exitCode, stdout, stderr }
}
private async runRequired(args: string[]): Promise<void> {
const result = await this.runPodmanCommand(args)
if (result.exitCode === 0) return
throw this.buildShellError(args, result)
}
private async runPodmanCommand(
args: string[],
options?: { onOutput?: LogFn; stdin?: SpawnOptions['stdin'] },
): Promise<ShellCommandResult> {
const process = this.spawnPodmanCommand(args, {
stderr: 'pipe',
stdin: options?.stdin,
stdout: 'pipe',
})
const [stdout, stderr, exitCode] = await Promise.all([
readTextStream(getSpawnStream(process.stdout), options?.onOutput),
readTextStream(getSpawnStream(process.stderr), options?.onOutput),
process.exited,
])
return { exitCode, stderr, stdout }
}
private spawnPodmanCommand(
args: string[],
options: Partial<SpawnOptions>,
): SpawnResult {
return this.spawn(
buildPodmanShellCommand(this.limactlPath, this.vmName, args),
{
stderr: options.stderr ?? 'ignore',
stdin: options.stdin,
stdout: options.stdout ?? 'ignore',
},
)
}
private buildShellError(
args: string[],
result: ShellCommandResult,
): PodmanTransportError {
return buildCommandError({
command: buildPodmanShellCommand(this.limactlPath, this.vmName, args),
exitCode: result.exitCode,
stderr: result.stderr,
stdout: result.stdout,
})
}
}
export function buildPodmanShellCommand(
limactlPath: string,
vmName: string,
args: string[],
): string[] {
return [limactlPath, 'shell', '--tty=false', vmName, '--', 'podman', ...args]
}
export function buildPodmanCreateArgs(
input: PodmanContainerCreateInput,
): string[] {
const args = ['create', '--name', input.name]
if (input.restartPolicy) {
args.push('--restart', input.restartPolicy)
}
if (input.envFilePath) {
args.push('--env-file', input.envFilePath)
}
for (const [key, value] of Object.entries(input.env ?? {})) {
args.push('-e', `${key}=${value}`)
}
for (const mount of input.mounts ?? []) {
args.push('-v', buildMountArg(mount))
}
for (const portMapping of input.portMappings ?? []) {
args.push('-p', buildPortMappingArg(portMapping))
}
for (const host of input.addHosts ?? []) {
args.push('--add-host', host)
}
if (input.healthcheck) {
args.push('--health-cmd', JSON.stringify(input.healthcheck.test))
if (input.healthcheck.interval) {
args.push('--health-interval', input.healthcheck.interval)
}
if (input.healthcheck.timeout) {
args.push('--health-timeout', input.healthcheck.timeout)
}
if (input.healthcheck.retries !== undefined) {
args.push('--health-retries', String(input.healthcheck.retries))
}
}
args.push(input.image)
args.push(...(input.command ?? []))
return args
}
export function buildPodmanExecArgs(
name: string,
input: PodmanExecInput,
): string[] {
const args = ['exec']
if (input.workingDir) {
args.push('--workdir', input.workingDir)
}
for (const [key, value] of Object.entries(input.env ?? {})) {
args.push('--env', `${key}=${value}`)
}
args.push(name)
args.push(...input.command)
return args
}
function buildMountArg(input: {
source: string
target: string
readOnly?: boolean
}): string {
const suffix = input.readOnly ? ':ro' : ''
return `${input.source}:${input.target}${suffix}`
}
function buildPortMappingArg(input: {
hostIp?: string
hostPort: number
containerPort: number
protocol?: string
}): string {
const prefix = input.hostIp
? `${input.hostIp}:${input.hostPort}`
: `${input.hostPort}`
const protocol = input.protocol ? `/${input.protocol}` : ''
return `${prefix}:${input.containerPort}${protocol}`
}
function parseContainerList(output: string): PodmanContainerSummary[] {
const parsed = JSON.parse(output) as Array<Record<string, unknown>>
return parsed.map((entry) => ({
id: readString(entry.Id, entry.ID, entry.id),
image: readString(entry.Image, entry.image, entry.ImageID),
name: readName(entry.Names, entry.Name, entry.name),
state: readString(entry.State, entry.state, entry.Status),
status: readOptionalString(entry.Status, entry.status),
}))
}
function readName(...values: unknown[]): string {
for (const value of values) {
if (Array.isArray(value) && typeof value[0] === 'string') {
return value[0]
}
if (typeof value === 'string' && value.trim()) return value
}
return ''
}
function readOptionalString(...values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value === 'string' && value.trim()) return value
}
return undefined
}
function readString(...values: unknown[]): string {
return readOptionalString(...values) ?? ''
}
function getSpawnStream(
stream: SpawnResult['stdout'] | SpawnResult['stderr'],
): SpawnReadable {
return stream instanceof ReadableStream ? stream : null
}

View File

@@ -0,0 +1,118 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { LogFn } from './podman-transport'
const MULTIPLEX_HEADER_SIZE = 8
type PodmanByteStream = ReadableStream<Uint8Array<ArrayBufferLike>>
export async function readTextStream(
stream: PodmanByteStream | null,
onLine?: LogFn,
): Promise<string> {
if (!stream) return ''
const reader = stream.getReader()
const decoder = new TextDecoder()
let text = ''
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
text += chunk
if (!onLine) continue
buffer += chunk
buffer = flushLineBuffer(buffer, onLine)
}
const trailing = decoder.decode()
text += trailing
if (!onLine) return text
buffer += trailing
if (buffer.trim()) onLine(buffer.trim())
return text
}
export async function readMultiplexedStream(
stream: PodmanByteStream | null,
onStdout?: LogFn,
onStderr?: LogFn,
): Promise<{ stdout: string; stderr: string }> {
if (!stream) return { stdout: '', stderr: '' }
const reader = stream.getReader()
const stdoutDecoder = new TextDecoder()
const stderrDecoder = new TextDecoder()
let stdout = ''
let stderr = ''
let stdoutBuffer = ''
let stderrBuffer = ''
let buffer: Uint8Array<ArrayBufferLike> = new Uint8Array(0)
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer = concatBytes(buffer, value)
while (buffer.length >= MULTIPLEX_HEADER_SIZE) {
const frameSize =
(buffer[4] << 24) | (buffer[5] << 16) | (buffer[6] << 8) | buffer[7]
if (buffer.length < MULTIPLEX_HEADER_SIZE + frameSize) break
const streamType = buffer[0]
const payload = buffer.slice(
MULTIPLEX_HEADER_SIZE,
MULTIPLEX_HEADER_SIZE + frameSize,
)
buffer = buffer.slice(MULTIPLEX_HEADER_SIZE + frameSize)
if (streamType === 2) {
const decoded = stderrDecoder.decode(payload, { stream: true })
stderr += decoded
if (onStderr)
stderrBuffer = flushLineBuffer(stderrBuffer + decoded, onStderr)
continue
}
const decoded = stdoutDecoder.decode(payload, { stream: true })
stdout += decoded
if (onStdout)
stdoutBuffer = flushLineBuffer(stdoutBuffer + decoded, onStdout)
}
}
stdout += stdoutDecoder.decode()
stderr += stderrDecoder.decode()
if (onStdout && stdoutBuffer.trim()) onStdout(stdoutBuffer.trim())
if (onStderr && stderrBuffer.trim()) onStderr(stderrBuffer.trim())
return { stdout, stderr }
}
function concatBytes(
left: Uint8Array<ArrayBufferLike>,
right: Uint8Array<ArrayBufferLike>,
): Uint8Array<ArrayBufferLike> {
const combined = new Uint8Array(left.length + right.length)
combined.set(left)
combined.set(right, left.length)
return combined
}
function flushLineBuffer(buffer: string, onLine: LogFn): string {
const lines = buffer.split('\n')
const trailing = lines.pop() ?? ''
for (const line of lines) {
const trimmed = line.trim()
if (trimmed) onLine(trimmed)
}
return trailing
}

View File

@@ -0,0 +1,85 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export type LogFn = (line: string) => void
export interface PodmanHealthcheck {
test: string[]
interval?: string
timeout?: string
retries?: number
}
export interface PodmanMount {
source: string
target: string
readOnly?: boolean
}
export interface PodmanPortMapping {
hostIp?: string
hostPort: number
containerPort: number
protocol?: 'tcp' | 'udp' | 'sctp'
}
export interface PodmanContainerCreateInput {
name: string
image: string
command?: string[]
env?: Record<string, string>
envFilePath?: string
mounts?: PodmanMount[]
portMappings?: PodmanPortMapping[]
restartPolicy?: 'no' | 'unless-stopped' | 'on-failure' | 'always'
addHosts?: string[]
healthcheck?: PodmanHealthcheck
}
export interface PodmanExecInput {
command: string[]
workingDir?: string
env?: Record<string, string>
}
export interface PodmanExecResult {
exitCode: number
stdout: string
stderr: string
}
export interface PodmanContainerSummary {
id: string
name: string
image: string
state: string
status?: string
}
export interface PodmanTransport {
imageExists(image: string): Promise<boolean>
pullImage(image: string, onLog?: LogFn): Promise<void>
loadImage(input: { archivePath: string }, onLog?: LogFn): Promise<void>
createContainer(input: PodmanContainerCreateInput): Promise<{ id: string }>
startContainer(name: string): Promise<void>
stopContainer(
name: string,
options?: { ignore?: boolean; timeoutSeconds?: number },
): Promise<void>
removeContainer(
name: string,
options?: { force?: boolean; ignore?: boolean },
): Promise<void>
inspectContainer(name: string): Promise<unknown>
listContainers(options?: { all?: boolean }): Promise<PodmanContainerSummary[]>
getLogs(name: string, options?: { tail?: number }): Promise<string[]>
tailLogs(name: string, onLog: LogFn): Promise<() => void>
exec(
name: string,
input: PodmanExecInput,
onLog?: LogFn,
): Promise<PodmanExecResult>
}

View File

@@ -0,0 +1,279 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it, mock } from 'bun:test'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
buildContainerCreateBody,
PodmanApi,
} from '../../../src/lib/podman/podman-api'
import { PodmanTransportError } from '../../../src/lib/podman/podman-errors'
describe('PodmanApi', () => {
afterEach(() => {
mock.restore()
})
it('checks image existence over fetch with a unix socket', async () => {
const fetchMock = mock<(...args: unknown[]) => Promise<Response>>()
.mockResolvedValueOnce(
new Response(null, {
headers: { 'Libpod-API-Version': '5.4.1' },
status: 200,
}),
)
.mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(new Response(null, { status: 404 }))
const api = new PodmanApi(
{ socketPath: '/tmp/podman.sock' },
{ fetch: fetchMock as never },
)
await expect(api.imageExists('hello-world:latest')).resolves.toBe(true)
await expect(api.imageExists('missing:latest')).resolves.toBe(false)
expect(fetchMock.mock.calls[0]?.[0]).toBe('http://localhost/libpod/_ping')
expect(fetchMock.mock.calls[1]?.[0]).toBe(
'http://localhost/v5.4.1/libpod/images/hello-world%3Alatest/exists',
)
expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({
method: 'GET',
unix: '/tmp/podman.sock',
})
})
it('builds a container create body from typed input', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'podman-api-test-'))
const envFilePath = join(tempDir, '.env')
writeFileSync(envFilePath, 'FROM_FILE=value\nSHARED=env-file\n')
try {
await expect(
buildContainerCreateBody({
name: 'gateway',
image: 'busybox:latest',
command: ['sleep', '60'],
env: { SHARED: 'inline', TOKEN: 'abc' },
envFilePath,
mounts: [
{ source: '/guest/home', target: '/home/node', readOnly: true },
],
portMappings: [
{ hostIp: '127.0.0.1', hostPort: 18789, containerPort: 18789 },
],
restartPolicy: 'unless-stopped',
addHosts: ['host.containers.internal:host-gateway'],
healthcheck: {
test: ['CMD', 'curl', '-sf', 'http://127.0.0.1:18789/healthz'],
interval: '30s',
timeout: '10s',
retries: 3,
},
}),
).resolves.toEqual({
command: ['sleep', '60'],
env: {
FROM_FILE: 'value',
SHARED: 'inline',
TOKEN: 'abc',
},
healthconfig: {
Interval: '30s',
Retries: 3,
Test: ['CMD', 'curl', '-sf', 'http://127.0.0.1:18789/healthz'],
Timeout: '10s',
},
hostadd: ['host.containers.internal:host-gateway'],
image: 'busybox:latest',
mounts: [
{
ReadOnly: true,
Source: '/guest/home',
Target: '/home/node',
Type: 'bind',
},
],
name: 'gateway',
portmappings: [
{
container_port: 18789,
host_ip: '127.0.0.1',
host_port: 18789,
protocol: 'tcp',
},
],
restart_policy: 'unless-stopped',
})
} finally {
rmSync(tempDir, { force: true, recursive: true })
}
})
it('surfaces env file read failures when building a container create body', async () => {
await expect(
buildContainerCreateBody({
name: 'gateway',
image: 'busybox:latest',
envFilePath: '/definitely/missing/.env',
}),
).rejects.toBeInstanceOf(Error)
})
it('creates containers with libpod JSON requests', async () => {
const fetchMock = mock<(...args: unknown[]) => Promise<Response>>()
.mockResolvedValueOnce(
new Response(null, {
headers: { 'Libpod-API-Version': '5.0.0' },
status: 200,
}),
)
.mockResolvedValueOnce(
Response.json({
Id: 'container-123',
}),
)
const api = new PodmanApi(
{ socketPath: '/tmp/podman.sock' },
{ fetch: fetchMock as never },
)
await expect(
api.createContainer({
name: 'gateway',
image: 'busybox:latest',
command: ['sleep', '60'],
}),
).resolves.toEqual({ id: 'container-123' })
expect(fetchMock.mock.calls[1]?.[0]).toBe(
'http://localhost/v5.0.0/libpod/containers/create',
)
expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({
headers: { 'Content-Type': 'application/json' },
method: 'POST',
unix: '/tmp/podman.sock',
})
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toEqual({
command: ['sleep', '60'],
env: {},
healthconfig: undefined,
hostadd: undefined,
image: 'busybox:latest',
mounts: [],
name: 'gateway',
portmappings: [],
restart_policy: undefined,
})
})
it('parses multiplexed exec output and returns the exec exit code', async () => {
const fetchMock = mock<(...args: unknown[]) => Promise<Response>>()
.mockResolvedValueOnce(
new Response(null, {
headers: { 'Libpod-API-Version': '5.0.0' },
status: 200,
}),
)
.mockResolvedValueOnce(Response.json({ Id: 'exec-1' }))
.mockResolvedValueOnce(
new Response(createMultiplexedStream(), {
headers: {
'Content-Type': 'application/vnd.docker.multiplexed-stream',
},
status: 200,
}),
)
.mockResolvedValueOnce(Response.json({ ExitCode: 17 }))
const api = new PodmanApi(
{ socketPath: '/tmp/podman.sock' },
{ fetch: fetchMock as never },
)
const lines: string[] = []
await expect(
api.exec(
'gateway',
{
command: ['node', 'dist/index.js', 'agents', 'list', '--json'],
env: { NODE_ENV: 'production' },
workingDir: '/home/node',
},
(line) => lines.push(line),
),
).resolves.toEqual({
exitCode: 17,
stderr: 'stderr line\n',
stdout: 'stdout line\n',
})
expect(fetchMock.mock.calls[1]?.[0]).toBe(
'http://localhost/v5.0.0/libpod/containers/gateway/exec',
)
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toEqual({
AttachStderr: true,
AttachStdout: true,
Cmd: ['node', 'dist/index.js', 'agents', 'list', '--json'],
Env: ['NODE_ENV=production'],
Tty: false,
WorkingDir: '/home/node',
})
expect(lines).toEqual(['stdout line', 'stderr line'])
})
it('maps HTTP errors into PodmanTransportError', async () => {
const fetchMock = mock<(...args: unknown[]) => Promise<Response>>()
.mockResolvedValueOnce(
new Response(null, {
headers: { 'Libpod-API-Version': '5.0.0' },
status: 200,
}),
)
.mockResolvedValueOnce(new Response('not found', { status: 404 }))
const api = new PodmanApi(
{ socketPath: '/tmp/podman.sock' },
{ fetch: fetchMock as never },
)
await expect(api.inspectContainer('missing')).rejects.toBeInstanceOf(
PodmanTransportError,
)
})
})
function createMultiplexedStream(): ReadableStream<Uint8Array> {
const stdout = encodeFrame(1, 'stdout line\n')
const stderr = encodeFrame(2, 'stderr line\n')
return new ReadableStream({
start(controller) {
controller.enqueue(stdout.slice(0, 5))
controller.enqueue(concat(stdout.slice(5), stderr))
controller.close()
},
})
}
function encodeFrame(streamType: number, text: string): Uint8Array {
const payload = new TextEncoder().encode(text)
const header = new Uint8Array(8)
header[0] = streamType
header[4] = (payload.length >>> 24) & 0xff
header[5] = (payload.length >>> 16) & 0xff
header[6] = (payload.length >>> 8) & 0xff
header[7] = payload.length & 0xff
return concat(header, payload)
}
function concat(left: Uint8Array, right: Uint8Array): Uint8Array {
const combined = new Uint8Array(left.length + right.length)
combined.set(left)
combined.set(right, left.length)
return combined
}

View File

@@ -0,0 +1,27 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it } from 'bun:test'
import { PodmanShell } from '../../../src/lib/podman/podman-shell'
const describeIntegration = process.env.RUN_PODMAN_SHELL_INTEGRATION
? describe
: describe.skip
describeIntegration('PodmanShell integration', () => {
const vmName = process.env.PODMAN_SHELL_VM_NAME || 'browseros-vm'
const limactlPath = process.env.PODMAN_SHELL_LIMACTL_PATH || 'limactl'
const shell = new PodmanShell({ limactlPath, vmName })
it('checks image existence against a real Lima VM', async () => {
const exists = await shell.imageExists('hello-world:latest')
expect(typeof exists).toBe('boolean')
})
it('lists containers against a real Lima VM', async () => {
const containers = await shell.listContainers({ all: true })
expect(Array.isArray(containers)).toBe(true)
})
})

View File

@@ -0,0 +1,293 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it, mock } from 'bun:test'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PodmanTransportError } from '../../../src/lib/podman/podman-errors'
import {
buildPodmanCreateArgs,
buildPodmanExecArgs,
buildPodmanShellCommand,
PodmanShell,
} from '../../../src/lib/podman/podman-shell'
describe('PodmanShell', () => {
afterEach(() => {
mock.restore()
})
it('builds limactl shell commands in automation mode', () => {
expect(
buildPodmanShellCommand('limactl', 'browseros-vm', [
'ps',
'--format',
'json',
]),
).toEqual([
'limactl',
'shell',
'--tty=false',
'browseros-vm',
'--',
'podman',
'ps',
'--format',
'json',
])
})
it('returns true when podman image exists exits zero', async () => {
const spawn = createSpawnMock([{ exitCode: 0 }])
const shell = new PodmanShell({ vmName: 'browseros-vm' }, { spawn })
await expect(shell.imageExists('hello-world:latest')).resolves.toBe(true)
})
it('returns false when podman image exists exits one without stderr', async () => {
const spawn = createSpawnMock([{ exitCode: 1 }])
const shell = new PodmanShell({ vmName: 'browseros-vm' }, { spawn })
await expect(shell.imageExists('hello-world:latest')).resolves.toBe(false)
})
it('streams pull progress and throws typed errors on failure', async () => {
const spawn = createSpawnMock([
{
exitCode: 125,
stderr: 'Error: connection refused\n',
stdout: 'Trying to pull...\n',
},
])
const shell = new PodmanShell({ vmName: 'browseros-vm' }, { spawn })
const lines: string[] = []
await expect(
shell.pullImage('busybox:latest', (line) => lines.push(line)),
).rejects.toBeInstanceOf(PodmanTransportError)
expect(lines).toEqual(['Trying to pull...', 'Error: connection refused'])
})
it('builds create arguments from the typed container input', () => {
expect(
buildPodmanCreateArgs({
name: 'gateway',
image: 'ghcr.io/openclaw/openclaw:latest',
command: ['node', 'dist/index.js', 'gateway'],
env: { HOME: '/home/node', NODE_ENV: 'production' },
envFilePath: '/guest/.env',
mounts: [{ source: '/guest/home', target: '/home/node' }],
portMappings: [
{
hostIp: '127.0.0.1',
hostPort: 18789,
containerPort: 18789,
},
],
restartPolicy: 'unless-stopped',
addHosts: ['host.containers.internal:host-gateway'],
healthcheck: {
test: ['CMD', 'curl', '-sf', 'http://127.0.0.1:18789/healthz'],
interval: '30s',
timeout: '10s',
retries: 3,
},
}),
).toEqual([
'create',
'--name',
'gateway',
'--restart',
'unless-stopped',
'--env-file',
'/guest/.env',
'-e',
'HOME=/home/node',
'-e',
'NODE_ENV=production',
'-v',
'/guest/home:/home/node',
'-p',
'127.0.0.1:18789:18789',
'--add-host',
'host.containers.internal:host-gateway',
'--health-cmd',
'["CMD","curl","-sf","http://127.0.0.1:18789/healthz"]',
'--health-interval',
'30s',
'--health-timeout',
'10s',
'--health-retries',
'3',
'ghcr.io/openclaw/openclaw:latest',
'node',
'dist/index.js',
'gateway',
])
})
it('creates containers and returns the resulting container id', async () => {
const spawn = createSpawnMock([{ exitCode: 0, stdout: 'container-123\n' }])
const shell = new PodmanShell({ vmName: 'browseros-vm' }, { spawn })
await expect(
shell.createContainer({
name: 'gateway',
image: 'busybox:latest',
command: ['sleep', '60'],
}),
).resolves.toEqual({ id: 'container-123' })
expect(spawn.mock.calls[0]?.[0]).toEqual([
'limactl',
'shell',
'--tty=false',
'browseros-vm',
'--',
'podman',
'create',
'--name',
'gateway',
'busybox:latest',
'sleep',
'60',
])
})
it('supports host archive loading via stdin', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'podman-shell-test-'))
const archivePath = join(tempDir, 'image.tar.gz')
writeFileSync(archivePath, 'archive')
const spawn = createSpawnMock([{ exitCode: 0 }])
const shell = new PodmanShell({ vmName: 'browseros-vm' }, { spawn })
try {
await shell.loadImage({ archivePath })
expect(spawn.mock.calls[0]?.[0]).toEqual([
'limactl',
'shell',
'--tty=false',
'browseros-vm',
'--',
'podman',
'load',
])
expect(spawn.mock.calls[0]?.[1]?.stdin).toBeDefined()
} finally {
rmSync(tempDir, { force: true, recursive: true })
}
})
it('builds exec arguments and returns stdout, stderr, and exit code', async () => {
const spawn = createSpawnMock([
{
exitCode: 17,
stderr: 'stderr line\n',
stdout: 'stdout line\n',
},
])
const shell = new PodmanShell({ vmName: 'browseros-vm' }, { spawn })
const lines: string[] = []
expect(
buildPodmanExecArgs('gateway', {
command: ['node', 'dist/index.js', 'agents', 'list', '--json'],
env: { NODE_ENV: 'production' },
workingDir: '/home/node',
}),
).toEqual([
'exec',
'--workdir',
'/home/node',
'--env',
'NODE_ENV=production',
'gateway',
'node',
'dist/index.js',
'agents',
'list',
'--json',
])
await expect(
shell.exec(
'gateway',
{
command: ['node', 'dist/index.js', 'agents', 'list', '--json'],
env: { NODE_ENV: 'production' },
workingDir: '/home/node',
},
(line) => lines.push(line),
),
).resolves.toEqual({
exitCode: 17,
stderr: 'stderr line\n',
stdout: 'stdout line\n',
})
expect(lines).toEqual(['stdout line', 'stderr line'])
})
it('parses podman ps JSON into typed container summaries', async () => {
const spawn = createSpawnMock([
{
exitCode: 0,
stdout: JSON.stringify([
{
Id: 'abc123',
Image: 'busybox:latest',
Names: ['gateway'],
State: 'running',
Status: 'Up 3s',
},
]),
},
])
const shell = new PodmanShell({ vmName: 'browseros-vm' }, { spawn })
await expect(shell.listContainers({ all: true })).resolves.toEqual([
{
id: 'abc123',
image: 'busybox:latest',
name: 'gateway',
state: 'running',
status: 'Up 3s',
},
])
})
})
type SpawnSpec = {
exitCode?: number
stderr?: string
stdout?: string
}
function createSpawnMock(processes: SpawnSpec[]): typeof Bun.spawn {
return mock((_args: string[], _options?: unknown) => {
const process = processes.shift() ?? {}
return createFakeProcess(process) as never
}) as unknown as typeof Bun.spawn
}
function createFakeProcess(spec: SpawnSpec): ReturnType<typeof Bun.spawn> {
return {
exited: Promise.resolve(spec.exitCode ?? 0),
kill: mock(() => {}),
stderr: createTextStream(spec.stderr ?? ''),
stdout: createTextStream(spec.stdout ?? ''),
} as never
}
function createTextStream(content: string): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(content))
controller.close()
},
})
}