mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 08:03:58 +00:00
Compare commits
1 Commits
fix/patch-
...
feat/podma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c6f8c679d |
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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) ?? ''
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user