6376 lines
215 KiB
TypeScript
6376 lines
215 KiB
TypeScript
/**
|
|
* Docker Operations Module
|
|
*
|
|
* Uses direct Docker API calls over Unix socket or HTTP/HTTPS.
|
|
* No external dependencies like dockerode - uses Node.js fetch.
|
|
*/
|
|
|
|
import { homedir } from 'node:os';
|
|
import { existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import * as http from 'node:http';
|
|
import * as https from 'node:https';
|
|
import { Readable } from 'node:stream';
|
|
import * as tls from 'node:tls';
|
|
import { createHash } from 'node:crypto';
|
|
import { pumpWebStreamToWritable } from './stream-pump';
|
|
import { computeRequestTimeoutMs } from './backups/request-timeout';
|
|
import type { Environment } from './db';
|
|
import { getSetting } from './db';
|
|
import { getAdditionalVolumeBinds, dedupeVolumesForRecreate } from './mount-dedupe';
|
|
import { resolveNanoCpusConflict } from './hostconfig-recreate';
|
|
import { rebaseEnvOntoImage, rebaseLabelsOntoImage, rebaseCommand, describeEnvRebase, describeLabelRebase, type ImageEnvLabels } from './container-env-merge';
|
|
import { encodeRegistryAuth } from './registry-auth';
|
|
import { isSystemContainer, classifyEmptyDigestImage } from './scheduler/tasks/update-utils';
|
|
import { deepDiff } from '../utils/diff.js';
|
|
import { getInstanceId } from './backups/identity';
|
|
import { isOwnedBackupHelper } from './backups/reap-core';
|
|
|
|
/**
|
|
* Custom error for when an environment is not found.
|
|
* API endpoints should catch this and return 404.
|
|
*/
|
|
export class EnvironmentNotFoundError extends Error {
|
|
public readonly envId: number;
|
|
|
|
constructor(envId: number) {
|
|
super(`Environment ${envId} not found`);
|
|
this.name = 'EnvironmentNotFoundError';
|
|
this.envId = envId;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Custom error for Docker connection failures with user-friendly messages.
|
|
* Wraps raw fetch errors to hide technical details from users.
|
|
*/
|
|
export class DockerConnectionError extends Error {
|
|
public readonly originalError: unknown;
|
|
|
|
constructor(message: string, originalError: unknown) {
|
|
super(message);
|
|
this.name = 'DockerConnectionError';
|
|
this.originalError = originalError;
|
|
}
|
|
|
|
/**
|
|
* Create a DockerConnectionError from any error, sanitizing technical messages
|
|
*/
|
|
static fromError(error: unknown, context?: string): DockerConnectionError {
|
|
const errorStr = String(error);
|
|
let friendlyMessage: string;
|
|
|
|
if (errorStr.includes('FailedToOpenSocket') || errorStr.includes('ECONNREFUSED')) {
|
|
friendlyMessage = 'Docker socket not accessible';
|
|
} else if (errorStr.includes('ECONNRESET') || errorStr.includes('connection was closed')) {
|
|
friendlyMessage = 'Connection lost';
|
|
} else if (errorStr.includes('verbose') || errorStr.includes('typo')) {
|
|
friendlyMessage = 'Connection failed';
|
|
} else if (errorStr.includes('timeout') || errorStr.includes('Timeout') || errorStr.includes('ETIMEDOUT')) {
|
|
friendlyMessage = 'Connection timeout';
|
|
} else if (errorStr.includes('ENOTFOUND') || errorStr.includes('getaddrinfo')) {
|
|
friendlyMessage = 'Host not found';
|
|
} else if (errorStr.includes('EHOSTUNREACH')) {
|
|
friendlyMessage = 'Host unreachable';
|
|
} else {
|
|
friendlyMessage = 'Connection error';
|
|
}
|
|
|
|
if (context) {
|
|
friendlyMessage = `${context}: ${friendlyMessage}`;
|
|
}
|
|
|
|
return new DockerConnectionError(friendlyMessage, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Container inspect result from Docker API
|
|
*/
|
|
export interface ContainerInspectResult {
|
|
Id: string;
|
|
Name: string;
|
|
RestartCount: number;
|
|
State: {
|
|
Status: string;
|
|
Running: boolean;
|
|
Paused: boolean;
|
|
Restarting: boolean;
|
|
OOMKilled: boolean;
|
|
Dead: boolean;
|
|
Pid: number;
|
|
ExitCode: number;
|
|
Error: string;
|
|
StartedAt: string;
|
|
FinishedAt: string;
|
|
Health?: {
|
|
Status: string;
|
|
FailingStreak: number;
|
|
Log: Array<{
|
|
Start: string;
|
|
End: string;
|
|
ExitCode: number;
|
|
Output: string;
|
|
}>;
|
|
};
|
|
};
|
|
Config: {
|
|
Hostname: string;
|
|
User: string;
|
|
Tty: boolean;
|
|
Env: string[];
|
|
Cmd: string[];
|
|
Image: string;
|
|
Labels: Record<string, string>;
|
|
WorkingDir: string;
|
|
Entrypoint: string[] | null;
|
|
};
|
|
NetworkSettings: {
|
|
Networks: Record<string, {
|
|
IPAddress: string;
|
|
Gateway: string;
|
|
MacAddress: string;
|
|
}>;
|
|
Ports: Record<string, Array<{ HostIp: string; HostPort: string }> | null>;
|
|
};
|
|
Mounts: Array<{
|
|
Type: string;
|
|
Source: string;
|
|
Destination: string;
|
|
Mode: string;
|
|
RW: boolean;
|
|
}>;
|
|
HostConfig: {
|
|
Binds: string[] | null;
|
|
NetworkMode: string;
|
|
PortBindings: Record<string, Array<{ HostIp: string; HostPort: string }>> | null;
|
|
RestartPolicy: {
|
|
Name: string;
|
|
MaximumRetryCount: number;
|
|
};
|
|
Privileged: boolean;
|
|
Memory: number;
|
|
MemorySwap: number;
|
|
NanoCpus: number;
|
|
CpuShares: number;
|
|
};
|
|
}
|
|
|
|
// Detect Docker socket path for local connections
|
|
function detectDockerSocket(): string {
|
|
// Check environment variable first
|
|
if (process.env.DOCKER_SOCKET && existsSync(process.env.DOCKER_SOCKET)) {
|
|
console.log(`Using Docker socket from DOCKER_SOCKET env: ${process.env.DOCKER_SOCKET}`);
|
|
return process.env.DOCKER_SOCKET;
|
|
}
|
|
|
|
// Check DOCKER_HOST environment variable
|
|
if (process.env.DOCKER_HOST) {
|
|
const dockerHost = process.env.DOCKER_HOST;
|
|
if (dockerHost.startsWith('unix://')) {
|
|
const socketPath = dockerHost.replace('unix://', '');
|
|
if (existsSync(socketPath)) {
|
|
console.log(`Using Docker socket from DOCKER_HOST: ${socketPath}`);
|
|
return socketPath;
|
|
}
|
|
}
|
|
}
|
|
|
|
// List of possible socket locations in order of preference
|
|
const possibleSockets = [
|
|
'/var/run/docker.sock', // Standard Linux/Docker Desktop
|
|
`${homedir()}/.docker/run/docker.sock`, // Docker Desktop for Mac (new location)
|
|
`${homedir()}/.orbstack/run/docker.sock`, // OrbStack
|
|
'/run/docker.sock', // Alternative Linux location
|
|
];
|
|
|
|
for (const socket of possibleSockets) {
|
|
if (existsSync(socket)) {
|
|
console.log(`Detected Docker socket at: ${socket}`);
|
|
return socket;
|
|
}
|
|
}
|
|
|
|
// Fallback to default
|
|
console.warn('No Docker socket found, using default /var/run/docker.sock');
|
|
return '/var/run/docker.sock';
|
|
}
|
|
|
|
const socketPath = detectDockerSocket();
|
|
|
|
/**
|
|
* Demultiplex Docker stream output (strip 8-byte headers)
|
|
* Docker streams have: 1 byte type, 3 bytes padding, 4 bytes size BE, then payload
|
|
*/
|
|
function demuxDockerStream(buffer: Buffer, options?: { separateStreams?: boolean }): string | { stdout: string; stderr: string } {
|
|
const stdout: string[] = [];
|
|
const stderr: string[] = [];
|
|
let offset = 0;
|
|
|
|
while (offset < buffer.length) {
|
|
if (offset + 8 > buffer.length) break;
|
|
|
|
const streamType = buffer.readUInt8(offset);
|
|
const frameSize = buffer.readUInt32BE(offset + 4);
|
|
|
|
if (frameSize === 0 || frameSize > buffer.length - offset - 8) {
|
|
// Invalid frame, return raw content with control chars stripped
|
|
const raw = buffer.toString('utf-8').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
|
|
return options?.separateStreams ? { stdout: raw, stderr: '' } : raw;
|
|
}
|
|
|
|
const payload = buffer.slice(offset + 8, offset + 8 + frameSize).toString('utf-8');
|
|
|
|
if (streamType === 1) {
|
|
stdout.push(payload);
|
|
} else if (streamType === 2) {
|
|
stderr.push(payload);
|
|
} else {
|
|
stdout.push(payload); // Default to stdout for unknown types
|
|
}
|
|
|
|
offset += 8 + frameSize;
|
|
}
|
|
|
|
if (options?.separateStreams) {
|
|
return { stdout: stdout.join(''), stderr: stderr.join('') };
|
|
}
|
|
return [...stdout, ...stderr].join('');
|
|
}
|
|
|
|
/**
|
|
* Process Docker stream frames incrementally from a buffer
|
|
* Returns processed frames and remaining buffer
|
|
*/
|
|
function processStreamFrames(
|
|
buffer: Buffer,
|
|
onStdout?: (data: string) => void,
|
|
onStderr?: (data: string) => void
|
|
): { stdout: string; remaining: Buffer<ArrayBufferLike> } {
|
|
// Collect stdout frame payloads as raw buffers first, then decode to UTF-8 once
|
|
// at the end. Decoding each frame individually corrupts multi-byte UTF-8 characters
|
|
// that may be split across frame boundaries (observed with Grype on Synology NAS).
|
|
const stdoutChunks: Buffer[] = [];
|
|
let stdoutLen = 0;
|
|
let offset = 0;
|
|
|
|
while (buffer.length >= offset + 8) {
|
|
const streamType = buffer.readUInt8(offset);
|
|
const frameSize = buffer.readUInt32BE(offset + 4);
|
|
|
|
// Validate stream type (0=stdin, 1=stdout, 2=stderr)
|
|
if (streamType > 2) break;
|
|
|
|
// Sanity check - no single frame should be > 10MB
|
|
if (frameSize > 10 * 1024 * 1024) break;
|
|
|
|
if (buffer.length < offset + 8 + frameSize) break;
|
|
|
|
const payloadBuf = buffer.slice(offset + 8, offset + 8 + frameSize);
|
|
|
|
if (streamType === 1) {
|
|
stdoutChunks.push(payloadBuf);
|
|
stdoutLen += payloadBuf.length;
|
|
onStdout?.(payloadBuf.toString('utf-8'));
|
|
} else if (streamType === 2) {
|
|
onStderr?.(payloadBuf.toString('utf-8'));
|
|
}
|
|
|
|
offset += 8 + frameSize;
|
|
}
|
|
|
|
const stdout = Buffer.concat(stdoutChunks, stdoutLen).toString('utf-8');
|
|
return { stdout, remaining: buffer.slice(offset) };
|
|
}
|
|
|
|
// Cache for environment configurations with timestamps
|
|
interface CachedEnv {
|
|
env: Environment;
|
|
lastUsed: number;
|
|
}
|
|
const envCache = new Map<number, CachedEnv>();
|
|
|
|
// Cache TTL: 30 minutes (in milliseconds)
|
|
const CACHE_TTL = 30 * 60 * 1000;
|
|
|
|
// All known Docker Hub hostname variations for credential matching
|
|
export const DOCKER_HUB_HOSTS = new Set([
|
|
'docker.io', 'hub.docker.com', 'registry.hub.docker.com',
|
|
'index.docker.io', 'registry-1.docker.io', 'registry.docker.io', 'docker.com'
|
|
]);
|
|
|
|
// Cleanup stale cache entries periodically
|
|
function cleanupEnvCache() {
|
|
const now = Date.now();
|
|
const entries = Array.from(envCache.entries());
|
|
for (const [envId, cached] of entries) {
|
|
if (now - cached.lastUsed > CACHE_TTL) {
|
|
envCache.delete(envId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Guard against multiple intervals during HMR
|
|
declare global {
|
|
var __dockerEnvCacheCleanupInterval: ReturnType<typeof setInterval> | undefined;
|
|
}
|
|
|
|
// Run cleanup every 10 minutes (guarded to prevent HMR leaks)
|
|
if (!globalThis.__dockerEnvCacheCleanupInterval) {
|
|
globalThis.__dockerEnvCacheCleanupInterval = setInterval(cleanupEnvCache, 10 * 60 * 1000);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Per-environment HTTPS Agent pool
|
|
// =============================================================================
|
|
// Used as a fallback when the Go TLS proxy is not available.
|
|
// Node's https.Agent with keepAlive reuses connections properly.
|
|
// =============================================================================
|
|
|
|
interface CachedAgent {
|
|
agent: https.Agent;
|
|
lastUsed: number;
|
|
}
|
|
|
|
const agentCache = new Map<string, CachedAgent>();
|
|
|
|
function getHttpsAgent(config: DockerClientConfig): https.Agent {
|
|
// Hash actual cert content so rotated certs get a new agent
|
|
const h = createHash('sha256');
|
|
h.update(`${config.host}:${config.port}:`);
|
|
if (config.ca) h.update(`ca:${config.ca}`);
|
|
if (config.cert) h.update(`cert:${config.cert}`);
|
|
if (config.key) h.update(`key:${config.key}`);
|
|
if (config.skipVerify) h.update('skip');
|
|
const key = h.digest('hex');
|
|
|
|
const cached = agentCache.get(key);
|
|
if (cached) {
|
|
cached.lastUsed = Date.now();
|
|
return cached.agent;
|
|
}
|
|
|
|
const agentOptions: https.AgentOptions = {
|
|
keepAlive: true,
|
|
timeout: 30000,
|
|
};
|
|
|
|
if (config.ca) {
|
|
// Include both the custom CA and Node.js built-in root certificates.
|
|
// Node.js replaces the entire CA store when `ca` is set, unlike Bun which appends.
|
|
// Without this, certs signed by intermediate CAs fail with "unable to get local issuer certificate".
|
|
agentOptions.ca = [config.ca, ...tls.rootCertificates];
|
|
}
|
|
if (config.cert) agentOptions.cert = config.cert;
|
|
if (config.key) agentOptions.key = config.key;
|
|
if (config.skipVerify) agentOptions.rejectUnauthorized = false;
|
|
|
|
const agent = new https.Agent(agentOptions);
|
|
agentCache.set(key, { agent, lastUsed: Date.now() });
|
|
return agent;
|
|
}
|
|
|
|
function cleanupAgentCache() {
|
|
const now = Date.now();
|
|
for (const [key, cached] of agentCache.entries()) {
|
|
if (now - cached.lastUsed > CACHE_TTL) {
|
|
cached.agent.destroy();
|
|
agentCache.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
var __dockerAgentCacheCleanupInterval: ReturnType<typeof setInterval> | undefined;
|
|
}
|
|
|
|
if (!globalThis.__dockerAgentCacheCleanupInterval) {
|
|
globalThis.__dockerAgentCacheCleanupInterval = setInterval(cleanupAgentCache, 10 * 60 * 1000);
|
|
}
|
|
|
|
/**
|
|
* Make an HTTPS request using Node.js https module with persistent Agent.
|
|
* Supports both buffered and streaming response modes.
|
|
*/
|
|
export function httpsAgentRequest(
|
|
config: DockerClientConfig,
|
|
path: string,
|
|
options: RequestInit = {},
|
|
streaming: boolean = false,
|
|
extraHeaders?: Record<string, string>
|
|
): Promise<Response> {
|
|
return new Promise((resolve, reject) => {
|
|
const method = (options.method || 'GET').toUpperCase();
|
|
const agent = getHttpsAgent(config);
|
|
|
|
const reqHeaders: Record<string, string> = { ...(extraHeaders || {}) };
|
|
if (options.headers) {
|
|
if (options.headers instanceof Headers) {
|
|
options.headers.forEach((value, key) => { reqHeaders[key] = value; });
|
|
} else if (typeof options.headers === 'object') {
|
|
Object.assign(reqHeaders, options.headers);
|
|
}
|
|
}
|
|
|
|
const reqOptions: https.RequestOptions = {
|
|
hostname: config.host,
|
|
port: config.port,
|
|
path,
|
|
method,
|
|
agent,
|
|
headers: reqHeaders,
|
|
};
|
|
|
|
// A stream request BODY (a large tar PUT) must not get the small-request idle timeout
|
|
// even though the RESPONSE isn't streamed - computeRequestTimeoutMs decides on both.
|
|
const streamingBody = typeof (options.body as ReadableStream | undefined)?.getReader === 'function';
|
|
const timeoutMs = computeRequestTimeoutMs({
|
|
path,
|
|
streamingBody,
|
|
streamingResponse: streaming,
|
|
composeTimeoutSecs: parseInt(process.env.COMPOSE_TIMEOUT || '900'),
|
|
});
|
|
if (timeoutMs !== null) reqOptions.timeout = timeoutMs;
|
|
|
|
// Honor AbortSignal from caller (e.g., AbortSignal.timeout(5000) for ping)
|
|
const signal = options.signal as AbortSignal | undefined;
|
|
if (signal?.aborted) {
|
|
reject(new Error('Request aborted'));
|
|
return;
|
|
}
|
|
|
|
const req = https.request(reqOptions, (res) => {
|
|
const headers = new Headers();
|
|
for (const [key, value] of Object.entries(res.headers)) {
|
|
if (value) {
|
|
if (Array.isArray(value)) {
|
|
value.forEach(v => headers.append(key, v));
|
|
} else {
|
|
headers.set(key, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
const status = res.statusCode || 200;
|
|
const statusText = res.statusMessage || '';
|
|
|
|
// Status codes that must not have a body
|
|
if ([101, 204, 205, 304].includes(status)) {
|
|
resolve(new Response(null, { status, statusText, headers }));
|
|
res.resume(); // drain
|
|
return;
|
|
}
|
|
|
|
if (streaming) {
|
|
const readable = new ReadableStream({
|
|
start(controller) {
|
|
res.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
|
|
res.on('end', () => controller.close());
|
|
res.on('error', (err) => controller.error(err));
|
|
},
|
|
cancel() { res.destroy(); }
|
|
});
|
|
resolve(new Response(readable, { status, statusText, headers }));
|
|
} else {
|
|
const chunks: Buffer[] = [];
|
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
res.on('end', () => {
|
|
resolve(new Response(Buffer.concat(chunks), { status, statusText, headers }));
|
|
});
|
|
res.on('error', reject);
|
|
}
|
|
});
|
|
|
|
req.on('error', reject);
|
|
req.on('timeout', () => { req.destroy(new Error('Request timeout')); });
|
|
|
|
if (signal) {
|
|
signal.addEventListener('abort', () => {
|
|
req.destroy(new Error('Request aborted'));
|
|
}, { once: true });
|
|
}
|
|
|
|
const body = options.body;
|
|
if (body) {
|
|
if (typeof body === 'string') {
|
|
req.end(body);
|
|
} else if (Buffer.isBuffer(body) || body instanceof Uint8Array) {
|
|
req.end(body);
|
|
} else if (body instanceof ArrayBuffer) {
|
|
req.end(Buffer.from(body));
|
|
} else if (body instanceof Blob) {
|
|
body.arrayBuffer().then(ab => req.end(Buffer.from(ab)), reject);
|
|
} else if (typeof (body as ReadableStream).getReader === 'function') {
|
|
// Stream the body with backpressure (O(1) RAM). Without awaiting 'drain' this
|
|
// piled the whole tar into RAM on a slow mTLS peer - see stream-pump.ts.
|
|
const reader = (body as ReadableStream<Uint8Array>).getReader();
|
|
pumpWebStreamToWritable(reader, req).catch(() => { /* req.destroy already called */ });
|
|
} else {
|
|
req.end();
|
|
}
|
|
} else {
|
|
req.end();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Import db functions for environment lookup
|
|
import { getEnvironment } from './db';
|
|
|
|
// Import hawser edge connection manager for edge mode routing
|
|
import { sendEdgeRequest, sendEdgeStreamRequest, isEdgeConnected, type EdgeResponse } from './hawser';
|
|
|
|
/**
|
|
* Docker API client configuration
|
|
*/
|
|
export interface DockerClientConfig {
|
|
type: 'socket' | 'http' | 'https';
|
|
socketPath?: string;
|
|
host?: string;
|
|
port?: number;
|
|
ca?: string;
|
|
cert?: string;
|
|
key?: string;
|
|
skipVerify?: boolean;
|
|
// Hawser connection settings
|
|
connectionType?: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
|
|
hawserToken?: string;
|
|
// Environment ID for edge mode routing
|
|
environmentId?: number;
|
|
}
|
|
|
|
/**
|
|
* Build Docker client config from an environment
|
|
*/
|
|
function buildConfigFromEnv(env: Environment): DockerClientConfig {
|
|
// Socket connection type - use Unix socket
|
|
if (env.connectionType === 'socket' || !env.connectionType) {
|
|
return {
|
|
type: 'socket',
|
|
socketPath: env.socketPath || '/var/run/docker.sock',
|
|
connectionType: 'socket',
|
|
environmentId: env.id
|
|
};
|
|
}
|
|
|
|
// Direct or Hawser connection types - use HTTP/HTTPS
|
|
const protocol = (env.protocol as 'http' | 'https') || 'http';
|
|
|
|
return {
|
|
type: protocol,
|
|
host: env.host || 'localhost',
|
|
port: env.port || 2375,
|
|
ca: env.tlsCa || undefined,
|
|
cert: env.tlsCert || undefined,
|
|
key: env.tlsKey || undefined,
|
|
skipVerify: env.tlsSkipVerify || undefined,
|
|
connectionType: env.connectionType as 'direct' | 'hawser-standard' | 'hawser-edge',
|
|
hawserToken: env.hawserToken || undefined,
|
|
environmentId: env.id
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get Docker client configuration for an environment
|
|
*/
|
|
export async function getDockerConfig(envId?: number | null): Promise<DockerClientConfig> {
|
|
if (!envId) {
|
|
throw new Error('No environment specified');
|
|
}
|
|
|
|
// Check cache first
|
|
const cached = envCache.get(envId);
|
|
if (cached) {
|
|
cached.lastUsed = Date.now();
|
|
return buildConfigFromEnv(cached.env);
|
|
}
|
|
|
|
// Fetch and cache
|
|
const env = await getEnvironment(envId);
|
|
if (env) {
|
|
envCache.set(envId, { env, lastUsed: Date.now() });
|
|
return buildConfigFromEnv(env);
|
|
}
|
|
|
|
throw new EnvironmentNotFoundError(envId);
|
|
}
|
|
|
|
interface DockerFetchOptions extends RequestInit {
|
|
/** Set to true for long-lived streaming connections */
|
|
streaming?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Check if a string is valid base64
|
|
*/
|
|
function isBase64(str: string): boolean {
|
|
if (!str || str.length === 0) return false;
|
|
// Base64 strings have length divisible by 4 and contain only valid chars
|
|
if (str.length % 4 !== 0) return false;
|
|
return /^[A-Za-z0-9+/]*={0,2}$/.test(str);
|
|
}
|
|
|
|
/**
|
|
* Convert EdgeResponse from hawser WebSocket to a standard Response object
|
|
* Handles base64-encoded binary data from Go agent
|
|
*/
|
|
function edgeResponseToResponse(edgeResponse: EdgeResponse): Response {
|
|
let body: string | Uint8Array = edgeResponse.body;
|
|
|
|
// The Go agent sends isBinary flag to indicate if body is base64-encoded
|
|
if (edgeResponse.isBinary && typeof body === 'string' && body.length > 0) {
|
|
// Decode base64 to binary
|
|
body = Uint8Array.from(atob(body), c => c.charCodeAt(0));
|
|
}
|
|
|
|
return new Response(body as BodyInit, {
|
|
status: edgeResponse.statusCode,
|
|
headers: edgeResponse.headers
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Drain a response body to release the underlying socket/TLS connection.
|
|
* Must be called on any Response whose body won't otherwise be consumed.
|
|
*/
|
|
export async function drainResponse(response: Response): Promise<void> {
|
|
if (!response.bodyUsed) {
|
|
try { await response.arrayBuffer(); } catch {}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drain a Docker API response, throwing if the status is not OK.
|
|
* Extracts the error message from the JSON body if available.
|
|
* Accepts optional extra status codes to treat as success (e.g. 304 Not Modified).
|
|
*/
|
|
async function throwDockerError(response: Response): Promise<never> {
|
|
const body = await response.text().catch(() => '');
|
|
let msg = `Docker API error: HTTP ${response.status}`;
|
|
if (body) {
|
|
try { msg = JSON.parse(body).message ?? body; } catch { msg = body; }
|
|
}
|
|
throw new Error(msg);
|
|
}
|
|
|
|
async function assertDockerResponse(response: Response, ...acceptStatuses: number[]): Promise<void> {
|
|
if (response.ok || acceptStatuses.includes(response.status)) {
|
|
await drainResponse(response);
|
|
return;
|
|
}
|
|
await throwDockerError(response);
|
|
}
|
|
|
|
/**
|
|
* Make a request to the Docker API
|
|
* Exported for use by stacks.ts module
|
|
*/
|
|
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
|
|
|
|
/**
|
|
* Build a Web API Response, handling null-body status codes.
|
|
*/
|
|
function buildResponse(body: Buffer | ReadableStream, status: number, statusText: string, headers: Headers): Response {
|
|
if (NULL_BODY_STATUSES.has(status)) {
|
|
return new Response(null, { status, statusText, headers });
|
|
}
|
|
return new Response(body, { status, statusText, headers });
|
|
}
|
|
|
|
/**
|
|
* Make an HTTP request over a Unix socket and return a Web API Response.
|
|
* Make an HTTP request over a Unix socket, returning a buffered Web API Response.
|
|
*/
|
|
export function unixSocketRequest(
|
|
socketPath: string,
|
|
path: string,
|
|
options: RequestInit = {}
|
|
): Promise<Response> {
|
|
return new Promise((resolve, reject) => {
|
|
const method = (options.method || 'GET').toUpperCase();
|
|
|
|
const reqOptions: http.RequestOptions = {
|
|
socketPath,
|
|
path,
|
|
method,
|
|
headers: {},
|
|
};
|
|
|
|
if (options.headers) {
|
|
if (options.headers instanceof Headers) {
|
|
options.headers.forEach((value, key) => {
|
|
(reqOptions.headers as Record<string, string>)[key] = value;
|
|
});
|
|
} else if (typeof options.headers === 'object') {
|
|
Object.assign(reqOptions.headers!, options.headers);
|
|
}
|
|
}
|
|
|
|
const req = http.request(reqOptions, (res) => {
|
|
const chunks: Buffer[] = [];
|
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
res.on('end', () => {
|
|
const body = Buffer.concat(chunks);
|
|
const headers = new Headers();
|
|
for (const [key, value] of Object.entries(res.headers)) {
|
|
if (value) {
|
|
if (Array.isArray(value)) {
|
|
value.forEach(v => headers.append(key, v));
|
|
} else {
|
|
headers.set(key, value);
|
|
}
|
|
}
|
|
}
|
|
resolve(buildResponse(body, res.statusCode || 200, res.statusMessage || '', headers));
|
|
});
|
|
res.on('error', reject);
|
|
});
|
|
|
|
req.on('error', reject);
|
|
|
|
if (options.body instanceof ReadableStream) {
|
|
// Stream a web ReadableStream (e.g. a large tar for /images/load) straight
|
|
// to the socket without buffering it in memory. Readable.fromWeb bridges
|
|
// web -> Node stream; pipe() ends the request when the source finishes.
|
|
Readable.fromWeb(options.body as any).on('error', reject).pipe(req);
|
|
} else {
|
|
if (options.body) {
|
|
if (typeof options.body === 'string') {
|
|
req.write(options.body);
|
|
} else if (options.body instanceof Uint8Array || Buffer.isBuffer(options.body)) {
|
|
req.write(options.body);
|
|
}
|
|
}
|
|
req.end();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Make an HTTP request over a Unix socket and return a streaming Web API Response.
|
|
* Used for long-lived connections like Docker events, logs, stats streaming.
|
|
*/
|
|
export function unixSocketStreamRequest(
|
|
socketPath: string,
|
|
path: string,
|
|
options: RequestInit = {}
|
|
): Promise<Response> {
|
|
return new Promise((resolve, reject) => {
|
|
const method = (options.method || 'GET').toUpperCase();
|
|
|
|
const reqOptions: http.RequestOptions = {
|
|
socketPath,
|
|
path,
|
|
method,
|
|
headers: {},
|
|
};
|
|
|
|
if (options.headers) {
|
|
if (options.headers instanceof Headers) {
|
|
options.headers.forEach((value, key) => {
|
|
(reqOptions.headers as Record<string, string>)[key] = value;
|
|
});
|
|
} else if (typeof options.headers === 'object') {
|
|
Object.assign(reqOptions.headers!, options.headers);
|
|
}
|
|
}
|
|
|
|
const req = http.request(reqOptions, (res) => {
|
|
const headers = new Headers();
|
|
for (const [key, value] of Object.entries(res.headers)) {
|
|
if (value) {
|
|
if (Array.isArray(value)) {
|
|
value.forEach(v => headers.append(key, v));
|
|
} else {
|
|
headers.set(key, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
const readable = new ReadableStream({
|
|
start(controller) {
|
|
res.on('data', (chunk: Buffer) => {
|
|
controller.enqueue(new Uint8Array(chunk));
|
|
});
|
|
res.on('end', () => {
|
|
controller.close();
|
|
});
|
|
res.on('error', (err) => {
|
|
controller.error(err);
|
|
});
|
|
},
|
|
cancel() {
|
|
res.destroy();
|
|
}
|
|
});
|
|
|
|
resolve(new Response(readable, {
|
|
status: res.statusCode || 200,
|
|
statusText: res.statusMessage || '',
|
|
headers,
|
|
}));
|
|
});
|
|
|
|
req.on('error', reject);
|
|
|
|
if (options.body instanceof ReadableStream) {
|
|
// Stream a web ReadableStream (e.g. a large tar for /images/load) straight
|
|
// to the socket without buffering it in memory. Readable.fromWeb bridges
|
|
// web -> Node stream; pipe() ends the request when the source finishes.
|
|
Readable.fromWeb(options.body as any).on('error', reject).pipe(req);
|
|
} else {
|
|
if (options.body) {
|
|
if (typeof options.body === 'string') {
|
|
req.write(options.body);
|
|
} else if (options.body instanceof Uint8Array || Buffer.isBuffer(options.body)) {
|
|
req.write(options.body);
|
|
}
|
|
}
|
|
req.end();
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function dockerFetch(
|
|
path: string,
|
|
options: DockerFetchOptions = {},
|
|
envId?: number | null
|
|
): Promise<Response> {
|
|
// Guard against path traversal — legitimate Docker API paths never contain '..'
|
|
if (path.includes('..')) {
|
|
throw new Error('Invalid Docker API path');
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
const config = await getDockerConfig(envId);
|
|
const { streaming, ...fetchOptions } = options;
|
|
const method = (options.method || 'GET').toUpperCase();
|
|
|
|
// Hawser Edge mode - route through WebSocket connection
|
|
if (config.connectionType === 'hawser-edge' && config.environmentId) {
|
|
// Check if agent is connected
|
|
if (!isEdgeConnected(config.environmentId)) {
|
|
const error = new Error('Hawser Edge agent is not connected');
|
|
// Log without stack trace for cleaner output
|
|
console.warn(`[Docker] Edge env ${config.environmentId}: agent not connected for ${method} ${path}`);
|
|
throw error;
|
|
}
|
|
|
|
// Extract request details
|
|
const headers: Record<string, string> = {};
|
|
|
|
// Convert Headers object to plain object
|
|
if (fetchOptions.headers) {
|
|
if (fetchOptions.headers instanceof Headers) {
|
|
fetchOptions.headers.forEach((value, key) => {
|
|
headers[key] = value;
|
|
});
|
|
} else if (typeof fetchOptions.headers === 'object') {
|
|
Object.assign(headers, fetchOptions.headers);
|
|
}
|
|
}
|
|
|
|
// Parse body if present
|
|
let body: unknown;
|
|
let isBinary = false;
|
|
if (fetchOptions.body) {
|
|
if (typeof fetchOptions.body === 'string') {
|
|
try {
|
|
body = JSON.parse(fetchOptions.body);
|
|
} catch {
|
|
body = fetchOptions.body;
|
|
}
|
|
} else if (fetchOptions.body instanceof ArrayBuffer || fetchOptions.body instanceof Uint8Array) {
|
|
// Binary body (tar uploads etc.) — base64 encode for JSON transport
|
|
const bytes = fetchOptions.body instanceof ArrayBuffer
|
|
? new Uint8Array(fetchOptions.body)
|
|
: fetchOptions.body;
|
|
body = Buffer.from(bytes).toString('base64');
|
|
isBinary = true;
|
|
} else {
|
|
body = fetchOptions.body;
|
|
}
|
|
}
|
|
|
|
// Send request through edge connection
|
|
try {
|
|
const edgeResponse = await sendEdgeRequest(
|
|
config.environmentId,
|
|
method,
|
|
path,
|
|
body,
|
|
headers,
|
|
streaming || false,
|
|
(streaming || path === '/_hawser/compose' || path.endsWith('/prune')) ? 300000 : 30000, // 5 min for streaming/compose/prune, 30s for normal
|
|
isBinary,
|
|
fetchOptions.signal ?? undefined
|
|
);
|
|
const elapsed = Date.now() - startTime;
|
|
// Only warn for slow requests, but skip /stats which is expected to be slow (5-10s)
|
|
if (elapsed > 5000 && !path.includes('/stats')) {
|
|
console.warn(`[Docker] Edge env ${config.environmentId}: ${method} ${path} took ${elapsed}ms`);
|
|
}
|
|
return edgeResponseToResponse(edgeResponse);
|
|
} catch (error: any) {
|
|
const elapsed = Date.now() - startTime;
|
|
// Log error message only, not full stack trace
|
|
const msg = error?.message || String(error);
|
|
console.error(`[Docker] Edge env ${config.environmentId}: ${method} ${path} failed after ${elapsed}ms: ${msg}`);
|
|
throw DockerConnectionError.fromError(error);
|
|
}
|
|
}
|
|
|
|
if (config.type === 'socket') {
|
|
// Unix socket via http.request({ socketPath })
|
|
try {
|
|
const requestFn = streaming ? unixSocketStreamRequest : unixSocketRequest;
|
|
const response = await requestFn(config.socketPath!, path, fetchOptions);
|
|
const elapsed = Date.now() - startTime;
|
|
// Only warn for slow requests, but skip /stats which is expected to be slow (5-10s)
|
|
if (elapsed > 5000 && !path.includes('/stats')) {
|
|
console.warn(`[Docker] Socket: ${method} ${path} took ${elapsed}ms`);
|
|
}
|
|
return response;
|
|
} catch (error: any) {
|
|
const elapsed = Date.now() - startTime;
|
|
// Log error message only, not full stack trace
|
|
const msg = error?.message || String(error);
|
|
console.error(`[Docker] Socket: ${method} ${path} failed after ${elapsed}ms: ${msg}`);
|
|
throw DockerConnectionError.fromError(error);
|
|
}
|
|
} else {
|
|
// HTTP/HTTPS remote connection
|
|
const protocol = config.type;
|
|
const url = `${protocol}://${config.host}:${config.port}${path}`;
|
|
|
|
const finalOptions: RequestInit = { ...fetchOptions };
|
|
|
|
// For Hawser Standard mode with token authentication
|
|
const extraHeaders: Record<string, string> = {};
|
|
if (config.connectionType === 'hawser-standard' && config.hawserToken) {
|
|
extraHeaders['X-Hawser-Token'] = config.hawserToken;
|
|
finalOptions.headers = {
|
|
...finalOptions.headers,
|
|
'X-Hawser-Token': config.hawserToken
|
|
};
|
|
}
|
|
|
|
// For HTTPS: use node:https with persistent Agent (fallback when Go proxy is down).
|
|
// For plain HTTP: use standard fetch().
|
|
if (config.type === 'https') {
|
|
try {
|
|
const response = await httpsAgentRequest(config, path, finalOptions, streaming || false, extraHeaders);
|
|
const elapsed = Date.now() - startTime;
|
|
if (elapsed > 5000 && !path.includes('/stats')) {
|
|
console.warn(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} took ${elapsed}ms`);
|
|
}
|
|
return response;
|
|
} catch (error: any) {
|
|
const elapsed = Date.now() - startTime;
|
|
const msg = error?.message || String(error);
|
|
console.error(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} failed after ${elapsed}ms: ${msg}`);
|
|
throw DockerConnectionError.fromError(error);
|
|
}
|
|
}
|
|
|
|
// Plain HTTP — use standard fetch()
|
|
if (!streaming && !finalOptions.signal) {
|
|
const isComposeOperation = path === '/_hawser/compose';
|
|
const composeTimeoutMs = parseInt(process.env.COMPOSE_TIMEOUT || '900') * 1000;
|
|
const isPrune = path.endsWith('/prune');
|
|
finalOptions.signal = AbortSignal.timeout(isComposeOperation ? composeTimeoutMs : isPrune ? 300000 : 30000);
|
|
}
|
|
|
|
// A ReadableStream request body (e.g. a large tar for /images/load) requires
|
|
// duplex:'half' under Node's fetch, else it throws before sending.
|
|
if (typeof (finalOptions.body as ReadableStream | undefined)?.getReader === 'function') {
|
|
(finalOptions as RequestInit & { duplex?: string }).duplex = 'half';
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(url, finalOptions);
|
|
const elapsed = Date.now() - startTime;
|
|
if (elapsed > 5000 && !path.includes('/stats')) {
|
|
console.warn(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} took ${elapsed}ms`);
|
|
}
|
|
return response;
|
|
} catch (error: any) {
|
|
const elapsed = Date.now() - startTime;
|
|
const msg = error?.message || String(error);
|
|
console.error(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} failed after ${elapsed}ms: ${msg}`);
|
|
throw DockerConnectionError.fromError(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Make a JSON request to Docker API
|
|
*/
|
|
export async function dockerJsonRequest<T>(
|
|
path: string,
|
|
options: RequestInit = {},
|
|
envId?: number | null
|
|
): Promise<T> {
|
|
const response = await dockerFetch(path, {
|
|
...options,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...options.headers
|
|
}
|
|
}, envId);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
let errorJson: any = {};
|
|
try {
|
|
errorJson = JSON.parse(errorText);
|
|
} catch {
|
|
// Not JSON, use text as message
|
|
errorJson = { message: errorText };
|
|
}
|
|
const error: any = new Error(errorJson.message || `Docker API error: ${response.status}`);
|
|
error.statusCode = response.status;
|
|
error.json = errorJson;
|
|
throw error;
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
// Clear cached client for an environment (e.g., when settings change)
|
|
export function clearDockerClientCache(envId?: number) {
|
|
if (envId !== undefined) {
|
|
envCache.delete(envId);
|
|
} else {
|
|
envCache.clear();
|
|
}
|
|
// Destroy HTTPS agents (TLS config may have changed)
|
|
for (const [key, cached] of agentCache.entries()) {
|
|
cached.agent.destroy();
|
|
agentCache.delete(key);
|
|
}
|
|
}
|
|
|
|
export interface ContainerInfo {
|
|
id: string;
|
|
name: string;
|
|
image: string;
|
|
/** sha256 image ID (container.ImageID) — matches vulnerability scan.imageId. */
|
|
imageId: string;
|
|
state: string;
|
|
status: string;
|
|
created: number;
|
|
ports: Array<{
|
|
IP?: string;
|
|
PrivatePort: number;
|
|
PublicPort?: number;
|
|
Type: string;
|
|
}>;
|
|
networks: { [networkName: string]: { ipAddress: string } };
|
|
health?: string;
|
|
restartCount: number;
|
|
exitCode?: number;
|
|
mounts: Array<{ type: string; name: string; source: string; destination: string; mode: string; rw: boolean }>;
|
|
labels: { [key: string]: string };
|
|
command: string;
|
|
}
|
|
|
|
export interface ImageInfo {
|
|
id: string;
|
|
tags: string[];
|
|
repoDigests?: string[];
|
|
size: number;
|
|
created: number;
|
|
}
|
|
|
|
// Container operations
|
|
export async function listContainers(all = true, envId?: number | null): Promise<ContainerInfo[]> {
|
|
const containers = await dockerJsonRequest<any[]>(
|
|
`/containers/json?all=${all}`,
|
|
{},
|
|
envId
|
|
);
|
|
|
|
// Fetch restart counts only for restarting containers
|
|
const restartCounts = new Map<string, number>();
|
|
const restartingContainers = containers.filter(c => c.State === 'restarting');
|
|
|
|
await Promise.all(
|
|
restartingContainers.map(async (container) => {
|
|
try {
|
|
const inspect = await inspectContainer(container.Id, envId);
|
|
restartCounts.set(container.Id, inspect.RestartCount || 0);
|
|
} catch {
|
|
// Ignore errors
|
|
}
|
|
})
|
|
);
|
|
|
|
// Fetch health status via inspect for containers with healthchecks where Status
|
|
// string doesn't include health info (Podman compat API omits it from the list endpoint).
|
|
// Skip entirely if any container already has health in Status (Docker includes it natively).
|
|
const healthStatuses = new Map<string, string>();
|
|
const healthRegex = /\((healthy|unhealthy|health:\s*starting)\)/i;
|
|
const anyHasHealthInStatus = containers.some(c => healthRegex.test(c.Status || ''));
|
|
if (!anyHasHealthInStatus) {
|
|
// Single API call to find which containers have healthchecks configured
|
|
try {
|
|
const healthFiltered = await dockerJsonRequest<any[]>(
|
|
'/containers/json?filters=' + encodeURIComponent(JSON.stringify({ health: ['healthy', 'unhealthy', 'starting'] })),
|
|
{}, envId
|
|
);
|
|
const healthIds = new Set((healthFiltered || []).map((c: any) => c.Id));
|
|
// Only inspect those specific containers (typically very few)
|
|
if (healthIds.size > 0) {
|
|
await Promise.all(
|
|
[...healthIds].map(async (id) => {
|
|
try {
|
|
const inspect = await inspectContainer(id, envId);
|
|
const h = inspect.State?.Health?.Status;
|
|
if (h) healthStatuses.set(id, h);
|
|
} catch {
|
|
// Ignore errors
|
|
}
|
|
})
|
|
);
|
|
}
|
|
} catch {
|
|
// Ignore - health filter may not be supported
|
|
}
|
|
}
|
|
|
|
return containers.map((container) => {
|
|
// Extract network info with IP addresses
|
|
const networks: { [networkName: string]: { ipAddress: string } } = {};
|
|
if (container.NetworkSettings?.Networks) {
|
|
for (const [networkName, networkData] of Object.entries(container.NetworkSettings.Networks)) {
|
|
networks[networkName] = {
|
|
ipAddress: (networkData as any).IPAddress || ''
|
|
};
|
|
}
|
|
}
|
|
|
|
// Extract mount info. Carry the volume Name separately from Source: for a
|
|
// named volume Docker sets Name (e.g. "stack_data") AND Source (the host
|
|
// path /var/lib/docker/volumes/stack_data/_data). Keeping Name lets the
|
|
// backup picker show the clean volume name instead of the host path.
|
|
const mounts = (container.Mounts || []).map((m: any) => ({
|
|
type: m.Type || 'unknown',
|
|
name: m.Name || '',
|
|
source: m.Source || m.Name || '',
|
|
destination: m.Destination || '',
|
|
mode: m.Mode || '',
|
|
rw: m.RW ?? true
|
|
}));
|
|
|
|
// Extract health status from Status string
|
|
// Docker formats: "(healthy)", "(unhealthy)", "(health: starting)"
|
|
// Podman compat API omits health from Status string, so fall back to inspect data
|
|
let health: string | undefined;
|
|
const healthMatch = container.Status?.match(/\((healthy|unhealthy|health:\s*starting)\)/i);
|
|
if (healthMatch) {
|
|
const matched = healthMatch[1].toLowerCase();
|
|
// Normalize "health: starting" to just "starting"
|
|
health = matched.includes('starting') ? 'starting' : matched;
|
|
} else {
|
|
const inspectHealth = healthStatuses.get(container.Id);
|
|
if (inspectHealth) {
|
|
health = inspectHealth === 'health: starting' ? 'starting' : inspectHealth;
|
|
}
|
|
}
|
|
|
|
// Parse exit code from Status string (e.g., "Exited (0) 5 minutes ago")
|
|
const exitCode = container.State === 'exited'
|
|
? parseInt(container.Status?.match(/\((\d+)\)/)?.[1] ?? '-1')
|
|
: undefined;
|
|
|
|
return {
|
|
id: container.Id,
|
|
name: container.Names[0]?.replace(/^\//, '') || 'unnamed',
|
|
image: container.Image,
|
|
imageId: container.ImageID || '',
|
|
state: container.State,
|
|
status: container.Status,
|
|
created: container.Created,
|
|
ports: container.Ports || [],
|
|
networks,
|
|
health,
|
|
restartCount: restartCounts.get(container.Id) || 0,
|
|
exitCode,
|
|
mounts,
|
|
labels: container.Labels || {},
|
|
command: container.Command || '',
|
|
systemContainer: isSystemContainer(container.Image || '')
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function getContainerStats(id: string, envId?: number | null) {
|
|
return dockerJsonRequest(`/containers/${id}/stats?stream=false`, {}, envId);
|
|
}
|
|
|
|
export async function startContainer(id: string, envId?: number | null) {
|
|
const response = await dockerFetch(`/containers/${id}/start`, { method: 'POST' }, envId);
|
|
await assertDockerResponse(response, 304); // 304 = already started
|
|
}
|
|
|
|
export async function stopContainer(id: string, envId?: number | null) {
|
|
const response = await dockerFetch(`/containers/${id}/stop`, { method: 'POST' }, envId);
|
|
await assertDockerResponse(response, 304); // 304 = already stopped
|
|
}
|
|
|
|
export async function restartContainer(id: string, envId?: number | null) {
|
|
const response = await dockerFetch(`/containers/${id}/restart`, { method: 'POST' }, envId);
|
|
await assertDockerResponse(response);
|
|
}
|
|
|
|
export async function pauseContainer(id: string, envId?: number | null) {
|
|
const response = await dockerFetch(`/containers/${id}/pause`, { method: 'POST' }, envId);
|
|
await assertDockerResponse(response);
|
|
}
|
|
|
|
export async function unpauseContainer(id: string, envId?: number | null) {
|
|
const response = await dockerFetch(`/containers/${id}/unpause`, { method: 'POST' }, envId);
|
|
await assertDockerResponse(response);
|
|
}
|
|
|
|
export async function removeContainer(id: string, force = false, envId?: number | null) {
|
|
const response = await dockerFetch(`/containers/${id}?force=${force}`, { method: 'DELETE' }, envId);
|
|
await assertDockerResponse(response, 404); // 404 = already gone
|
|
}
|
|
|
|
export async function renameContainer(id: string, newName: string, envId?: number | null) {
|
|
const response = await dockerFetch(`/containers/${id}/rename?name=${encodeURIComponent(newName)}`, { method: 'POST' }, envId);
|
|
await assertDockerResponse(response);
|
|
}
|
|
|
|
/**
|
|
* In-place container property update. Wraps Docker's POST /containers/{id}/update,
|
|
* which is the only API that changes container properties WITHOUT recreating it.
|
|
*
|
|
* The accepted body field set is fixed by Docker — anything outside this set
|
|
* requires container recreation (image, env, ports, networks, mounts, etc).
|
|
* Whitelist enforced here so callers can't accidentally smuggle a
|
|
* recreate-only field through this code path.
|
|
*/
|
|
export const IN_PLACE_UPDATE_FIELDS = [
|
|
// Restart policy — the headline use case (#1153)
|
|
'RestartPolicy',
|
|
// CPU
|
|
'CpuShares', 'CpuPeriod', 'CpuQuota', 'CpuRealtimePeriod', 'CpuRealtimeRuntime',
|
|
'CpusetCpus', 'CpusetMems', 'NanoCpus',
|
|
// Memory
|
|
'Memory', 'MemorySwap', 'MemoryReservation', 'MemorySwappiness', 'KernelMemory',
|
|
// Block I/O
|
|
'BlkioWeight', 'BlkioWeightDevice',
|
|
'BlkioDeviceReadBps', 'BlkioDeviceWriteBps',
|
|
'BlkioDeviceReadIOps', 'BlkioDeviceWriteIOps',
|
|
// Misc
|
|
'PidsLimit'
|
|
] as const;
|
|
export type InPlaceUpdateField = typeof IN_PLACE_UPDATE_FIELDS[number];
|
|
|
|
export interface UpdateContainerRuntimeResult {
|
|
Warnings: string[] | null;
|
|
}
|
|
|
|
/**
|
|
* Apply an in-place update. `updates` keys MUST be from IN_PLACE_UPDATE_FIELDS;
|
|
* unknown keys are silently dropped (not passed to Docker) so a malicious or
|
|
* confused caller can't sneak a recreate-only field through.
|
|
*/
|
|
export async function updateContainerRuntime(
|
|
id: string,
|
|
updates: Partial<Record<InPlaceUpdateField, unknown>>,
|
|
envId?: number | null
|
|
): Promise<UpdateContainerRuntimeResult> {
|
|
const allowed = new Set<string>(IN_PLACE_UPDATE_FIELDS);
|
|
const body: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(updates)) {
|
|
if (allowed.has(key) && value !== undefined) body[key] = value;
|
|
}
|
|
if (Object.keys(body).length === 0) {
|
|
throw new Error('No updatable fields provided');
|
|
}
|
|
return dockerJsonRequest<UpdateContainerRuntimeResult>(
|
|
`/containers/${id}/update`,
|
|
{ method: 'POST', body: JSON.stringify(body) },
|
|
envId
|
|
);
|
|
}
|
|
|
|
export async function getContainerLogs(id: string, tail: number | 'all' = 100, envId?: number | null, since?: string, until?: string): Promise<string> {
|
|
// Check if container has TTY enabled
|
|
const info = await inspectContainer(id, envId);
|
|
const hasTty = info.Config?.Tty ?? false;
|
|
|
|
let query = `stdout=true&stderr=true×tamps=true`;
|
|
if (tail === 'all') {
|
|
query += `&tail=all`;
|
|
} else {
|
|
query += `&tail=${tail}`;
|
|
}
|
|
if (since) {
|
|
query += `&since=${since}`;
|
|
}
|
|
if (until) {
|
|
query += `&until=${until}`;
|
|
}
|
|
|
|
const response = await dockerFetch(
|
|
`/containers/${id}/logs?${query}`,
|
|
{},
|
|
envId
|
|
);
|
|
|
|
if (!response.ok) await throwDockerError(response);
|
|
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
|
|
// If TTY is enabled, logs are raw text (no demux needed)
|
|
if (hasTty) {
|
|
return buffer.toString('utf-8');
|
|
}
|
|
|
|
return demuxDockerStream(buffer) as string;
|
|
}
|
|
|
|
export async function inspectContainer(id: string, envId?: number | null): Promise<ContainerInspectResult> {
|
|
return dockerJsonRequest<ContainerInspectResult>(`/containers/${id}/json`, {}, envId);
|
|
}
|
|
|
|
export interface HealthcheckConfig {
|
|
test?: string[];
|
|
interval?: number;
|
|
timeout?: number;
|
|
retries?: number;
|
|
startPeriod?: number;
|
|
}
|
|
|
|
export interface UlimitConfig {
|
|
name: string;
|
|
soft: number;
|
|
hard: number;
|
|
}
|
|
|
|
export interface DeviceMapping {
|
|
hostPath: string;
|
|
containerPath: string;
|
|
permissions?: string;
|
|
}
|
|
|
|
/** GPU/device request for containers (e.g., Nvidia GPU) */
|
|
export interface DeviceRequest {
|
|
driver?: string;
|
|
count?: number;
|
|
deviceIDs?: string[];
|
|
capabilities?: string[][];
|
|
options?: { [key: string]: string };
|
|
}
|
|
|
|
export interface CreateContainerOptions {
|
|
name: string;
|
|
image: string;
|
|
ports?: { [key: string]: { HostIp?: string; HostPort: string } } | null;
|
|
volumes?: { [key: string]: {} };
|
|
volumeBinds?: string[] | null;
|
|
env?: string[];
|
|
labels?: { [key: string]: string };
|
|
cmd?: string[];
|
|
entrypoint?: string[];
|
|
workingDir?: string;
|
|
restartPolicy?: string;
|
|
restartMaxRetries?: number;
|
|
networkMode?: string;
|
|
/** Additional networks attached after creation via POST /networks/<name>/connect.
|
|
* Must NOT include the primary (which lives in networkMode + EndpointsConfig). */
|
|
additionalNetworks?: string[];
|
|
/** @deprecated use networkMode + additionalNetworks. Kept only so old callers
|
|
* (auto-update inspect path) keep working — see backward-compat block below. */
|
|
networks?: string[];
|
|
/** Network aliases for the primary network */
|
|
networkAliases?: string[];
|
|
/** Static IPv4 address for the primary network */
|
|
networkIpv4Address?: string;
|
|
/** Static IPv6 address for the primary network */
|
|
networkIpv6Address?: string;
|
|
/** Gateway priority for the primary network (Docker Engine 28+) */
|
|
networkGwPriority?: number;
|
|
/** Per-network endpoint configuration (IPv4, IPv6, aliases) */
|
|
networkConfigs?: Record<string, {
|
|
ipv4Address?: string;
|
|
ipv6Address?: string;
|
|
aliases?: string[];
|
|
}>;
|
|
user?: string | null;
|
|
privileged?: boolean;
|
|
healthcheck?: HealthcheckConfig | null;
|
|
memory?: number;
|
|
memoryReservation?: number;
|
|
memorySwap?: number;
|
|
cpuShares?: number;
|
|
cpuQuota?: number;
|
|
cpuPeriod?: number;
|
|
nanoCpus?: number;
|
|
capAdd?: string[];
|
|
capDrop?: string[];
|
|
devices?: DeviceMapping[];
|
|
dns?: string[];
|
|
dnsSearch?: string[];
|
|
dnsOptions?: string[];
|
|
securityOpt?: string[];
|
|
ulimits?: UlimitConfig[];
|
|
// Terminal settings
|
|
tty?: boolean;
|
|
stdinOpen?: boolean;
|
|
// Process and memory settings
|
|
oomKillDisable?: boolean;
|
|
pidsLimit?: number;
|
|
shmSize?: number;
|
|
// Tmpfs mounts
|
|
tmpfs?: { [key: string]: string };
|
|
// Sysctls
|
|
sysctls?: { [key: string]: string };
|
|
// Logging configuration
|
|
logDriver?: string;
|
|
logOptions?: { [key: string]: string };
|
|
// Namespace settings
|
|
ipcMode?: string;
|
|
pidMode?: string;
|
|
utsMode?: string;
|
|
// Hostname
|
|
hostname?: string;
|
|
// Cgroup parent
|
|
cgroupParent?: string;
|
|
// Stop signal
|
|
stopSignal?: string;
|
|
// Init process
|
|
init?: boolean;
|
|
// Stop timeout
|
|
stopTimeout?: number;
|
|
// MAC address
|
|
macAddress?: string;
|
|
// Extra hosts (/etc/hosts entries)
|
|
extraHosts?: string[];
|
|
// Device requests (GPU access, etc.)
|
|
deviceRequests?: DeviceRequest[];
|
|
// Container runtime (e.g., 'runc', 'nvidia' for GPU containers)
|
|
runtime?: string | null;
|
|
// Read-only root filesystem
|
|
readonlyRootfs?: boolean;
|
|
// CPU pinning (e.g., "0-3", "0,1")
|
|
cpusetCpus?: string;
|
|
// NUMA memory nodes (e.g., "0-1", "0")
|
|
cpusetMems?: string;
|
|
// Additional groups for the container process
|
|
groupAdd?: string[];
|
|
// Memory swappiness (0-100)
|
|
memorySwappiness?: number;
|
|
// User namespace mode
|
|
usernsMode?: string;
|
|
// Domain name
|
|
domainname?: string;
|
|
}
|
|
|
|
export async function createContainer(options: CreateContainerOptions, envId?: number | null) {
|
|
const containerConfig: any = {
|
|
Image: options.image,
|
|
Env: options.env || [],
|
|
Labels: options.labels || {},
|
|
HostConfig: {
|
|
RestartPolicy: {
|
|
Name: options.restartPolicy || 'no',
|
|
...(options.restartPolicy === 'on-failure' && options.restartMaxRetries !== undefined
|
|
? { MaximumRetryCount: options.restartMaxRetries }
|
|
: {})
|
|
}
|
|
}
|
|
};
|
|
|
|
if (options.cmd && options.cmd.length > 0) {
|
|
containerConfig.Cmd = options.cmd;
|
|
}
|
|
|
|
if (options.user !== undefined) {
|
|
containerConfig.User = options.user ?? '';
|
|
}
|
|
|
|
if (options.healthcheck === null) {
|
|
// Explicitly disable healthcheck (user cleared it)
|
|
containerConfig.Healthcheck = { Test: ["NONE"] };
|
|
} else if (options.healthcheck) {
|
|
containerConfig.Healthcheck = {};
|
|
if (options.healthcheck.test && options.healthcheck.test.length > 0) {
|
|
containerConfig.Healthcheck.Test = options.healthcheck.test;
|
|
}
|
|
if (options.healthcheck.interval !== undefined) {
|
|
containerConfig.Healthcheck.Interval = options.healthcheck.interval;
|
|
}
|
|
if (options.healthcheck.timeout !== undefined) {
|
|
containerConfig.Healthcheck.Timeout = options.healthcheck.timeout;
|
|
}
|
|
if (options.healthcheck.retries !== undefined) {
|
|
containerConfig.Healthcheck.Retries = options.healthcheck.retries;
|
|
}
|
|
if (options.healthcheck.startPeriod !== undefined) {
|
|
containerConfig.Healthcheck.StartPeriod = options.healthcheck.startPeriod;
|
|
}
|
|
}
|
|
|
|
if (options.ports === null) {
|
|
// Explicitly clear ports (user removed all mappings)
|
|
containerConfig.ExposedPorts = {};
|
|
containerConfig.HostConfig.PortBindings = {};
|
|
} else if (options.ports) {
|
|
containerConfig.ExposedPorts = {};
|
|
containerConfig.HostConfig.PortBindings = {};
|
|
|
|
for (const [containerPort, hostConfig] of Object.entries(options.ports)) {
|
|
containerConfig.ExposedPorts[containerPort] = {};
|
|
containerConfig.HostConfig.PortBindings[containerPort] = [hostConfig];
|
|
}
|
|
}
|
|
|
|
if (options.volumeBinds === null) {
|
|
// Explicitly clear volume binds (user removed all)
|
|
containerConfig.HostConfig.Binds = [];
|
|
} else if (options.volumeBinds && options.volumeBinds.length > 0) {
|
|
containerConfig.HostConfig.Binds = options.volumeBinds;
|
|
}
|
|
|
|
if (options.volumes) {
|
|
containerConfig.Volumes = options.volumes;
|
|
}
|
|
|
|
// Backward-compat: callers (and the auto-update inspect path) may still pass
|
|
// the legacy "networks" array which conflated primary + extras. Normalize to
|
|
// the new model: anything that isn't the primary becomes an additional network.
|
|
if (options.networks && !options.additionalNetworks) {
|
|
const primary = options.networkMode || '';
|
|
options.additionalNetworks = options.networks.filter(n => n !== primary);
|
|
}
|
|
|
|
// Networking model:
|
|
// - networkMode is the SINGLE source of truth for the primary network
|
|
// - EndpointsConfig has exactly ONE entry, keyed by networkMode, carrying
|
|
// the form's IPv4/IPv6/aliases/gwPriority
|
|
// - additionalNetworks (extras only — NEVER includes the primary) are
|
|
// attached after container creation via POST /networks/{name}/connect
|
|
// - Shared modes (host / none / container:X / service:X) skip both
|
|
// EndpointsConfig and additionalNetworks: Docker rejects them.
|
|
const primaryMode = options.networkMode || '';
|
|
const isSharedMode = primaryMode === 'host' || primaryMode === 'none' || primaryMode.startsWith('container:') || primaryMode.startsWith('service:');
|
|
|
|
if (primaryMode) {
|
|
containerConfig.HostConfig.NetworkMode = primaryMode;
|
|
}
|
|
|
|
// Build the single EndpointsConfig entry for the primary (only for non-shared modes).
|
|
if (primaryMode && !isSharedMode) {
|
|
const endpointConfig: any = {};
|
|
if (options.networkAliases?.length) {
|
|
endpointConfig.Aliases = options.networkAliases;
|
|
}
|
|
if (options.networkIpv4Address || options.networkIpv6Address) {
|
|
endpointConfig.IPAMConfig = {};
|
|
if (options.networkIpv4Address) endpointConfig.IPAMConfig.IPv4Address = options.networkIpv4Address;
|
|
if (options.networkIpv6Address) endpointConfig.IPAMConfig.IPv6Address = options.networkIpv6Address;
|
|
}
|
|
if (options.networkGwPriority !== undefined) {
|
|
endpointConfig.GwPriority = options.networkGwPriority;
|
|
}
|
|
containerConfig.NetworkingConfig = {
|
|
EndpointsConfig: {
|
|
[primaryMode]: endpointConfig
|
|
}
|
|
};
|
|
}
|
|
|
|
// Extras attached after creation. Empty array for shared modes.
|
|
const extraNetworks: { name: string; config: any }[] = [];
|
|
if (!isSharedMode && options.additionalNetworks?.length) {
|
|
for (const netName of options.additionalNetworks) {
|
|
if (netName === primaryMode) continue; // primary lives in EndpointsConfig, not here
|
|
const netCfg = options.networkConfigs?.[netName];
|
|
const endpointConfig: any = {};
|
|
if (netCfg) {
|
|
if (netCfg.aliases?.length) endpointConfig.Aliases = netCfg.aliases;
|
|
if (netCfg.ipv4Address || netCfg.ipv6Address) {
|
|
endpointConfig.IPAMConfig = {};
|
|
if (netCfg.ipv4Address) endpointConfig.IPAMConfig.IPv4Address = netCfg.ipv4Address;
|
|
if (netCfg.ipv6Address) endpointConfig.IPAMConfig.IPv6Address = netCfg.ipv6Address;
|
|
}
|
|
}
|
|
extraNetworks.push({ name: netName, config: endpointConfig });
|
|
}
|
|
}
|
|
|
|
if (options.privileged !== undefined) {
|
|
containerConfig.HostConfig.Privileged = options.privileged;
|
|
}
|
|
|
|
if (options.memory) {
|
|
containerConfig.HostConfig.Memory = options.memory;
|
|
}
|
|
if (options.memoryReservation) {
|
|
containerConfig.HostConfig.MemoryReservation = options.memoryReservation;
|
|
}
|
|
if (options.cpuShares) {
|
|
containerConfig.HostConfig.CpuShares = options.cpuShares;
|
|
}
|
|
if (options.cpuQuota) {
|
|
containerConfig.HostConfig.CpuQuota = options.cpuQuota;
|
|
}
|
|
if (options.cpuPeriod) {
|
|
containerConfig.HostConfig.CpuPeriod = options.cpuPeriod;
|
|
}
|
|
if (options.nanoCpus) {
|
|
containerConfig.HostConfig.NanoCpus = options.nanoCpus;
|
|
}
|
|
|
|
if (options.capAdd && options.capAdd.length > 0) {
|
|
containerConfig.HostConfig.CapAdd = options.capAdd;
|
|
}
|
|
if (options.capDrop && options.capDrop.length > 0) {
|
|
containerConfig.HostConfig.CapDrop = options.capDrop;
|
|
}
|
|
|
|
if (options.devices && options.devices.length > 0) {
|
|
containerConfig.HostConfig.Devices = options.devices.map(d => ({
|
|
PathOnHost: d.hostPath,
|
|
PathInContainer: d.containerPath,
|
|
CgroupPermissions: d.permissions || 'rwm'
|
|
}));
|
|
}
|
|
|
|
if (options.dns && options.dns.length > 0) {
|
|
containerConfig.HostConfig.Dns = options.dns;
|
|
}
|
|
if (options.dnsSearch && options.dnsSearch.length > 0) {
|
|
containerConfig.HostConfig.DnsSearch = options.dnsSearch;
|
|
}
|
|
if (options.dnsOptions && options.dnsOptions.length > 0) {
|
|
containerConfig.HostConfig.DnsOptions = options.dnsOptions;
|
|
}
|
|
|
|
if (options.securityOpt && options.securityOpt.length > 0) {
|
|
containerConfig.HostConfig.SecurityOpt = options.securityOpt;
|
|
}
|
|
|
|
if (options.ulimits && options.ulimits.length > 0) {
|
|
containerConfig.HostConfig.Ulimits = options.ulimits.map(u => ({
|
|
Name: u.name,
|
|
Soft: u.soft,
|
|
Hard: u.hard
|
|
}));
|
|
}
|
|
|
|
// Entrypoint
|
|
if (options.entrypoint && options.entrypoint.length > 0) {
|
|
containerConfig.Entrypoint = options.entrypoint;
|
|
}
|
|
|
|
// Working directory
|
|
if (options.workingDir) {
|
|
containerConfig.WorkingDir = options.workingDir;
|
|
}
|
|
|
|
// Hostname
|
|
if (options.hostname) {
|
|
containerConfig.Hostname = options.hostname;
|
|
}
|
|
|
|
// TTY and StdinOpen
|
|
if (options.tty !== undefined) {
|
|
containerConfig.Tty = options.tty;
|
|
}
|
|
if (options.stdinOpen !== undefined) {
|
|
containerConfig.OpenStdin = options.stdinOpen;
|
|
}
|
|
|
|
// Memory swap
|
|
if (options.memorySwap !== undefined) {
|
|
containerConfig.HostConfig.MemorySwap = options.memorySwap;
|
|
}
|
|
|
|
// OOM kill disable
|
|
if (options.oomKillDisable !== undefined) {
|
|
containerConfig.HostConfig.OomKillDisable = options.oomKillDisable;
|
|
}
|
|
|
|
// Pids limit
|
|
if (options.pidsLimit !== undefined) {
|
|
containerConfig.HostConfig.PidsLimit = options.pidsLimit;
|
|
}
|
|
|
|
// Shared memory size
|
|
if (options.shmSize !== undefined) {
|
|
containerConfig.HostConfig.ShmSize = options.shmSize;
|
|
}
|
|
|
|
// Tmpfs mounts
|
|
if (options.tmpfs && Object.keys(options.tmpfs).length > 0) {
|
|
containerConfig.HostConfig.Tmpfs = options.tmpfs;
|
|
}
|
|
|
|
// Sysctls
|
|
if (options.sysctls && Object.keys(options.sysctls).length > 0) {
|
|
containerConfig.HostConfig.Sysctls = options.sysctls;
|
|
}
|
|
|
|
// Logging configuration
|
|
if (options.logDriver) {
|
|
containerConfig.HostConfig.LogConfig = {
|
|
Type: options.logDriver,
|
|
Config: options.logOptions || {}
|
|
};
|
|
}
|
|
|
|
// IPC mode
|
|
if (options.ipcMode) {
|
|
containerConfig.HostConfig.IpcMode = options.ipcMode;
|
|
}
|
|
|
|
// PID mode
|
|
if (options.pidMode) {
|
|
containerConfig.HostConfig.PidMode = options.pidMode;
|
|
}
|
|
|
|
// UTS mode
|
|
if (options.utsMode) {
|
|
containerConfig.HostConfig.UTSMode = options.utsMode;
|
|
}
|
|
|
|
// Cgroup parent
|
|
if (options.cgroupParent) {
|
|
containerConfig.HostConfig.CgroupParent = options.cgroupParent;
|
|
}
|
|
|
|
// Stop signal
|
|
if (options.stopSignal) {
|
|
containerConfig.StopSignal = options.stopSignal;
|
|
}
|
|
|
|
// Init process
|
|
if (options.init !== undefined) {
|
|
containerConfig.HostConfig.Init = options.init;
|
|
}
|
|
|
|
// Stop timeout
|
|
if (options.stopTimeout !== undefined) {
|
|
containerConfig.StopTimeout = options.stopTimeout;
|
|
}
|
|
|
|
// MAC address — set both top-level (API <1.44) and endpoint config (API 1.44+)
|
|
if (options.macAddress) {
|
|
containerConfig.MacAddress = options.macAddress;
|
|
|
|
// For Docker API 1.44+, MacAddress must be in EndpointConfig
|
|
const primaryNetwork = options.networks?.[0] || options.networkMode || 'bridge';
|
|
if (containerConfig.NetworkingConfig?.EndpointsConfig?.[primaryNetwork]) {
|
|
containerConfig.NetworkingConfig.EndpointsConfig[primaryNetwork].MacAddress = options.macAddress;
|
|
} else {
|
|
containerConfig.NetworkingConfig = containerConfig.NetworkingConfig || { EndpointsConfig: {} };
|
|
containerConfig.NetworkingConfig.EndpointsConfig = containerConfig.NetworkingConfig.EndpointsConfig || {};
|
|
containerConfig.NetworkingConfig.EndpointsConfig[primaryNetwork] = {
|
|
...containerConfig.NetworkingConfig.EndpointsConfig[primaryNetwork],
|
|
MacAddress: options.macAddress
|
|
};
|
|
}
|
|
}
|
|
|
|
// Extra hosts (/etc/hosts entries)
|
|
if (options.extraHosts && options.extraHosts.length > 0) {
|
|
containerConfig.HostConfig.ExtraHosts = options.extraHosts;
|
|
}
|
|
|
|
// Device requests (GPU access, etc.)
|
|
if (options.deviceRequests && options.deviceRequests.length > 0) {
|
|
containerConfig.HostConfig.DeviceRequests = options.deviceRequests.map(dr => ({
|
|
Driver: dr.driver || '',
|
|
Count: dr.count ?? -1,
|
|
DeviceIDs: dr.deviceIDs || [],
|
|
Capabilities: dr.capabilities || [],
|
|
Options: dr.options || {}
|
|
}));
|
|
}
|
|
|
|
// Container runtime (e.g., 'nvidia' for GPU containers)
|
|
if (options.runtime !== undefined) {
|
|
containerConfig.HostConfig.Runtime = options.runtime ?? '';
|
|
}
|
|
|
|
// Read-only root filesystem
|
|
if (options.readonlyRootfs !== undefined) {
|
|
containerConfig.HostConfig.ReadonlyRootfs = options.readonlyRootfs;
|
|
}
|
|
|
|
// CPU pinning
|
|
if (options.cpusetCpus) {
|
|
containerConfig.HostConfig.CpusetCpus = options.cpusetCpus;
|
|
}
|
|
|
|
// NUMA memory nodes
|
|
if (options.cpusetMems) {
|
|
containerConfig.HostConfig.CpusetMems = options.cpusetMems;
|
|
}
|
|
|
|
// Additional groups
|
|
if (options.groupAdd && options.groupAdd.length > 0) {
|
|
containerConfig.HostConfig.GroupAdd = options.groupAdd;
|
|
}
|
|
|
|
// Memory swappiness
|
|
if (options.memorySwappiness !== undefined) {
|
|
containerConfig.HostConfig.MemorySwappiness = options.memorySwappiness;
|
|
}
|
|
|
|
// User namespace mode
|
|
if (options.usernsMode) {
|
|
containerConfig.HostConfig.UsernsMode = options.usernsMode;
|
|
}
|
|
|
|
// Domain name
|
|
if (options.domainname) {
|
|
containerConfig.Domainname = options.domainname;
|
|
}
|
|
|
|
// Shared network modes (host / container:X / service:X) inherit the
|
|
// namespace's networking, so Docker rejects any field that would override it.
|
|
// Strip them defensively — the merge in updateContainer carries values from
|
|
// the old config that may not be valid for the new mode (e.g. switching from
|
|
// bridge → container:foo with a leftover Hostname).
|
|
// Mirrors the same logic in recreateContainerFromInspect.
|
|
{
|
|
const primary = containerConfig.HostConfig?.NetworkMode || '';
|
|
const isHost = primary === 'host';
|
|
const isContainer = primary.startsWith('container:');
|
|
const isService = primary.startsWith('service:');
|
|
if (isHost || isContainer || isService) {
|
|
delete containerConfig.Hostname;
|
|
delete containerConfig.Domainname;
|
|
delete containerConfig.MacAddress;
|
|
}
|
|
if (isContainer || isService) {
|
|
// container:X also shares ports / DNS / hosts with the target
|
|
delete containerConfig.ExposedPorts;
|
|
if (containerConfig.HostConfig) {
|
|
delete containerConfig.HostConfig.PortBindings;
|
|
delete containerConfig.HostConfig.PublishAllPorts;
|
|
delete containerConfig.HostConfig.Dns;
|
|
delete containerConfig.HostConfig.DnsOptions;
|
|
delete containerConfig.HostConfig.DnsSearch;
|
|
delete containerConfig.HostConfig.ExtraHosts;
|
|
delete containerConfig.HostConfig.Links;
|
|
}
|
|
// NetworkingConfig is meaningless when sharing a namespace
|
|
delete containerConfig.NetworkingConfig;
|
|
}
|
|
}
|
|
|
|
const result = await dockerJsonRequest<{ Id: string }>(
|
|
`/containers/create?name=${encodeURIComponent(options.name)}`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify(containerConfig)
|
|
},
|
|
envId
|
|
);
|
|
|
|
// Attach additional networks now that the container exists. Docker only allows
|
|
// a single network at create time, so anything beyond the primary is connected here.
|
|
for (const extra of extraNetworks) {
|
|
try {
|
|
await connectContainerToNetworkRaw(extra.name, result.Id, extra.config, envId);
|
|
} catch (err: any) {
|
|
console.error(`Failed to attach additional network "${extra.name}":`, err?.message || err);
|
|
// Don't fail the whole create — primary network is already connected.
|
|
// Caller (updateContainer rollback path) will surface this if needed.
|
|
}
|
|
}
|
|
|
|
return { id: result.Id, start: () => startContainer(result.Id, envId) };
|
|
}
|
|
|
|
|
|
/**
|
|
* Recreate a container using full Config/HostConfig passthrough from inspect data.
|
|
* Passes Config and HostConfig directly from inspect to create, only changing
|
|
* the image. No field mapping or stripping.
|
|
*
|
|
* Flow:
|
|
* 1. Stop container
|
|
* 2. Rename to name-old (frees the name for the new container)
|
|
* 3. Disconnect all networks (frees static IPs)
|
|
* 4. Create new container with original name, one network
|
|
* 5. Connect additional networks
|
|
* 6. Start new container
|
|
* 7. Remove old container
|
|
*
|
|
* On failure: rollback (rename old back, reconnect networks, restart old)
|
|
*/
|
|
export async function recreateContainerFromInspect(
|
|
inspectData: any,
|
|
newImage: string,
|
|
envId?: number | null,
|
|
log?: (msg: string) => void,
|
|
/**
|
|
* The OLD image's Config (Env/Labels), captured BEFORE the new image was
|
|
* pulled. Required for the env/label rebase (#1226, #1256): once the tag is
|
|
* repointed the old image may be untagged/GC'd and no longer inspectable, so
|
|
* it cannot be re-fetched here. When omitted, the rebase falls back to a
|
|
* best-effort inspect of the old image id and, failing that, verbatim.
|
|
*/
|
|
oldImageConfig?: ImageEnvLabels | null
|
|
): Promise<{ Id: string }> {
|
|
const config = inspectData.Config || {};
|
|
const hostConfig = inspectData.HostConfig || {};
|
|
const networks: Record<string, any> = inspectData.NetworkSettings?.Networks || {};
|
|
const name = inspectData.Name?.replace(/^\//, '') || '';
|
|
const oldContainerId = inspectData.Id;
|
|
const wasRunning = inspectData.State?.Running;
|
|
|
|
// Detect shared/special network modes where network manipulation must be skipped
|
|
const networkMode = hostConfig.NetworkMode || '';
|
|
const isSharedNetwork = networkMode.startsWith('container:') ||
|
|
networkMode.startsWith('service:') ||
|
|
networkMode === 'host' ||
|
|
networkMode === 'none';
|
|
|
|
if (isSharedNetwork) {
|
|
log?.(`Shared network mode detected: ${networkMode} — skipping network manipulation`);
|
|
}
|
|
|
|
// 1. Stop the container
|
|
if (wasRunning) {
|
|
log?.('Stopping container...');
|
|
await stopContainer(oldContainerId, envId);
|
|
}
|
|
|
|
// 2. Rename old container to free the name
|
|
log?.('Renaming old container...');
|
|
await dockerFetch(
|
|
`/containers/${oldContainerId}/rename?name=${encodeURIComponent(name + '-old')}`,
|
|
{ method: 'POST' },
|
|
envId
|
|
).then(async r => { if (!r.ok) throw new Error('Failed to rename old container'); await drainResponse(r); });
|
|
|
|
// 3. Disconnect all networks from old container (frees static IPs)
|
|
// Skip for shared network modes (container:X, host, none) — Docker manages these
|
|
// Capture the first network for use during container creation
|
|
let initialNetworkName: string | null = null;
|
|
let initialNetworkConfig: any = null;
|
|
|
|
if (!isSharedNetwork) {
|
|
for (const [netName, netConfig] of Object.entries(networks)) {
|
|
const networkId = (netConfig as any).NetworkID;
|
|
if (networkId) {
|
|
try {
|
|
await disconnectContainerFromNetwork(networkId, oldContainerId, true, envId);
|
|
} catch {
|
|
// Best effort - network may already be disconnected
|
|
}
|
|
}
|
|
|
|
// Use first network for creation
|
|
if (!initialNetworkName) {
|
|
initialNetworkName = netName;
|
|
initialNetworkConfig = netConfig;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rollback helper: restore old container on failure
|
|
const rollback = async () => {
|
|
try {
|
|
log?.('Rolling back: restoring old container...');
|
|
// Rename back
|
|
await dockerFetch(
|
|
`/containers/${oldContainerId}/rename?name=${encodeURIComponent(name)}`,
|
|
{ method: 'POST' },
|
|
envId
|
|
).then(r => drainResponse(r)).catch(() => {});
|
|
|
|
// Reconnect networks using full EndpointSettings from inspect
|
|
if (!isSharedNetwork) {
|
|
for (const [, netConfig] of Object.entries(networks)) {
|
|
const nc = netConfig as any;
|
|
if (nc.NetworkID) {
|
|
await connectContainerToNetworkRaw(nc.NetworkID, oldContainerId, nc, envId).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restart
|
|
if (wasRunning) {
|
|
await startContainer(oldContainerId, envId).catch(() => {});
|
|
}
|
|
} catch {
|
|
log?.('Rollback failed');
|
|
}
|
|
};
|
|
|
|
// 4. Build create config - pass Config and HostConfig directly from inspect
|
|
const createConfig: any = {
|
|
...config,
|
|
Image: newImage,
|
|
HostConfig: hostConfig
|
|
};
|
|
|
|
// 4a. Rebase image-baked env/labels onto the NEW image (#1226, #1256).
|
|
// A container's Config.Env/Labels are flattened at create time, so the old
|
|
// image's baked defaults are indistinguishable from user overrides and would
|
|
// be frozen on recreation. We compare the container's values against the OLD
|
|
// image and rebase by VALUE: keys the user never touched (value == old image)
|
|
// follow the new image; user overrides (value differs) and user-only entries
|
|
// survive.
|
|
//
|
|
// The OLD image config MUST come from the caller (captured before the pull) —
|
|
// after the tag is repointed the old digest is often untagged/GC'd and can no
|
|
// longer be inspected. We fall back to inspecting the old image id only when
|
|
// the caller didn't provide it, and to verbatim if that also fails.
|
|
try {
|
|
let oldImg = oldImageConfig ?? null;
|
|
if (!oldImg) {
|
|
const oldInspect = await inspectImage(inspectData.Image || config.Image, envId);
|
|
oldImg = {
|
|
Env: (oldInspect as any)?.Config?.Env,
|
|
Labels: (oldInspect as any)?.Config?.Labels,
|
|
Cmd: (oldInspect as any)?.Config?.Cmd ?? null,
|
|
Entrypoint: (oldInspect as any)?.Config?.Entrypoint ?? null,
|
|
};
|
|
}
|
|
const newImg = await inspectImage(newImage, envId);
|
|
const oldEnv = oldImg?.Env || [];
|
|
const newEnv = (newImg as any)?.Config?.Env || [];
|
|
const oldLabels = oldImg?.Labels || {};
|
|
const newLabels = (newImg as any)?.Config?.Labels || {};
|
|
createConfig.Env = rebaseEnvOntoImage(config.Env || [], oldEnv, newEnv);
|
|
createConfig.Labels = rebaseLabelsOntoImage(config.Labels || {}, oldLabels, newLabels);
|
|
|
|
// 4b. Rebase Cmd/Entrypoint the same way (#1371): a container's Config.Cmd is flattened at
|
|
// create time, so the OLD image's baked CMD is indistinguishable from a user `command:`
|
|
// override and would be frozen onto the new image - a container can then start with a
|
|
// command the new image no longer provides (missing script -> exit 127 -> restart loop).
|
|
// oldImg?.Cmd == null (caller didn't capture it / inspect failed) -> keep verbatim.
|
|
const cmdR = rebaseCommand(config.Cmd, oldImg?.Cmd, (newImg as any)?.Config?.Cmd);
|
|
const entR = rebaseCommand(config.Entrypoint, oldImg?.Entrypoint, (newImg as any)?.Config?.Entrypoint);
|
|
createConfig.Cmd = cmdR.value;
|
|
createConfig.Entrypoint = entR.value;
|
|
if (cmdR.adopted) log?.(`Cmd — adopted from new image: ${JSON.stringify(cmdR.value)} (was ${JSON.stringify(config.Cmd)})`);
|
|
if (entR.adopted) log?.(`Entrypoint — adopted from new image: ${JSON.stringify(entR.value)} (was ${JSON.stringify(config.Entrypoint)})`);
|
|
|
|
const envSummary = describeEnvRebase(config.Env || [], oldEnv, newEnv);
|
|
const labelSummary = describeLabelRebase(config.Labels || {}, oldLabels, newLabels);
|
|
const fmt = (label: string, keys: string[]) => keys.length ? `${label}: ${keys.join(', ')}` : '';
|
|
const envParts = [
|
|
fmt('adopted from new image', envSummary.adopted),
|
|
fmt('kept your overrides', envSummary.preserved),
|
|
fmt('kept your vars', envSummary.userOnly)
|
|
].filter(Boolean);
|
|
log?.(`Env rebase — ${envParts.length ? envParts.join('; ') : 'no changes'}`);
|
|
const labelParts = [
|
|
fmt('adopted from new image', labelSummary.adopted),
|
|
fmt('kept your overrides', labelSummary.preserved),
|
|
fmt('kept your labels', labelSummary.userOnly)
|
|
].filter(Boolean);
|
|
log?.(`Label rebase — ${labelParts.length ? labelParts.join('; ') : 'no changes'}`);
|
|
} catch (e: any) {
|
|
log?.(`Rebase skipped (image inspect failed), keeping env/labels verbatim: ${e?.message || e}`);
|
|
}
|
|
|
|
// Strip default MemorySwappiness — Podman + cgroupv2 rejects it.
|
|
// Docker returns -1, Podman returns 0 when unset.
|
|
const swappiness = createConfig.HostConfig?.MemorySwappiness;
|
|
if (swappiness == null || swappiness === -1 || swappiness === 0) {
|
|
delete createConfig.HostConfig.MemorySwappiness;
|
|
}
|
|
|
|
// NanoCpus and CpuPeriod/CpuQuota are two ways to express the same CPU limit and are
|
|
// mutually exclusive at create time. Podman's inspect reports BOTH, so passing the whole
|
|
// HostConfig back trips its "NanoCpus conflicts with CpuPeriod and CpuQuota" (#1381).
|
|
if (resolveNanoCpusConflict(createConfig.HostConfig)) {
|
|
log?.('Dropped CpuPeriod/CpuQuota — they conflict with NanoCpus at create time (kept NanoCpus)');
|
|
}
|
|
|
|
// container:<name> mode shares the network namespace — Docker rejects
|
|
// networking-related fields on the dependent container since they're
|
|
// owned by the network provider container
|
|
if (networkMode.startsWith('container:')) {
|
|
delete createConfig.Hostname;
|
|
delete createConfig.Domainname;
|
|
delete createConfig.ExposedPorts;
|
|
delete createConfig.MacAddress;
|
|
// HostConfig fields that conflict with container network mode
|
|
if (createConfig.HostConfig) {
|
|
delete createConfig.HostConfig.PortBindings;
|
|
delete createConfig.HostConfig.PublishAllPorts;
|
|
delete createConfig.HostConfig.DNS;
|
|
delete createConfig.HostConfig.DNSOptions;
|
|
delete createConfig.HostConfig.DNSSearch;
|
|
delete createConfig.HostConfig.ExtraHosts;
|
|
delete createConfig.HostConfig.Links;
|
|
}
|
|
|
|
// Resolve container ID references to names for resilience.
|
|
// Docker stores NetworkMode with the full container SHA ID, but if that container
|
|
// gets recreated (new ID), the reference goes stale. Using the container name
|
|
// instead makes the reference survive recreation.
|
|
const containerRef = networkMode.slice('container:'.length);
|
|
const isHexId = /^[0-9a-f]{12,64}$/.test(containerRef);
|
|
if (isHexId) {
|
|
try {
|
|
const refInspect = await inspectContainer(containerRef, envId);
|
|
// Container exists — switch from ID to name for resilience
|
|
const refName = (refInspect as any).Name?.replace(/^\//, '');
|
|
if (refName) {
|
|
createConfig.HostConfig.NetworkMode = `container:${refName}`;
|
|
log?.(`Resolved network container ID to name: ${refName}`);
|
|
}
|
|
} catch {
|
|
// Container ID is stale — the referenced container was likely recreated
|
|
// with a new ID. We can't resolve without knowing the original name.
|
|
log?.(`WARNING: Network reference container:${containerRef.slice(0, 12)}... is stale (container not found). The container may fail to start if the referenced container was recreated.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Deduplicate Config.Volumes against every mount point that will exist on the new container
|
|
// (Tmpfs, existing binds, the binds we re-add below, and inspect.Mounts). Docker rejects
|
|
// duplicate mount points; the tmpfs/bind/volume mount wins over the bare image-VOLUME
|
|
// declaration. Compute additionalBinds first so the dedup sees them (#1088 / #1363).
|
|
const additionalBinds = getAdditionalVolumeBinds(hostConfig || {}, inspectData.Mounts || []);
|
|
if (hostConfig) {
|
|
const kept = dedupeVolumesForRecreate(createConfig.Volumes, hostConfig, inspectData.Mounts || [], additionalBinds);
|
|
if (kept) createConfig.Volumes = kept;
|
|
else delete createConfig.Volumes;
|
|
}
|
|
if (additionalBinds.length > 0) {
|
|
createConfig.HostConfig = {
|
|
...hostConfig,
|
|
Binds: [...(hostConfig?.Binds || []), ...additionalBinds]
|
|
};
|
|
}
|
|
|
|
// Docker can only connect to one network at creation. Pass the first network
|
|
// from the old container's settings to avoid getting a random bridge IP.
|
|
// Skip for shared network modes — EndpointsConfig conflicts with container:/host/none modes.
|
|
if (!isSharedNetwork && initialNetworkName && initialNetworkConfig) {
|
|
const endpointConfig = { ...initialNetworkConfig };
|
|
createConfig.NetworkingConfig = {
|
|
EndpointsConfig: {
|
|
[initialNetworkName]: endpointConfig
|
|
}
|
|
};
|
|
// Container-level MacAddress conflicts with endpoint-level MacAddress.
|
|
// Docker requires them to match or the top-level one to be empty.
|
|
// The MAC is preserved in the endpoint config (correct location per API v1.44+),
|
|
// so clear the top-level one to avoid the conflict error.
|
|
delete createConfig.MacAddress;
|
|
}
|
|
|
|
// 5. Create new container
|
|
log?.('Creating new container...');
|
|
let newContainerId: string;
|
|
try {
|
|
const result = await dockerJsonRequest<{ Id: string }>(
|
|
`/containers/create?name=${encodeURIComponent(name)}`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify(createConfig)
|
|
},
|
|
envId
|
|
);
|
|
newContainerId = result.Id;
|
|
} catch (createError: any) {
|
|
log?.(`Create failed: ${createError.message}`);
|
|
await rollback();
|
|
throw createError;
|
|
}
|
|
|
|
// 6. Connect additional networks using full EndpointSettings from inspect
|
|
// Skip for shared network modes — Docker manages networking via the parent container
|
|
if (!isSharedNetwork) {
|
|
for (const [netName, netConfig] of Object.entries(networks)) {
|
|
if (netName === initialNetworkName) continue; // Already connected at creation
|
|
|
|
const nc = netConfig as any;
|
|
if (nc.NetworkID) {
|
|
try {
|
|
await connectContainerToNetworkRaw(nc.NetworkID, newContainerId, nc, envId);
|
|
} catch (netError: any) {
|
|
log?.(`Warning: Failed to connect to network "${netName}": ${netError.message}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 7. Start new container
|
|
if (wasRunning) {
|
|
log?.('Starting new container...');
|
|
try {
|
|
await startContainer(newContainerId, envId);
|
|
} catch (startError: any) {
|
|
log?.(`Start failed: ${startError.message}, rolling back...`);
|
|
// Remove failed new container
|
|
await removeContainer(newContainerId, true, envId).catch(() => {});
|
|
await rollback();
|
|
throw startError;
|
|
}
|
|
}
|
|
|
|
// 8. Log config diff between old and new container
|
|
try {
|
|
const newInspect = await inspectContainer(newContainerId, envId);
|
|
const diffs = deepDiff(inspectData, newInspect);
|
|
if (diffs.length === 0) {
|
|
log?.(`[${name}] Config diff: no differences (all settings preserved)`);
|
|
} else {
|
|
log?.(`[${name}] Config diff: ${diffs.length} difference(s):`);
|
|
for (const d of diffs) {
|
|
log?.(` [${name}] ${d}`);
|
|
}
|
|
}
|
|
} catch {
|
|
// Non-critical, don't fail the update
|
|
}
|
|
|
|
// 9. Remove old container (best effort)
|
|
log?.('Removing old container...');
|
|
await removeContainer(oldContainerId, true, envId).catch(() => {});
|
|
|
|
log?.('Container recreated successfully');
|
|
return { Id: newContainerId };
|
|
}
|
|
|
|
/**
|
|
* Ensure `image` is present on the target env, pulling it if not. Docker's
|
|
* `/containers/create` does NOT auto-pull (unlike `docker run`), so a cross-env
|
|
* clone restore onto a host that has never seen the image fails with
|
|
* "No such image". Check-then-pull closes that gap. A pull failure (private
|
|
* registry, missing arch, real 404) propagates so the caller surfaces the raw
|
|
* Docker error instead of the more confusing "No such image".
|
|
*/
|
|
async function ensureImagePresent(image: string, envId?: number | null, log?: (msg: string) => void): Promise<void> {
|
|
const res = await dockerFetch(`/images/${encodeURIComponent(image)}/json`, {}, envId);
|
|
if (res.ok) { await drainResponse(res); return; }
|
|
await drainResponse(res);
|
|
log?.(`Image ${image} not present — pulling…`);
|
|
await pullImage(image, undefined, envId);
|
|
log?.(`Pulled ${image}`);
|
|
}
|
|
|
|
/**
|
|
* Create a container from inspect-like metadata (for restore).
|
|
* Applies the same edge-case handling as recreateContainerFromInspect
|
|
* (Podman compat, shared network modes, volume deduplication, etc.)
|
|
* but without the stop/rename/rollback flow — creates fresh.
|
|
*/
|
|
export async function createContainerFromMetadata(
|
|
containerName: string,
|
|
image: string,
|
|
metadata: {
|
|
config?: any;
|
|
hostConfig?: any;
|
|
mounts?: any[];
|
|
networkSettings?: { Networks?: Record<string, any> };
|
|
},
|
|
envId?: number | null,
|
|
log?: (msg: string) => void
|
|
): Promise<{ Id: string }> {
|
|
const config = { ...metadata.config } || {};
|
|
const hostConfig = { ...metadata.hostConfig } || {};
|
|
const networks: Record<string, any> = metadata.networkSettings?.Networks || {};
|
|
|
|
const networkMode = hostConfig.NetworkMode || '';
|
|
const isSharedNetwork = networkMode.startsWith('container:') ||
|
|
networkMode.startsWith('service:') ||
|
|
networkMode === 'host' ||
|
|
networkMode === 'none';
|
|
|
|
// Build create config
|
|
const createConfig: any = {
|
|
...config,
|
|
Image: image,
|
|
HostConfig: hostConfig
|
|
};
|
|
|
|
// Clean up fields not useful for restore
|
|
delete createConfig.Domainname;
|
|
delete createConfig.MacAddress;
|
|
|
|
// Strip default MemorySwappiness (Podman + cgroupv2 rejects it)
|
|
const swappiness = createConfig.HostConfig?.MemorySwappiness;
|
|
if (swappiness == null || swappiness === -1 || swappiness === 0) {
|
|
delete createConfig.HostConfig.MemorySwappiness;
|
|
}
|
|
|
|
// container:<name> mode: clean up conflicting network fields
|
|
if (networkMode.startsWith('container:')) {
|
|
delete createConfig.Hostname;
|
|
delete createConfig.ExposedPorts;
|
|
if (createConfig.HostConfig) {
|
|
delete createConfig.HostConfig.PortBindings;
|
|
delete createConfig.HostConfig.PublishAllPorts;
|
|
delete createConfig.HostConfig.DNS;
|
|
delete createConfig.HostConfig.DNSOptions;
|
|
delete createConfig.HostConfig.DNSSearch;
|
|
delete createConfig.HostConfig.ExtraHosts;
|
|
delete createConfig.HostConfig.Links;
|
|
}
|
|
}
|
|
|
|
// Preserve anonymous volumes, then dedup Config.Volumes against every resulting mount point
|
|
// (incl. these added binds + metadata.mounts) so recreate never sends a duplicate (#1088 / #1363).
|
|
const additionalBinds = getAdditionalVolumeBinds(hostConfig || {}, metadata.mounts || []);
|
|
if (hostConfig) {
|
|
const kept = dedupeVolumesForRecreate(createConfig.Volumes, hostConfig, metadata.mounts || [], additionalBinds);
|
|
if (kept) createConfig.Volumes = kept;
|
|
else delete createConfig.Volumes;
|
|
}
|
|
if (additionalBinds.length > 0) {
|
|
createConfig.HostConfig = {
|
|
...hostConfig,
|
|
Binds: [...(hostConfig?.Binds || []), ...additionalBinds]
|
|
};
|
|
}
|
|
|
|
// Set initial network for creation
|
|
if (!isSharedNetwork) {
|
|
const firstNetName = Object.keys(networks)[0];
|
|
const firstNetConfig = firstNetName ? networks[firstNetName] : null;
|
|
if (firstNetName && firstNetConfig) {
|
|
// Strip NetworkID — it's the source env's ID and won't match on
|
|
// the target. Docker resolves by name when NetworkID is absent.
|
|
const { NetworkID: _id, ...endpointConfig } = firstNetConfig as Record<string, unknown>;
|
|
createConfig.NetworkingConfig = {
|
|
EndpointsConfig: { [firstNetName]: endpointConfig }
|
|
};
|
|
}
|
|
}
|
|
|
|
// Pull the image first if the target env doesn't have it — a cross-env clone
|
|
// restore lands on a host that may never have seen this image, and create
|
|
// (unlike run) won't pull it.
|
|
await ensureImagePresent(image, envId, log);
|
|
|
|
// Create container
|
|
log?.(`Creating container ${containerName}...`);
|
|
const result = await dockerJsonRequest<{ Id: string }>(
|
|
`/containers/create?name=${encodeURIComponent(containerName)}`,
|
|
{ method: 'POST', body: JSON.stringify(createConfig) },
|
|
envId
|
|
);
|
|
const newContainerId = result.Id;
|
|
|
|
// Connect additional networks (by name — source NetworkIDs don't exist on
|
|
// the target after a cross-env restore, and even on same-env restores the
|
|
// network may have been recreated with a new ID).
|
|
if (!isSharedNetwork) {
|
|
const firstNetName = Object.keys(networks)[0];
|
|
for (const [netName, netConfig] of Object.entries(networks)) {
|
|
if (netName === firstNetName) continue;
|
|
const { NetworkID: _id, ...nc } = (netConfig as Record<string, unknown>);
|
|
try {
|
|
await connectContainerToNetworkRaw(netName, newContainerId, nc, envId);
|
|
} catch (netError: any) {
|
|
log?.(`Warning: Failed to connect to network "${netName}": ${netError.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Start container
|
|
log?.(`Starting container ${containerName}...`);
|
|
await startContainer(newContainerId, envId);
|
|
|
|
log?.(`Container ${containerName} created and started (${newContainerId.slice(0, 12)})`);
|
|
return { Id: newContainerId };
|
|
}
|
|
|
|
/**
|
|
* Extract all container options from Docker inspect data.
|
|
* This preserves ALL container settings for recreation.
|
|
* Used by both updateContainer and recreateContainer to ensure consistency.
|
|
*/
|
|
export function extractContainerOptions(inspectData: any): CreateContainerOptions {
|
|
const config = inspectData.Config;
|
|
const hostConfig = inspectData.HostConfig;
|
|
const name = inspectData.Name?.replace(/^\//, '') || '';
|
|
|
|
// Port bindings - preserve all host port mappings including HostIp
|
|
const ports: { [key: string]: { HostIp?: string; HostPort: string } } = {};
|
|
if (hostConfig.PortBindings) {
|
|
for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) {
|
|
if (bindings && (bindings as any[]).length > 0) {
|
|
const binding = (bindings as any[])[0];
|
|
ports[containerPort] = {
|
|
HostPort: binding.HostPort || ''
|
|
};
|
|
// Preserve HostIp if specified (e.g., '192.168.0.250:80:80' in compose)
|
|
if (binding.HostIp) {
|
|
ports[containerPort].HostIp = binding.HostIp;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Volume bindings - preserve ALL volumes including anonymous volumes
|
|
const volumeBinds: string[] = [];
|
|
const mountedPaths = new Set<string>();
|
|
|
|
// First, add all entries from hostConfig.Binds (named volumes and bind mounts)
|
|
if (hostConfig.Binds && Array.isArray(hostConfig.Binds)) {
|
|
for (const bind of hostConfig.Binds) {
|
|
volumeBinds.push(bind);
|
|
const parts = bind.split(':');
|
|
if (parts.length >= 2) {
|
|
mountedPaths.add(parts[1].split(':')[0]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Then, add anonymous volumes from Mounts that aren't already in Binds
|
|
const mounts = inspectData.Mounts || [];
|
|
for (const mount of mounts) {
|
|
if (mount.Type === 'volume' && mount.Name && mount.Destination) {
|
|
if (!mountedPaths.has(mount.Destination)) {
|
|
const bindStr = mount.RW === false
|
|
? `${mount.Name}:${mount.Destination}:ro`
|
|
: `${mount.Name}:${mount.Destination}`;
|
|
volumeBinds.push(bindStr);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Healthcheck configuration
|
|
let healthcheck: HealthcheckConfig | undefined = undefined;
|
|
if (config.Healthcheck && config.Healthcheck.Test && config.Healthcheck.Test.length > 0) {
|
|
if (config.Healthcheck.Test[0] !== 'NONE') {
|
|
healthcheck = {
|
|
test: config.Healthcheck.Test,
|
|
interval: config.Healthcheck.Interval,
|
|
timeout: config.Healthcheck.Timeout,
|
|
retries: config.Healthcheck.Retries,
|
|
startPeriod: config.Healthcheck.StartPeriod
|
|
};
|
|
}
|
|
}
|
|
|
|
// Device mappings
|
|
const devices = (hostConfig.Devices || []).map((d: any) => ({
|
|
hostPath: d.PathOnHost || '',
|
|
containerPath: d.PathInContainer || '',
|
|
permissions: d.CgroupPermissions || 'rwm'
|
|
})).filter((d: any) => d.hostPath && d.containerPath);
|
|
|
|
// Ulimits
|
|
const ulimits = (hostConfig.Ulimits || []).map((u: any) => ({
|
|
name: u.Name,
|
|
soft: u.Soft,
|
|
hard: u.Hard
|
|
}));
|
|
|
|
// Extract network settings
|
|
const networkSettings = inspectData.NetworkSettings?.Networks || {};
|
|
const primaryNetwork = hostConfig.NetworkMode || 'bridge';
|
|
|
|
// Extract primary network aliases, static IP, and gateway priority
|
|
let networkAliases: string[] | undefined;
|
|
let networkIpv4Address: string | undefined;
|
|
let networkIpv6Address: string | undefined;
|
|
let macAddress: string | undefined;
|
|
let networkGwPriority: number | undefined;
|
|
|
|
const containerId = inspectData.Id || '';
|
|
const shortContainerId = containerId.substring(0, 12);
|
|
|
|
// Extract compose labels for alias reconstruction
|
|
const composeProject = config.Labels?.['com.docker.compose.project'];
|
|
const composeService = config.Labels?.['com.docker.compose.service'];
|
|
|
|
for (const [netName, netConfig] of Object.entries(networkSettings)) {
|
|
const netConf = netConfig as any;
|
|
const isPrimary = netName === primaryNetwork ||
|
|
(primaryNetwork === 'bridge' && (netName === 'bridge' || netName === 'default'));
|
|
|
|
if (isPrimary) {
|
|
// Filter out auto-generated container IDs
|
|
const allAliases = (netConf.Aliases?.length > 0 ? netConf.Aliases : netConf.DNSNames) || [];
|
|
networkAliases = allAliases.filter((a: string) =>
|
|
a !== containerId && a !== shortContainerId
|
|
);
|
|
|
|
// For compose containers, ensure service name and project-service aliases
|
|
if (composeProject && composeService) {
|
|
if (!networkAliases) networkAliases = [];
|
|
if (!networkAliases.includes(composeService)) {
|
|
networkAliases.push(composeService);
|
|
}
|
|
const projectService = `${composeProject}-${composeService}`;
|
|
if (!networkAliases.includes(projectService)) {
|
|
networkAliases.push(projectService);
|
|
}
|
|
}
|
|
|
|
if (!networkAliases || networkAliases.length === 0) {
|
|
networkAliases = undefined;
|
|
}
|
|
|
|
networkIpv4Address = netConf.IPAMConfig?.IPv4Address || undefined;
|
|
networkIpv6Address = netConf.IPAMConfig?.IPv6Address || undefined;
|
|
macAddress = netConf.MacAddress || undefined;
|
|
networkGwPriority = netConf.GwPriority !== undefined && netConf.GwPriority !== 0
|
|
? netConf.GwPriority : undefined;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Device requests (GPU, etc.)
|
|
const deviceRequests = hostConfig.DeviceRequests?.length > 0
|
|
? hostConfig.DeviceRequests.map((dr: any) => ({
|
|
driver: dr.Driver || undefined,
|
|
count: dr.Count,
|
|
deviceIDs: dr.DeviceIDs?.length > 0 ? dr.DeviceIDs : undefined,
|
|
capabilities: dr.Capabilities?.length > 0 ? dr.Capabilities : undefined,
|
|
options: dr.Options && Object.keys(dr.Options).length > 0 ? dr.Options : undefined
|
|
}))
|
|
: undefined;
|
|
|
|
return {
|
|
name,
|
|
image: config.Image,
|
|
|
|
// Command and entrypoint
|
|
cmd: config.Cmd || undefined,
|
|
entrypoint: config.Entrypoint || undefined,
|
|
workingDir: config.WorkingDir || undefined,
|
|
|
|
// Environment and labels
|
|
env: config.Env || [],
|
|
labels: config.Labels || {},
|
|
|
|
// Port mappings
|
|
ports: Object.keys(ports).length > 0 ? ports : undefined,
|
|
|
|
// Volume bindings
|
|
volumeBinds: volumeBinds.length > 0 ? volumeBinds : undefined,
|
|
|
|
// Restart policy
|
|
restartPolicy: hostConfig.RestartPolicy?.Name || 'no',
|
|
restartMaxRetries: hostConfig.RestartPolicy?.MaximumRetryCount,
|
|
|
|
// Network settings
|
|
networkMode: hostConfig.NetworkMode || undefined,
|
|
networkAliases,
|
|
networkIpv4Address,
|
|
networkIpv6Address,
|
|
networkGwPriority,
|
|
|
|
// User and hostname
|
|
user: config.User || undefined,
|
|
hostname: config.Hostname || undefined,
|
|
domainname: config.Domainname || undefined,
|
|
|
|
// Privileged mode
|
|
privileged: hostConfig.Privileged || undefined,
|
|
|
|
// Healthcheck
|
|
healthcheck,
|
|
|
|
// Terminal settings
|
|
tty: config.Tty || undefined,
|
|
stdinOpen: config.OpenStdin || undefined,
|
|
|
|
// Memory limits
|
|
memory: hostConfig.Memory || undefined,
|
|
memoryReservation: hostConfig.MemoryReservation || undefined,
|
|
memorySwap: hostConfig.MemorySwap || undefined,
|
|
|
|
// CPU limits
|
|
cpuShares: hostConfig.CpuShares || undefined,
|
|
cpuQuota: hostConfig.CpuQuota || undefined,
|
|
cpuPeriod: hostConfig.CpuPeriod || undefined,
|
|
nanoCpus: hostConfig.NanoCpus || undefined,
|
|
|
|
// Capabilities
|
|
capAdd: hostConfig.CapAdd?.length > 0 ? hostConfig.CapAdd : undefined,
|
|
capDrop: hostConfig.CapDrop?.length > 0 ? hostConfig.CapDrop : undefined,
|
|
|
|
// Devices
|
|
devices: devices.length > 0 ? devices : undefined,
|
|
|
|
// DNS settings
|
|
dns: hostConfig.Dns?.length > 0 ? hostConfig.Dns : undefined,
|
|
dnsSearch: hostConfig.DnsSearch?.length > 0 ? hostConfig.DnsSearch : undefined,
|
|
dnsOptions: hostConfig.DnsOptions?.length > 0 ? hostConfig.DnsOptions : undefined,
|
|
|
|
// Security options
|
|
securityOpt: hostConfig.SecurityOpt?.length > 0 ? hostConfig.SecurityOpt : undefined,
|
|
|
|
// Ulimits
|
|
ulimits: ulimits.length > 0 ? ulimits : undefined,
|
|
|
|
// Process and memory settings
|
|
oomKillDisable: hostConfig.OomKillDisable || undefined,
|
|
pidsLimit: hostConfig.PidsLimit || undefined,
|
|
shmSize: hostConfig.ShmSize || undefined,
|
|
|
|
// Tmpfs mounts
|
|
tmpfs: hostConfig.Tmpfs && Object.keys(hostConfig.Tmpfs).length > 0 ? hostConfig.Tmpfs : undefined,
|
|
|
|
// Sysctls
|
|
sysctls: hostConfig.Sysctls && Object.keys(hostConfig.Sysctls).length > 0 ? hostConfig.Sysctls : undefined,
|
|
|
|
// Logging configuration
|
|
logDriver: hostConfig.LogConfig?.Type || undefined,
|
|
logOptions: hostConfig.LogConfig?.Config && Object.keys(hostConfig.LogConfig.Config).length > 0
|
|
? hostConfig.LogConfig.Config : undefined,
|
|
|
|
// Namespace settings
|
|
ipcMode: hostConfig.IpcMode || undefined,
|
|
pidMode: hostConfig.PidMode || undefined,
|
|
utsMode: hostConfig.UTSMode || undefined,
|
|
|
|
// Cgroup parent
|
|
cgroupParent: hostConfig.CgroupParent || undefined,
|
|
|
|
// Stop signal and timeout
|
|
stopSignal: config.StopSignal || undefined,
|
|
stopTimeout: config.StopTimeout || undefined,
|
|
|
|
// Init process
|
|
init: hostConfig.Init === true ? true : undefined,
|
|
|
|
// MAC address
|
|
macAddress,
|
|
|
|
// Extra hosts
|
|
extraHosts: hostConfig.ExtraHosts?.length > 0 ? hostConfig.ExtraHosts : undefined,
|
|
|
|
// Device requests (GPU)
|
|
deviceRequests,
|
|
|
|
// Container runtime
|
|
runtime: hostConfig.Runtime && hostConfig.Runtime !== 'runc' ? hostConfig.Runtime : undefined,
|
|
|
|
// Read-only root filesystem
|
|
readonlyRootfs: hostConfig.ReadonlyRootfs === true ? true : undefined,
|
|
|
|
// CPU pinning
|
|
cpusetCpus: hostConfig.CpusetCpus || undefined,
|
|
cpusetMems: hostConfig.CpusetMems || undefined,
|
|
|
|
// Additional groups
|
|
groupAdd: hostConfig.GroupAdd?.length > 0 ? hostConfig.GroupAdd : undefined,
|
|
|
|
// Memory swappiness
|
|
memorySwappiness: hostConfig.MemorySwappiness != null && hostConfig.MemorySwappiness !== -1 && hostConfig.MemorySwappiness !== 0 ? hostConfig.MemorySwappiness : undefined,
|
|
|
|
// User namespace mode
|
|
usernsMode: hostConfig.UsernsMode || undefined
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Update a container by recreating it with merged options.
|
|
* Preserves ALL existing container settings and merges user-provided options on top.
|
|
*/
|
|
export async function updateContainer(id: string, options: Partial<CreateContainerOptions>, startAfterUpdate = false, envId?: number | null) {
|
|
const oldContainerInfo = await inspectContainer(id, envId);
|
|
const wasRunning = oldContainerInfo.State.Running;
|
|
const name = oldContainerInfo.Name?.replace(/^\//, '') || '';
|
|
const oldContainerId = oldContainerInfo.Id;
|
|
const networks: Record<string, any> = oldContainerInfo.NetworkSettings?.Networks || {};
|
|
const hostConfig = oldContainerInfo.HostConfig || {};
|
|
const networkMode = hostConfig.NetworkMode || '';
|
|
const isSharedNetwork = networkMode.startsWith('container:') ||
|
|
networkMode.startsWith('service:') ||
|
|
networkMode === 'host' || networkMode === 'none';
|
|
|
|
// Extract ALL existing container options
|
|
const existingOptions = extractContainerOptions(oldContainerInfo);
|
|
|
|
// Per-network fields (aliases, static IPs, MAC, gateway priority) are scoped to
|
|
// a single network. When the user switches the primary network, the values
|
|
// extracted from the old network must NOT follow the container — e.g. compose
|
|
// service aliases from "anton" applied to the default bridge fail with
|
|
// "invalid endpoint settings" because the default bridge doesn't accept aliases.
|
|
if (options.networkMode && options.networkMode !== networkMode) {
|
|
existingOptions.networkAliases = undefined;
|
|
existingOptions.networkIpv4Address = undefined;
|
|
existingOptions.networkIpv6Address = undefined;
|
|
existingOptions.networkGwPriority = undefined;
|
|
existingOptions.macAddress = undefined;
|
|
}
|
|
|
|
// Merge user-provided options on top of existing options.
|
|
// Semantics (#1119):
|
|
// - key absent from `options` → preserve existingOptions[key]
|
|
// - key present with explicit `null` → CLEAR the field (treat as undefined)
|
|
// - key present with any other value → use the user's value
|
|
//
|
|
// Without the null-as-clear handling, clearing a Resources field in the UI
|
|
// (which sends null) would merge over the existing value identically to a
|
|
// preserved field — Memory limit, etc. would silently stick around.
|
|
const userOptions: Record<string, unknown> = {};
|
|
for (const [k, v] of Object.entries(options)) {
|
|
userOptions[k] = v === null ? undefined : v;
|
|
}
|
|
const mergedOptions: CreateContainerOptions = {
|
|
...existingOptions,
|
|
...userOptions,
|
|
// Replace labels, but preserve Docker internal labels (com.docker.*)
|
|
labels: options.labels !== undefined && options.labels !== null
|
|
? {
|
|
...Object.fromEntries(
|
|
Object.entries(existingOptions.labels || {}).filter(([k]) => k.startsWith('com.docker.'))
|
|
),
|
|
...options.labels
|
|
}
|
|
: options.labels === null ? {} : existingOptions.labels
|
|
};
|
|
|
|
// 1. Stop old container
|
|
if (wasRunning) {
|
|
await stopContainer(id, envId);
|
|
}
|
|
|
|
// 2. Rename old container to free the name (instead of removing — allows rollback)
|
|
await dockerFetch(
|
|
`/containers/${oldContainerId}/rename?name=${encodeURIComponent(name + '-old')}`,
|
|
{ method: 'POST' },
|
|
envId
|
|
).then(async r => { if (!r.ok) throw new Error('Failed to rename old container'); await drainResponse(r); });
|
|
|
|
// 3. Disconnect networks from old container to free static IPs
|
|
if (!isSharedNetwork) {
|
|
for (const [, netConfig] of Object.entries(networks)) {
|
|
const nc = netConfig as any;
|
|
if (nc.NetworkID) {
|
|
await disconnectContainerFromNetwork(nc.NetworkID, oldContainerId, true, envId).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rollback helper: restore old container on failure
|
|
const rollback = async () => {
|
|
try {
|
|
// Rename back
|
|
await dockerFetch(
|
|
`/containers/${oldContainerId}/rename?name=${encodeURIComponent(name)}`,
|
|
{ method: 'POST' },
|
|
envId
|
|
).then(r => drainResponse(r)).catch(() => {});
|
|
|
|
// Reconnect networks
|
|
if (!isSharedNetwork) {
|
|
for (const [, netConfig] of Object.entries(networks)) {
|
|
const nc = netConfig as any;
|
|
if (nc.NetworkID) {
|
|
await connectContainerToNetworkRaw(nc.NetworkID, oldContainerId, nc, envId).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restart if it was running
|
|
if (wasRunning) {
|
|
await startContainer(oldContainerId, envId).catch(() => {});
|
|
}
|
|
} catch {
|
|
// Rollback is best-effort
|
|
}
|
|
};
|
|
|
|
// 4. Create new container
|
|
let newContainer;
|
|
try {
|
|
newContainer = await createContainer(mergedOptions, envId);
|
|
} catch (createError) {
|
|
await rollback();
|
|
throw createError;
|
|
}
|
|
|
|
// 5. Start if needed
|
|
if (startAfterUpdate) {
|
|
try {
|
|
await newContainer.start();
|
|
} catch (startError) {
|
|
// Remove failed new container and restore old one
|
|
await removeContainer(newContainer.id, true, envId).catch(() => {});
|
|
await rollback();
|
|
throw startError;
|
|
}
|
|
}
|
|
|
|
// 6. Remove old container (success path only)
|
|
await removeContainer(oldContainerId, true, envId).catch(() => {});
|
|
|
|
return newContainer;
|
|
}
|
|
|
|
// Image operations
|
|
export async function listImages(envId?: number | null): Promise<ImageInfo[]> {
|
|
// Fetch images and containers in parallel
|
|
const [images, containers] = await Promise.all([
|
|
dockerJsonRequest<any[]>('/images/json', {}, envId),
|
|
dockerJsonRequest<any[]>('/containers/json?all=true', {}, envId).catch(() => [] as any[])
|
|
]);
|
|
|
|
// Build a map of imageId -> container count
|
|
// Docker may return -1 for Containers field on some hosts, so we compute it ourselves
|
|
const imageContainerCount = new Map<string, number>();
|
|
for (const container of containers) {
|
|
const imageId = container.ImageID || container.Image;
|
|
if (imageId) {
|
|
imageContainerCount.set(imageId, (imageContainerCount.get(imageId) || 0) + 1);
|
|
}
|
|
}
|
|
|
|
return images.map((image) => ({
|
|
id: image.Id,
|
|
repoTags: image.RepoTags || [],
|
|
tags: image.RepoTags || [],
|
|
repoDigests: image.RepoDigests || [],
|
|
size: image.Size,
|
|
virtualSize: image.VirtualSize || image.Size,
|
|
created: image.Created,
|
|
labels: image.Labels || {},
|
|
containers: imageContainerCount.get(image.Id) || 0
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Diagnostic log for registry auth headers (#1105).
|
|
* Logs only whether credentials were present and the target registry — no
|
|
* credential-derived material (lengths, byte values, or header bytes).
|
|
*/
|
|
function logAuthDiagnostics(
|
|
tag: string,
|
|
registry: string,
|
|
serveraddress: string,
|
|
username: string,
|
|
password: string,
|
|
authHeader: string
|
|
): void {
|
|
console.log(
|
|
`${tag} auth: registry=${registry} serveraddress=${serveraddress} ` +
|
|
`hasUser=${username.length > 0} hasPassword=${password.length > 0} hasAuthHeader=${authHeader.length > 0}`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Build X-Registry-Auth header for authenticated Docker image pulls.
|
|
* Looks up stored registry credentials and returns a headers object
|
|
* with the base64-encoded auth config, or an empty object if no credentials found.
|
|
*/
|
|
export async function buildRegistryAuthHeader(imageName: string): Promise<Record<string, string>> {
|
|
const headers: Record<string, string> = {};
|
|
try {
|
|
const { registry } = parseImageReference(imageName);
|
|
const creds = await findRegistryCredentials(registry);
|
|
if (creds) {
|
|
// Docker Engine requires 'https://index.docker.io/v1/' as serveraddress
|
|
// for Docker Hub auth — just the hostname is treated as unauthenticated
|
|
const isHub = DOCKER_HUB_HOSTS.has(registry);
|
|
const serveraddress = isHub ? 'https://index.docker.io/v1/' : registry;
|
|
if (isHub) {
|
|
console.log(`[Registry] docker-hub variant '${registry}' canonicalized to https://index.docker.io/v1/ for auth`);
|
|
}
|
|
const authConfig = {
|
|
username: creds.username,
|
|
password: creds.password,
|
|
serveraddress
|
|
};
|
|
const authHeader = encodeRegistryAuth(authConfig);
|
|
headers['X-Registry-Auth'] = authHeader;
|
|
logAuthDiagnostics('[Pull]', registry, serveraddress, creds.username, creds.password, authHeader);
|
|
} else {
|
|
console.log(`[Pull] No credentials found for ${registry}`);
|
|
}
|
|
} catch (e) {
|
|
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
console.error(`[Pull] Failed to lookup credentials:`, errorMsg);
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
export async function pullImage(imageName: string, onProgress?: (data: any) => void, envId?: number | null) {
|
|
// Parse image name and tag to avoid pulling all tags
|
|
// Docker API: if tag is empty, it pulls ALL tags for the image
|
|
// Format can be: repo:tag, repo@digest, or just repo (defaults to :latest)
|
|
let fromImage = imageName;
|
|
let tag = 'latest';
|
|
|
|
if (imageName.includes('@')) {
|
|
// Image with digest: repo@sha256:abc123
|
|
// Don't split, pass as-is (digest is part of fromImage)
|
|
fromImage = imageName;
|
|
tag = ''; // Empty tag when using digest
|
|
} else if (imageName.includes(':')) {
|
|
// Image with tag: repo:tag or registry.example.com/repo:tag
|
|
const lastColonIndex = imageName.lastIndexOf(':');
|
|
const potentialTag = imageName.substring(lastColonIndex + 1);
|
|
// Make sure we're not splitting on a port number (e.g., registry.example.com:5000/repo)
|
|
// Tags don't contain slashes, but registry ports are followed by a path
|
|
if (!potentialTag.includes('/')) {
|
|
fromImage = imageName.substring(0, lastColonIndex);
|
|
tag = potentialTag;
|
|
}
|
|
}
|
|
|
|
// Build URL with explicit tag parameter to prevent pulling all tags
|
|
const url = tag
|
|
? `/images/create?fromImage=${encodeURIComponent(fromImage)}&tag=${encodeURIComponent(tag)}`
|
|
: `/images/create?fromImage=${encodeURIComponent(fromImage)}`;
|
|
|
|
// Look up registry credentials for authenticated pulls
|
|
const headers = await buildRegistryAuthHeader(imageName);
|
|
|
|
// Diagnostic logging (#1105): what we're sending to the daemon
|
|
console.log(`[Pull] POST ${url} headers=${Object.keys(headers).join(',') || '(none)'}`);
|
|
|
|
// Use streaming: true for longer timeout on edge environments
|
|
const response = await dockerFetch(url, { method: 'POST', streaming: true, headers }, envId);
|
|
|
|
// Diagnostic logging (#1105): daemon response status
|
|
console.log(`[Pull] response status=${response.status} ${response.statusText}`);
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text();
|
|
console.error(`[Pull] error body: ${body}`);
|
|
throw new Error(`Failed to pull image: ${body}`);
|
|
}
|
|
|
|
// Stream the response for progress updates
|
|
const reader = response.body?.getReader();
|
|
if (!reader) return;
|
|
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
// Docker streams pull failures as {"error":...} lines inside a 200 response.
|
|
// Capture the last one and throw after the stream ends so callers see the
|
|
// failure (and onProgress still fires for the error line).
|
|
let streamError: string | null = null;
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() || '';
|
|
|
|
for (const line of lines) {
|
|
if (line.trim()) {
|
|
try {
|
|
const data = JSON.parse(line);
|
|
if (data.error || data.errorDetail) {
|
|
console.error(`[Pull] stream error: ${line}`);
|
|
streamError = data.errorDetail?.message || data.error || 'Image pull failed';
|
|
}
|
|
if (onProgress) onProgress(data);
|
|
} catch {
|
|
// Ignore parse errors
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (streamError) {
|
|
throw new Error(`Failed to pull image: ${streamError}`);
|
|
}
|
|
}
|
|
|
|
export async function removeImage(id: string, force = false, envId?: number | null) {
|
|
const response = await dockerFetch(`/images/${encodeURIComponent(id)}?force=${force}`, { method: 'DELETE' }, envId);
|
|
if (!response.ok) {
|
|
const data = await response.json().catch(() => ({}));
|
|
const error: any = new Error(data.message || 'Failed to remove image');
|
|
error.statusCode = response.status;
|
|
error.json = data;
|
|
throw error;
|
|
}
|
|
await drainResponse(response);
|
|
}
|
|
|
|
export async function getImageHistory(id: string, envId?: number | null) {
|
|
return dockerJsonRequest(`/images/${encodeURIComponent(id)}/history`, {}, envId);
|
|
}
|
|
|
|
export async function inspectImage(id: string, envId?: number | null) {
|
|
return dockerJsonRequest(`/images/${encodeURIComponent(id)}/json`, {}, envId);
|
|
}
|
|
|
|
/**
|
|
* Parse an image reference into registry, repository, and tag components.
|
|
* Follows Docker's reference parsing rules.
|
|
* Examples:
|
|
* nginx:latest -> { registry: 'index.docker.io', repo: 'library/nginx', tag: 'latest' }
|
|
* ghcr.io/user/image:v1 -> { registry: 'ghcr.io', repo: 'user/image', tag: 'v1' }
|
|
* registry.example.com:5000/repo:tag -> { registry: 'registry.example.com:5000', repo: 'repo', tag: 'tag' }
|
|
*/
|
|
function parseImageReference(imageName: string): { registry: string; repo: string; tag: string } {
|
|
let registry = 'index.docker.io'; // Docker Hub's actual host
|
|
let repo = imageName;
|
|
let tag = 'latest';
|
|
|
|
// Handle digest references (remove digest part for manifest lookup)
|
|
if (repo.includes('@')) {
|
|
const [repoWithoutDigest] = repo.split('@');
|
|
repo = repoWithoutDigest;
|
|
}
|
|
|
|
// Extract tag
|
|
const lastColon = repo.lastIndexOf(':');
|
|
if (lastColon > -1) {
|
|
const potentialTag = repo.substring(lastColon + 1);
|
|
// Make sure it's not a port number (no slashes in tags)
|
|
if (!potentialTag.includes('/')) {
|
|
tag = potentialTag;
|
|
repo = repo.substring(0, lastColon);
|
|
}
|
|
}
|
|
|
|
// Extract registry if present
|
|
const firstSlash = repo.indexOf('/');
|
|
if (firstSlash > -1) {
|
|
const firstPart = repo.substring(0, firstSlash);
|
|
// If the first part contains a dot, colon, or is "localhost", it's a registry
|
|
if (firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost') {
|
|
registry = firstPart;
|
|
repo = repo.substring(firstSlash + 1);
|
|
}
|
|
}
|
|
|
|
// Normalize docker.io to index.docker.io (Docker Hub's actual registry host)
|
|
// docker.io redirects to www.docker.com, while index.docker.io is the real API
|
|
if (registry === 'docker.io') {
|
|
registry = 'index.docker.io';
|
|
}
|
|
|
|
// Docker Hub requires library/ prefix for official images
|
|
if (registry === 'index.docker.io' && !repo.includes('/')) {
|
|
repo = `library/${repo}`;
|
|
}
|
|
|
|
return { registry, repo, tag };
|
|
}
|
|
|
|
/**
|
|
* Parse a registry URL into host and path components.
|
|
* Handles URLs with or without protocol, and preserves organization paths.
|
|
*
|
|
* Examples:
|
|
* 'https://registry.example.com/org' -> { host: 'registry.example.com', path: '/org', fullRegistry: 'registry.example.com/org' }
|
|
* 'ghcr.io' -> { host: 'ghcr.io', path: '', fullRegistry: 'ghcr.io' }
|
|
* 'registry.example.com:5000/myorg' -> { host: 'registry.example.com:5000', path: '/myorg', fullRegistry: 'registry.example.com:5000/myorg' }
|
|
*/
|
|
export function parseRegistryUrl(url: string): { host: string; path: string; fullRegistry: string; protocol: string } {
|
|
// Detect protocol (default to https)
|
|
const protocol = url.startsWith('http://') ? 'http' : 'https';
|
|
// Remove protocol
|
|
const withoutProtocol = url.replace(/^https?:\/\//, '');
|
|
// Remove trailing slash
|
|
const trimmed = withoutProtocol.replace(/\/$/, '');
|
|
// Split on first slash (after port if present)
|
|
const slashIndex = trimmed.indexOf('/');
|
|
if (slashIndex === -1) {
|
|
return { host: trimmed, path: '', fullRegistry: trimmed, protocol };
|
|
}
|
|
const host = trimmed.substring(0, slashIndex);
|
|
const path = trimmed.substring(slashIndex); // includes leading /
|
|
return { host, path, fullRegistry: trimmed, protocol };
|
|
}
|
|
|
|
/**
|
|
* Find registry credentials from Dockhand's stored registries.
|
|
* Matches by registry URL including organization path if present.
|
|
*
|
|
* Matching logic:
|
|
* - Full match: stored 'registry.example.com/org' matches requested 'registry.example.com/org'
|
|
* - Host-only stored: stored 'registry.example.com' matches requested 'registry.example.com/org'
|
|
* (allows a single credential entry to work for all org paths)
|
|
*/
|
|
async function findRegistryCredentials(registryHost: string): Promise<{ username: string; password: string } | null> {
|
|
try {
|
|
// Import here to avoid circular dependency
|
|
const { getRegistries } = await import('./db.js');
|
|
const registries = await getRegistries();
|
|
|
|
const requested = parseRegistryUrl(registryHost);
|
|
|
|
for (const reg of registries) {
|
|
const stored = parseRegistryUrl(reg.url);
|
|
|
|
// Match if:
|
|
// 1. Full registry paths match exactly, OR
|
|
// 2. Hosts match and stored registry has no path (applies to any org)
|
|
if (stored.fullRegistry === requested.fullRegistry ||
|
|
(stored.host === requested.host && !stored.path)) {
|
|
if (reg.username && reg.password) {
|
|
const via = stored.fullRegistry === requested.fullRegistry ? 'full' : 'host-only';
|
|
console.log(`[Registry] matched stored=${reg.url} requested=${registryHost} via=${via}`);
|
|
// Normalize legacy creds saved before #1105 trim fix
|
|
return { username: reg.username.trim(), password: reg.password.trim() };
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bidirectional Docker Hub alias matching:
|
|
// If the requested host is any Docker Hub variant, match against any stored Docker Hub variant
|
|
if (DOCKER_HUB_HOSTS.has(requested.host)) {
|
|
for (const reg of registries) {
|
|
const stored = parseRegistryUrl(reg.url);
|
|
if (DOCKER_HUB_HOSTS.has(stored.host)) {
|
|
if (reg.username && reg.password) {
|
|
console.log(`[Registry] matched stored=${reg.url} requested=${registryHost} via=hub-alias`);
|
|
return { username: reg.username.trim(), password: reg.password.trim() };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// No stored credentials matched. This is NOT an error for public images —
|
|
// anonymous access still works; it just means no auth header is attached.
|
|
// (A Docker Hub entry configured without a username/password is anonymous,
|
|
// so it logs here even though hub-alias matched the host — see #1255.)
|
|
const candidates = registries.map(r => parseRegistryUrl(r.url).host).join(', ');
|
|
console.log(
|
|
`[Registry] no stored credentials for requested=${registryHost} ` +
|
|
`(hub-alias=${DOCKER_HUB_HOSTS.has(requested.host)}); using anonymous access; ` +
|
|
`candidates=[${candidates || 'none configured'}]`
|
|
);
|
|
return null;
|
|
} catch (e) {
|
|
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
console.error('[Registry] Failed to lookup credentials:', errorMsg);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get bearer token from registry using challenge-response flow.
|
|
* This follows the Docker Registry v2 authentication spec:
|
|
* 1. Make request to /v2/ to get WWW-Authenticate challenge
|
|
* 2. Parse realm, service, scope from challenge
|
|
* 3. Request token from realm URL (with credentials if available)
|
|
*/
|
|
async function getRegistryBearerToken(registry: string, repo: string): Promise<string | null> {
|
|
try {
|
|
const registryUrl = `https://${registry}`;
|
|
|
|
// Look up stored credentials for this registry
|
|
const credentials = await findRegistryCredentials(registry);
|
|
|
|
// Step 1: Challenge request to /v2/
|
|
const challengeResponse = await fetch(`${registryUrl}/v2/`, {
|
|
method: 'GET',
|
|
headers: { 'User-Agent': 'Dockhand/1.0' }
|
|
});
|
|
|
|
// If 200, no auth needed
|
|
if (challengeResponse.ok) {
|
|
await drainResponse(challengeResponse);
|
|
return null;
|
|
}
|
|
|
|
// If not 401, something else is wrong
|
|
if (challengeResponse.status !== 401) {
|
|
await drainResponse(challengeResponse);
|
|
console.error(`Registry challenge failed: ${challengeResponse.status}`);
|
|
return null;
|
|
}
|
|
|
|
// Step 2: Parse WWW-Authenticate header
|
|
const wwwAuth = challengeResponse.headers.get('WWW-Authenticate') || '';
|
|
const challenge = wwwAuth.toLowerCase();
|
|
|
|
if (challenge.startsWith('basic')) {
|
|
// Basic auth - use credentials if we have them
|
|
await drainResponse(challengeResponse);
|
|
if (credentials) {
|
|
const basicAuth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64');
|
|
return `Basic ${basicAuth}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (!challenge.startsWith('bearer')) {
|
|
await drainResponse(challengeResponse);
|
|
console.error(`Unsupported auth type: ${wwwAuth}`);
|
|
return null;
|
|
}
|
|
|
|
// Drain 401 response body before bearer token fetch (required by Node.js/Undici for connection reuse)
|
|
await drainResponse(challengeResponse);
|
|
|
|
// Parse bearer challenge: Bearer realm="...",service="...",scope="..."
|
|
const realmMatch = wwwAuth.match(/realm="([^"]+)"/i);
|
|
const serviceMatch = wwwAuth.match(/service="([^"]+)"/i);
|
|
|
|
if (!realmMatch) {
|
|
console.error('No realm in WWW-Authenticate header');
|
|
return null;
|
|
}
|
|
|
|
const realm = realmMatch[1];
|
|
const service = serviceMatch ? serviceMatch[1] : '';
|
|
const scope = `repository:${repo}:pull`;
|
|
|
|
// Step 3: Request token from realm (with credentials if available).
|
|
// Empty scope is allowed — means "no specific resource permission".
|
|
// Useful for credential validation: some registries (Docker Hub)
|
|
// reject privileged scopes like registry:catalog:* even for valid
|
|
// users, so omitting scope is the only reliable login check.
|
|
const tokenUrl = new URL(realm);
|
|
if (service) tokenUrl.searchParams.set('service', service);
|
|
if (scope) tokenUrl.searchParams.set('scope', scope);
|
|
|
|
const tokenHeaders: Record<string, string> = { 'User-Agent': 'Dockhand/1.0' };
|
|
|
|
// Add Basic auth header if we have credentials
|
|
if (credentials) {
|
|
const basicAuth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64');
|
|
tokenHeaders['Authorization'] = `Basic ${basicAuth}`;
|
|
}
|
|
|
|
const tokenResponse = await fetch(tokenUrl.toString(), {
|
|
headers: tokenHeaders
|
|
});
|
|
|
|
if (!tokenResponse.ok) {
|
|
await tokenResponse.text(); // Consume body to release socket
|
|
console.error(`Token request failed: ${tokenResponse.status}`);
|
|
return null;
|
|
}
|
|
|
|
const tokenData = await tokenResponse.json() as { token?: string; access_token?: string };
|
|
const token = tokenData.token || tokenData.access_token || null;
|
|
|
|
return token ? `Bearer ${token}` : null;
|
|
|
|
} catch (e) {
|
|
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
const cause = (e as any)?.cause;
|
|
const causeMsg = cause ? ` (cause: ${cause})` : '';
|
|
console.error('[Registry] Failed to get bearer token:', errorMsg + causeMsg);
|
|
const causeStr = String(cause ?? errorMsg);
|
|
if (causeStr.includes('EAI_AGAIN') || causeStr.includes('ENOTFOUND')) {
|
|
console.error('[Registry] DNS resolution failed. If you are on a NAS (Synology, uGreen, QNAP), try adding --dns=8.8.8.8 to your docker run command or set {"dns": ["8.8.8.8"]} in /etc/docker/daemon.json');
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get authentication header for registry API requests.
|
|
* Handles the Docker Registry V2 OAuth2 token flow:
|
|
* 1. Challenge request to /v2/ to get WWW-Authenticate header
|
|
* 2. Parse realm, service from challenge
|
|
* 3. Request token from realm with credentials (if available)
|
|
*
|
|
* @param registryUrl - Full registry URL (e.g., 'https://ghcr.io' or 'https://registry.example.com/org')
|
|
* @param scope - Token scope (e.g., 'registry:catalog:*' or 'repository:user/repo:pull')
|
|
* @param credentials - Optional credentials { username, password }
|
|
* @returns Authorization header value (e.g., 'Bearer xxx' or 'Basic xxx') or null
|
|
*/
|
|
export async function getRegistryAuthHeader(
|
|
registryUrl: string,
|
|
scope: string,
|
|
credentials?: { username: string; password: string } | null
|
|
): Promise<string | null> {
|
|
try {
|
|
// Parse URL to extract host (V2 API is always at the host root)
|
|
const parsed = parseRegistryUrl(registryUrl);
|
|
const apiBaseUrl = `${parsed.protocol}://${parsed.host}`;
|
|
|
|
// Step 1: Challenge request to /v2/ (always at registry root, not under org path)
|
|
const challengeResponse = await fetch(`${apiBaseUrl}/v2/`, {
|
|
method: 'GET',
|
|
headers: { 'User-Agent': 'Dockhand/1.0' }
|
|
});
|
|
|
|
// If 200, no auth needed
|
|
if (challengeResponse.ok) {
|
|
await drainResponse(challengeResponse);
|
|
return null;
|
|
}
|
|
|
|
// If not 401, something else is wrong
|
|
if (challengeResponse.status !== 401) {
|
|
await drainResponse(challengeResponse);
|
|
console.error(`Registry challenge failed: ${challengeResponse.status}`);
|
|
return null;
|
|
}
|
|
|
|
// Step 2: Parse WWW-Authenticate header
|
|
const wwwAuth = challengeResponse.headers.get('WWW-Authenticate') || '';
|
|
const challenge = wwwAuth.toLowerCase();
|
|
|
|
if (challenge.startsWith('basic')) {
|
|
// Basic auth - use credentials if we have them
|
|
await drainResponse(challengeResponse);
|
|
if (credentials) {
|
|
const basicAuth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64');
|
|
return `Basic ${basicAuth}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (!challenge.startsWith('bearer')) {
|
|
await drainResponse(challengeResponse);
|
|
console.error(`Unsupported auth type: ${wwwAuth}`);
|
|
return null;
|
|
}
|
|
|
|
// Drain 401 response body before bearer token fetch (required by Node.js/Undici for connection reuse)
|
|
await drainResponse(challengeResponse);
|
|
|
|
// Parse bearer challenge: Bearer realm="...",service="...",scope="..."
|
|
const realmMatch = wwwAuth.match(/realm="([^"]+)"/i);
|
|
const serviceMatch = wwwAuth.match(/service="([^"]+)"/i);
|
|
|
|
if (!realmMatch) {
|
|
console.error('No realm in WWW-Authenticate header');
|
|
return null;
|
|
}
|
|
|
|
const realm = realmMatch[1];
|
|
const service = serviceMatch ? serviceMatch[1] : '';
|
|
|
|
// Step 3: Request token from realm (with credentials if available).
|
|
// Empty scope is allowed — means "no specific resource permission".
|
|
// Useful for credential validation: some registries (Docker Hub)
|
|
// reject privileged scopes like registry:catalog:* even for valid
|
|
// users, so omitting scope is the only reliable login check.
|
|
const tokenUrl = new URL(realm);
|
|
if (service) tokenUrl.searchParams.set('service', service);
|
|
if (scope) tokenUrl.searchParams.set('scope', scope);
|
|
|
|
const tokenHeaders: Record<string, string> = { 'User-Agent': 'Dockhand/1.0' };
|
|
|
|
// Add Basic auth header if we have credentials
|
|
if (credentials) {
|
|
const basicAuth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64');
|
|
tokenHeaders['Authorization'] = `Basic ${basicAuth}`;
|
|
}
|
|
|
|
const tokenResponse = await fetch(tokenUrl.toString(), {
|
|
headers: tokenHeaders
|
|
});
|
|
|
|
if (!tokenResponse.ok) {
|
|
const errorBody = await tokenResponse.text().catch(() => '');
|
|
console.error(`Token request failed: ${tokenResponse.status} - ${errorBody}`);
|
|
return null;
|
|
}
|
|
|
|
const tokenData = await tokenResponse.json() as { token?: string; access_token?: string };
|
|
const token = tokenData.token || tokenData.access_token || null;
|
|
|
|
return token ? `Bearer ${token}` : null;
|
|
|
|
} catch (e) {
|
|
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
const cause = (e as any)?.cause;
|
|
const causeMsg = cause ? ` (cause: ${cause})` : '';
|
|
console.error('[Registry] Failed to get auth header:', errorMsg + causeMsg);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper to get normalized registry URL and auth header for registry API requests.
|
|
* Combines URL normalization, credential extraction, and token flow in one call.
|
|
*
|
|
* @param registry - Registry object from database
|
|
* @param scope - Token scope (e.g., 'registry:catalog:*' or 'repository:user/repo:pull')
|
|
* @returns { baseUrl, orgPath, authHeader } - Base URL (host only for V2 API), org path, and auth header
|
|
*/
|
|
export async function getRegistryAuth(
|
|
registry: { url: string; username?: string | null; password?: string | null },
|
|
scope: string
|
|
): Promise<{ baseUrl: string; orgPath: string; authHeader: string | null }> {
|
|
// Parse registry URL to extract host and organization path
|
|
const parsed = parseRegistryUrl(registry.url);
|
|
|
|
// V2 API endpoints are always at the registry host root
|
|
const baseUrl = `${parsed.protocol}://${parsed.host}`;
|
|
|
|
// Get auth header using proper token flow
|
|
const credentials = registry.username && registry.password
|
|
? { username: registry.username, password: registry.password }
|
|
: null;
|
|
|
|
const authHeader = await getRegistryAuthHeader(registry.url, scope, credentials);
|
|
|
|
return { baseUrl, orgPath: parsed.path, authHeader };
|
|
}
|
|
|
|
// Catalog-failure classification lives in an import-light module so it's unit-testable
|
|
// without pulling this file's better-sqlite3 imports into the test process (#873).
|
|
export { classifyCatalogFailure, CATALOG_NOT_SUPPORTED_MSG } from './registry-catalog.js';
|
|
|
|
// --- Harbor fallback for catalog and image search ---
|
|
// Harbor denies access to the V2 _catalog endpoint for robot accounts.
|
|
// We detect Harbor and use the native project API as a fallback.
|
|
|
|
/** Harbor detection cache per host (TTL 5 min). Only definitive (non-error) detections are cached. */
|
|
const harborDetectionCache = new Map<string, { isHarbor: boolean; ts: number }>();
|
|
const HARBOR_CACHE_TTL = 5 * 60 * 1000;
|
|
|
|
export interface HarborCatalogResult {
|
|
repositories: string[];
|
|
/** Pagination cursor: "harbor:<page>" or null if last page */
|
|
nextLast: string | null;
|
|
}
|
|
|
|
/**
|
|
* Detects whether a registry is a Harbor instance.
|
|
* Checks for service="harbor-registry" in the WWW-Authenticate header from /v2/,
|
|
* then confirms via /api/v2.0/ping. Result is cached for 5 min per host.
|
|
*/
|
|
export async function isHarborRegistry(registryUrl: string): Promise<boolean> {
|
|
const parsed = parseRegistryUrl(registryUrl);
|
|
const host = parsed.host;
|
|
|
|
const cached = harborDetectionCache.get(host);
|
|
if (cached && Date.now() - cached.ts < HARBOR_CACHE_TTL) {
|
|
return cached.isHarbor;
|
|
}
|
|
|
|
// Respect the registry's configured scheme and port (parsed.host includes the port).
|
|
const baseUrl = `${parsed.protocol}://${parsed.host}`;
|
|
|
|
let isHarbor = false;
|
|
try {
|
|
// Step 1: check the WWW-Authenticate header from /v2/
|
|
const challengeResp = await fetch(`${baseUrl}/v2/`, {
|
|
method: 'GET',
|
|
headers: { 'User-Agent': 'Dockhand/1.0' }
|
|
});
|
|
const wwwAuth = challengeResp.headers.get('WWW-Authenticate') || '';
|
|
if (wwwAuth.toLowerCase().includes('service="harbor-registry"')) {
|
|
// Step 2: confirm via /api/v2.0/ping
|
|
const pingResp = await fetch(`${baseUrl}/api/v2.0/ping`, {
|
|
method: 'GET',
|
|
headers: { 'User-Agent': 'Dockhand/1.0' }
|
|
});
|
|
if (pingResp.ok) {
|
|
const body = await pingResp.text();
|
|
if (body.includes('Pong')) {
|
|
isHarbor = true;
|
|
}
|
|
}
|
|
}
|
|
// A definitive answer was obtained (no network error): cache it.
|
|
harborDetectionCache.set(host, { isHarbor, ts: Date.now() });
|
|
} catch {
|
|
// Network error: detection is indeterminate. Do NOT cache, so a transient
|
|
// outage can't pin "not Harbor" for the whole TTL and keep returning 403s
|
|
// once Harbor recovers.
|
|
}
|
|
|
|
return isHarbor;
|
|
}
|
|
|
|
/**
|
|
* Builds the Basic auth header for the Harbor API from a registry object.
|
|
* Credentials are trimmed: pasted values often carry trailing whitespace that
|
|
* silently breaks Basic auth.
|
|
*/
|
|
function getHarborBasicAuth(registry: { username?: string | null; password?: string | null }): string | null {
|
|
const username = registry.username?.trim();
|
|
const password = registry.password?.trim();
|
|
if (username && password) {
|
|
return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Builds the common headers (JSON + Basic auth) for Harbor API requests. */
|
|
function harborHeaders(registry: { username?: string | null; password?: string | null }): Record<string, string> {
|
|
const headers: Record<string, string> = {
|
|
'Accept': 'application/json',
|
|
'User-Agent': 'Dockhand/1.0'
|
|
};
|
|
const authHeader = getHarborBasicAuth(registry);
|
|
if (authHeader) headers['Authorization'] = authHeader;
|
|
return headers;
|
|
}
|
|
|
|
/** Removes Harbor query-grammar control characters so a search term can't break or alter the q filter. */
|
|
function sanitizeHarborQueryTerm(term: string): string {
|
|
// Harbor's query grammar uses , = ~ ( ) as control characters.
|
|
return term.replace(/[,=~()]/g, '');
|
|
}
|
|
|
|
/**
|
|
* Follows Harbor list-endpoint pagination, collecting every item's name.
|
|
* Terminates on a short page or once X-Total-Count is reached; a safety cap
|
|
* bounds pathological servers (a warning is logged if it is hit).
|
|
*/
|
|
async function harborPaginateNames(
|
|
urlForPage: (page: number, pageSize: number) => string,
|
|
headers: Record<string, string>,
|
|
options: { throwOnFirstError: boolean; label: string }
|
|
): Promise<string[]> {
|
|
const names: string[] = [];
|
|
const pageSize = 100;
|
|
const maxPages = 100; // ~10k items: defensive bound against a server that never returns a short page
|
|
let total = 0;
|
|
let page = 1;
|
|
while (page <= maxPages) {
|
|
const resp = await fetch(urlForPage(page, pageSize), { headers });
|
|
if (!resp.ok) {
|
|
if (page === 1 && options.throwOnFirstError) {
|
|
throw new Error(`Harbor API error ${resp.status} while ${options.label}`);
|
|
}
|
|
break;
|
|
}
|
|
if (page === 1) total = parseInt(resp.headers.get('X-Total-Count') || '0', 10);
|
|
const items: Array<{ name: string }> = await resp.json();
|
|
for (const item of items) names.push(item.name);
|
|
if (items.length < pageSize) break;
|
|
if (total > 0 && names.length >= total) break;
|
|
page++;
|
|
}
|
|
if (page > maxPages) {
|
|
console.warn(`[Harbor] Pagination safety cap (${maxPages} pages) reached while ${options.label}; the list may be truncated`);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
/** Enumerates the names of all Harbor projects the account can access. */
|
|
async function harborListAllProjects(baseUrl: string, headers: Record<string, string>): Promise<string[]> {
|
|
return harborPaginateNames(
|
|
(page, size) => `${baseUrl}/projects?page=${page}&page_size=${size}`,
|
|
headers,
|
|
{ throwOnFirstError: true, label: 'listing projects' }
|
|
);
|
|
}
|
|
|
|
/** Enumerates the names of all repositories within a single Harbor project. */
|
|
async function harborListProjectRepositories(baseUrl: string, project: string, headers: Record<string, string>): Promise<string[]> {
|
|
return harborPaginateNames(
|
|
(page, size) => `${baseUrl}/projects/${encodeURIComponent(project)}/repositories?page=${page}&page_size=${size}`,
|
|
headers,
|
|
{ throwOnFirstError: false, label: `listing repositories of project ${project}` }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Lists repositories via the Harbor project API.
|
|
* With orgPath set, paginates a single project natively (using X-Total-Count).
|
|
* Without orgPath, enumerates every accessible project and all of their
|
|
* repositories, then paginates the flattened list so the UI gets a correct
|
|
* hasMore signal instead of a silently truncated first page.
|
|
* @param page - page number (1-based)
|
|
* @param pageSize - number of results per page
|
|
*/
|
|
export async function harborListRepositories(
|
|
registry: { url: string; username?: string | null; password?: string | null },
|
|
orgPath: string,
|
|
page: number = 1,
|
|
pageSize: number = 100
|
|
): Promise<HarborCatalogResult> {
|
|
const parsed = parseRegistryUrl(registry.url);
|
|
const baseUrl = `${parsed.protocol}://${parsed.host}/api/v2.0`;
|
|
const headers = harborHeaders(registry);
|
|
|
|
if (orgPath) {
|
|
// Single project: native page-based pagination.
|
|
const project = orgPath.replace(/^\//, '');
|
|
const url = `${baseUrl}/projects/${encodeURIComponent(project)}/repositories?page=${page}&page_size=${pageSize}`;
|
|
const resp = await fetch(url, { headers });
|
|
if (!resp.ok) {
|
|
throw new Error(`Harbor API error ${resp.status} for project ${project}`);
|
|
}
|
|
const totalCount = parseInt(resp.headers.get('X-Total-Count') || '0', 10);
|
|
const repos: Array<{ name: string }> = await resp.json();
|
|
const repositories = repos.map(r => r.name);
|
|
const hasMore = page * pageSize < totalCount;
|
|
return { repositories, nextLast: hasMore ? `harbor:${page + 1}` : null };
|
|
}
|
|
|
|
// No orgPath: enumerate all projects and all of their repositories, then
|
|
// slice the flattened list for the requested page.
|
|
const projects = await harborListAllProjects(baseUrl, headers);
|
|
const all: string[] = [];
|
|
for (const project of projects) {
|
|
const repos = await harborListProjectRepositories(baseUrl, project, headers);
|
|
for (const name of repos) all.push(name);
|
|
}
|
|
|
|
const start = (page - 1) * pageSize;
|
|
const repositories = all.slice(start, start + pageSize);
|
|
const hasMore = start + pageSize < all.length;
|
|
return { repositories, nextLast: hasMore ? `harbor:${page + 1}` : null };
|
|
}
|
|
|
|
/**
|
|
* Searches repositories via the Harbor API using filter q=name=~{term}.
|
|
* Iterates through all accessible projects (or a single one if orgPath is set).
|
|
* The term is sanitized for the Harbor query grammar; a client-side substring
|
|
* check on the original term keeps the results precise.
|
|
*/
|
|
export async function harborSearchRepositories(
|
|
registry: { url: string; username?: string | null; password?: string | null },
|
|
term: string,
|
|
orgPath: string,
|
|
limit: number = 25
|
|
): Promise<string[]> {
|
|
const parsed = parseRegistryUrl(registry.url);
|
|
const baseUrl = `${parsed.protocol}://${parsed.host}/api/v2.0`;
|
|
const headers = harborHeaders(registry);
|
|
|
|
const termLower = term.toLowerCase();
|
|
const safeTerm = sanitizeHarborQueryTerm(term);
|
|
const results: string[] = [];
|
|
|
|
const projectNames = orgPath
|
|
? [orgPath.replace(/^\//, '')]
|
|
: await harborListAllProjects(baseUrl, headers);
|
|
|
|
// Search each project using the Harbor filter
|
|
for (const project of projectNames) {
|
|
if (results.length >= limit) break;
|
|
|
|
const q = encodeURIComponent(`name=~${safeTerm}`);
|
|
const url = `${baseUrl}/projects/${encodeURIComponent(project)}/repositories?q=${q}&page=1&page_size=${limit}`;
|
|
const resp = await fetch(url, { headers });
|
|
if (!resp.ok) continue;
|
|
|
|
const repos: Array<{ name: string }> = await resp.json();
|
|
for (const r of repos) {
|
|
// The server filter ran on the sanitized term, so re-check the original
|
|
// term client-side to guarantee precise substring matches.
|
|
if (r.name.toLowerCase().includes(termLower)) {
|
|
results.push(r.name);
|
|
if (results.length >= limit) break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Check the registry for the current manifest digest of an image.
|
|
* Simple HEAD request to get Docker-Content-Digest header.
|
|
* Docker stores the manifest list digest in RepoDigests, so we compare that directly.
|
|
*/
|
|
export async function getRegistryManifestDigest(imageName: string): Promise<string | null> {
|
|
try {
|
|
const { registry, repo, tag } = parseImageReference(imageName);
|
|
const token = await getRegistryBearerToken(registry, repo);
|
|
const manifestUrl = `https://${registry}/v2/${repo}/manifests/${tag}`;
|
|
|
|
const headers: Record<string, string> = {
|
|
'User-Agent': 'Dockhand/1.0',
|
|
'Accept': [
|
|
'application/vnd.docker.distribution.manifest.list.v2+json',
|
|
'application/vnd.oci.image.index.v1+json',
|
|
'application/vnd.docker.distribution.manifest.v2+json',
|
|
'application/vnd.oci.image.manifest.v1+json'
|
|
].join(', ')
|
|
};
|
|
if (token) headers['Authorization'] = token;
|
|
|
|
const response = await fetch(manifestUrl, { method: 'HEAD', headers });
|
|
|
|
if (!response.ok) {
|
|
await drainResponse(response);
|
|
if (response.status === 429) {
|
|
const retryAfter = response.headers.get('Retry-After');
|
|
console.warn(`[Registry] ${imageName}: rate limited (429)${retryAfter ? `, retry after ${retryAfter}s` : ''}`);
|
|
} else {
|
|
console.error(`[Registry] ${imageName}: ${response.status}`);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const digest = response.headers.get('Docker-Content-Digest');
|
|
await drainResponse(response);
|
|
return digest;
|
|
} catch (e) {
|
|
const causeStr = String((e as any)?.cause ?? e);
|
|
if (causeStr.includes('EAI_AGAIN') || causeStr.includes('ENOTFOUND')) {
|
|
console.error(`[Registry] ${imageName}: DNS resolution failed. If you are on a NAS (Synology, uGreen, QNAP), add --dns=8.8.8.8 to your docker run command.`);
|
|
} else {
|
|
console.error(`[Registry] ${imageName}: ${e}`);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export interface ImageUpdateCheckResult {
|
|
hasUpdate: boolean;
|
|
currentDigest?: string;
|
|
registryDigest?: string;
|
|
/** True if this is a local-only image (no registry) */
|
|
isLocalImage?: boolean;
|
|
/** Error message if check failed */
|
|
error?: string;
|
|
}
|
|
|
|
/**
|
|
* Check if an image has an update available by comparing local digests against registry.
|
|
* This is a lightweight check that doesn't pull the image.
|
|
*
|
|
* @param imageName - The image name with optional tag (e.g., "nginx:latest")
|
|
* @param currentImageId - The sha256 ID of the current local image
|
|
* @param envId - Optional environment ID for multi-environment support
|
|
* @returns Update check result with hasUpdate flag and digest info
|
|
*/
|
|
export async function checkImageUpdateAvailable(
|
|
imageName: string,
|
|
currentImageId: string,
|
|
envId?: number
|
|
): Promise<ImageUpdateCheckResult> {
|
|
try {
|
|
// Skip update check for digest-pinned images
|
|
// If the user explicitly pins to a digest (image@sha256:...), they don't want auto-updates
|
|
if (isDigestBasedImage(imageName)) {
|
|
return {
|
|
hasUpdate: false,
|
|
currentDigest: imageName.split('@')[1] // Extract the digest part
|
|
};
|
|
}
|
|
|
|
// Skip update check for images tagged against a localhost registry.
|
|
// These never resolve from inside Dockhand's container and only produce
|
|
// ECONNREFUSED noise (see #1083).
|
|
if (/^localhost(:\d+)?\//.test(imageName)) {
|
|
return {
|
|
hasUpdate: false,
|
|
isLocalImage: true,
|
|
currentDigest: currentImageId
|
|
};
|
|
}
|
|
|
|
// Get current image info to get RepoDigests
|
|
let currentImageInfo: any;
|
|
try {
|
|
currentImageInfo = await inspectImage(currentImageId, envId);
|
|
} catch {
|
|
return { hasUpdate: false, error: 'Could not inspect current image' };
|
|
}
|
|
|
|
const currentRepoDigests: string[] = currentImageInfo?.RepoDigests || [];
|
|
|
|
// Extract digest part from RepoDigest (format: repo@sha256:...)
|
|
const extractDigest = (rd: string): string | null => {
|
|
const atIndex = rd.lastIndexOf('@');
|
|
return atIndex > -1 ? rd.substring(atIndex + 1) : null;
|
|
};
|
|
|
|
// Get ALL local digests - an image can have multiple RepoDigests
|
|
// (e.g., when a tag is updated but the content for your architecture is the same)
|
|
const localDigests = currentRepoDigests
|
|
.map(extractDigest)
|
|
.filter((d): d is string => d !== null);
|
|
|
|
// The RUNNING image has no repo digests. That is ambiguous: it can be a
|
|
// genuinely local/built image (no registry to check against), OR a registry
|
|
// image whose tag has since moved to a newer pull — leaving the container
|
|
// running an old, now-untagged, digest-less image (#1288). The old code
|
|
// declared "local" here and skipped the check forever, which is exactly the
|
|
// state that means "this container needs updating".
|
|
//
|
|
// Distinguish by asking the registry for imageName's tag (imageName is the
|
|
// container's Config.Image, e.g. "rotki/rotki:latest"): if the registry
|
|
// answers, this IS a registry image, and since the running image can't match
|
|
// a digest we don't have, an update is available. If the registry can't
|
|
// resolve it (404 / unreachable / truly local), THEN treat it as local.
|
|
if (localDigests.length === 0) {
|
|
const registryDigest = await getRegistryManifestDigest(imageName);
|
|
const { hasUpdate, isLocalImage, registryDigest: rd } = classifyEmptyDigestImage(registryDigest);
|
|
return { hasUpdate, isLocalImage, currentDigest: currentImageId, registryDigest: rd };
|
|
}
|
|
|
|
// Query registry for current manifest digest
|
|
const registryDigest = await getRegistryManifestDigest(imageName);
|
|
|
|
if (!registryDigest) {
|
|
// Registry unreachable or image not found - can't determine update status
|
|
return {
|
|
hasUpdate: false,
|
|
currentDigest: currentRepoDigests[0],
|
|
error: 'Could not query registry'
|
|
};
|
|
}
|
|
|
|
// Check if registry digest matches ANY of the local digests
|
|
const matchesLocal = localDigests.includes(registryDigest);
|
|
const hasUpdate = !matchesLocal;
|
|
|
|
return {
|
|
hasUpdate,
|
|
currentDigest: currentRepoDigests[0],
|
|
registryDigest: hasUpdate ? registryDigest : undefined
|
|
};
|
|
} catch (e: any) {
|
|
return { hasUpdate: false, error: e.message };
|
|
}
|
|
}
|
|
|
|
export async function tagImage(id: string, repo: string, tag: string, envId?: number | null) {
|
|
const response = await dockerFetch(
|
|
`/images/${encodeURIComponent(id)}/tag?repo=${encodeURIComponent(repo)}&tag=${encodeURIComponent(tag)}`,
|
|
{ method: 'POST' },
|
|
envId
|
|
);
|
|
await assertDockerResponse(response);
|
|
}
|
|
|
|
/**
|
|
* Generate a temporary tag name for safe pulling during auto-updates.
|
|
* This allows scanning the new image before committing to the update.
|
|
* @param imageName - The original image name (e.g., "nginx:latest" or "nginx")
|
|
* @returns Temporary tag name (e.g., "nginx:latest-dockhand-pending")
|
|
*/
|
|
export function getTempImageTag(imageName: string): string {
|
|
// Handle images with digest (e.g., nginx@sha256:abc123)
|
|
if (imageName.includes('@')) {
|
|
// For digest-based images, we can't use temp tags - return as-is
|
|
return imageName;
|
|
}
|
|
|
|
// Find the last colon
|
|
const lastColon = imageName.lastIndexOf(':');
|
|
|
|
// No colon at all - simple image like "nginx"
|
|
if (lastColon === -1) {
|
|
return `${imageName}:latest-dockhand-pending`;
|
|
}
|
|
|
|
const afterColon = imageName.substring(lastColon + 1);
|
|
|
|
// If the part after the last colon contains a slash, it's a port number
|
|
// e.g., "registry:5000/nginx" -> afterColon = "5000/nginx"
|
|
// In this case, there's no tag, so we append :latest-dockhand-pending
|
|
if (afterColon.includes('/')) {
|
|
return `${imageName}:latest-dockhand-pending`;
|
|
}
|
|
|
|
// Otherwise, the last colon separates repo from tag
|
|
// e.g., "registry.bor6.pl/test:latest" -> repo="registry.bor6.pl/test", tag="latest"
|
|
const repo = imageName.substring(0, lastColon);
|
|
const tag = afterColon;
|
|
|
|
return `${repo}:${tag}-dockhand-pending`;
|
|
}
|
|
|
|
/**
|
|
* Check if an image name is using a digest (sha256) instead of a tag.
|
|
* Digest-based images don't need temp tag handling.
|
|
*/
|
|
export function isDigestBasedImage(imageName: string): boolean {
|
|
return imageName.includes('@sha256:');
|
|
}
|
|
|
|
/**
|
|
* Normalize an image tag for comparison.
|
|
* Docker Hub images can be represented as:
|
|
* - n8nio/n8n:latest
|
|
* - docker.io/n8nio/n8n:latest
|
|
* - docker.io/library/nginx:latest (for official images)
|
|
* - library/nginx:latest
|
|
* - nginx:latest
|
|
* Custom registries:
|
|
* - docker.n8n.io/n8nio/n8n (n8n's custom registry)
|
|
*/
|
|
function normalizeImageTag(tag: string): string {
|
|
let normalized = tag;
|
|
// Remove docker.io/ prefix
|
|
normalized = normalized.replace(/^docker\.io\//, '');
|
|
// Remove library/ prefix for official images
|
|
normalized = normalized.replace(/^library\//, '');
|
|
// Add :latest if no tag specified (and not a digest)
|
|
if (!normalized.includes(':') && !normalized.includes('@')) {
|
|
normalized = `${normalized}:latest`;
|
|
}
|
|
return normalized.toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Get image ID by tag name.
|
|
* Uses Docker's image inspect API which correctly resolves any image reference
|
|
* (docker.io, ghcr.io, custom registries, etc.)
|
|
* @returns Image ID (sha256:...) or null if not found
|
|
*/
|
|
export async function getImageIdByTag(tagName: string, envId?: number | null): Promise<string | null> {
|
|
try {
|
|
// First try: Use Docker's image inspect API - this is the most reliable
|
|
// as Docker knows exactly how to resolve the image name
|
|
const imageInfo = await inspectImage(tagName, envId) as { Id?: string } | null;
|
|
if (imageInfo?.Id) {
|
|
return imageInfo.Id;
|
|
}
|
|
} catch {
|
|
// Image inspect failed - fall back to listing images
|
|
}
|
|
|
|
try {
|
|
// Fallback: Search through listed images with normalization
|
|
const images = await listImages(envId);
|
|
const normalizedSearch = normalizeImageTag(tagName);
|
|
|
|
for (const image of images) {
|
|
if (image.tags) {
|
|
for (const tag of image.tags) {
|
|
if (normalizeImageTag(tag) === normalizedSearch) {
|
|
return image.id;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a temporary image by its tag.
|
|
* Used to clean up after a blocked auto-update.
|
|
* @param imageIdOrTag - Image ID or tag to remove
|
|
* @param force - Force removal even if image is in use
|
|
*/
|
|
export async function removeTempImage(imageIdOrTag: string, envId?: number | null, force = true): Promise<void> {
|
|
try {
|
|
await removeImage(imageIdOrTag, force, envId);
|
|
} catch (error: any) {
|
|
// Log but don't throw - cleanup failure shouldn't break the flow
|
|
console.warn(`[Docker] Failed to remove temp image ${imageIdOrTag}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Export (save) an image as a tar archive stream.
|
|
* Uses Docker's GET /images/{name}/get endpoint.
|
|
* @returns Response object with tar stream body
|
|
*/
|
|
export async function exportImage(id: string, envId?: number | null): Promise<Response> {
|
|
const response = await dockerFetch(
|
|
`/images/${encodeURIComponent(id)}/get`,
|
|
{ method: 'GET', streaming: true },
|
|
envId
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text().catch(() => 'Unknown error');
|
|
throw new Error(`Failed to export image: ${response.status} - ${error}`);
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
// System information
|
|
export async function getDockerInfo(envId?: number | null) {
|
|
return dockerJsonRequest('/info', {}, envId);
|
|
}
|
|
|
|
export async function getDockerVersion(envId?: number | null) {
|
|
return dockerJsonRequest('/version', {}, envId);
|
|
}
|
|
|
|
/**
|
|
* Get the Docker daemon's API version string for a given environment.
|
|
* Used to pin DOCKER_API_VERSION when spawning sidecar containers (scanner, updater)
|
|
* whose bundled Docker CLI may be newer than the host daemon supports.
|
|
* Returns null if the version cannot be determined.
|
|
* Requires an envId — local Docker (no environment) must query /version directly.
|
|
*/
|
|
export async function getNegotiatedApiVersion(envId: number): Promise<string | null> {
|
|
try {
|
|
const versionInfo = await getDockerVersion(envId);
|
|
return versionInfo?.ApiVersion || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lightweight ping check for Docker daemon availability.
|
|
* Uses /_ping endpoint which returns "OK" as plain text with minimal overhead.
|
|
* Used by the circuit breaker to probe offline environments.
|
|
*/
|
|
export async function dockerPing(envId: number): Promise<boolean> {
|
|
try {
|
|
// Edge connections go WebSocket → agent → Docker daemon, which adds latency on slow hosts.
|
|
// Use a longer timeout for edge to avoid false negatives on overloaded NAS/VPS devices.
|
|
const config = await getDockerConfig(envId).catch(() => null);
|
|
const timeoutMs = config?.connectionType === 'hawser-edge' ? 20000 : 5000;
|
|
const response = await dockerFetch('/_ping', {
|
|
signal: AbortSignal.timeout(timeoutMs)
|
|
}, envId);
|
|
await drainResponse(response);
|
|
return response.ok;
|
|
} catch (error: any) {
|
|
const msg = error?.message || String(error);
|
|
if (msg.includes('unreachable')) {
|
|
console.warn(`[Docker] ${config?.connectionType || 'direct'} ${config?.host || envId}: /_ping failed - host unreachable`);
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get Hawser agent info (for hawser-standard mode)
|
|
* Returns agent info including uptime
|
|
*/
|
|
export async function getHawserInfo(envId: number): Promise<{
|
|
agentId: string;
|
|
agentName: string;
|
|
dockerVersion: string;
|
|
hawserVersion: string;
|
|
mode: string;
|
|
uptime: number;
|
|
} | null> {
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
try {
|
|
const response = await dockerFetch('/_hawser/info', {}, envId);
|
|
if (response.ok) {
|
|
return await response.json();
|
|
}
|
|
await drainResponse(response);
|
|
console.warn(`[Hawser] Info endpoint returned ${response.status} for env ${envId}`);
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error);
|
|
console.warn(`[Hawser] Failed to fetch info for env ${envId} (attempt ${attempt + 1}): ${msg}`);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Volume operations
|
|
export interface VolumeInfo {
|
|
name: string;
|
|
driver: string;
|
|
mountpoint: string;
|
|
scope: string;
|
|
created: string;
|
|
labels: { [key: string]: string };
|
|
/** Containers currently referencing this volume (running or stopped); empty = unused. */
|
|
usedBy: Array<{ containerId: string; containerName: string }>;
|
|
/** driver_opts (e.g. NFS/CIFS type+device+o), only when non-empty. */
|
|
options?: Record<string, string>;
|
|
}
|
|
|
|
export async function listVolumes(envId?: number | null): Promise<VolumeInfo[]> {
|
|
// Fetch volumes and containers in parallel
|
|
const [volumeResult, containers] = await Promise.all([
|
|
dockerJsonRequest<{ Volumes: any[] }>('/volumes', {}, envId),
|
|
dockerJsonRequest<any[]>('/containers/json?all=true', {}, envId)
|
|
]);
|
|
|
|
// Build a map of volume name -> containers using it
|
|
const volumeUsageMap = new Map<string, { containerId: string; containerName: string }[]>();
|
|
|
|
for (const container of containers) {
|
|
const containerName = container.Names?.[0]?.replace(/^\//, '') || 'unnamed';
|
|
const containerId = container.Id;
|
|
|
|
for (const mount of container.Mounts || []) {
|
|
// Check for volume-type mounts (not bind mounts)
|
|
if (mount.Type === 'volume' && mount.Name) {
|
|
const volumeName = mount.Name;
|
|
if (!volumeUsageMap.has(volumeName)) {
|
|
volumeUsageMap.set(volumeName, []);
|
|
}
|
|
volumeUsageMap.get(volumeName)!.push({ containerId, containerName });
|
|
}
|
|
}
|
|
}
|
|
|
|
return (volumeResult.Volumes || []).map((volume: any) => ({
|
|
name: volume.Name,
|
|
driver: volume.Driver,
|
|
mountpoint: volume.Mountpoint,
|
|
scope: volume.Scope,
|
|
created: volume.CreatedAt,
|
|
labels: volume.Labels || {},
|
|
usedBy: volumeUsageMap.get(volume.Name) || [],
|
|
// Surface driver_opts (e.g. NFS/CIFS type+device+o) so the UI can
|
|
// distinguish network-backed volumes from plain local ones. Docker's
|
|
// /volumes endpoint already includes this — we were dropping it.
|
|
options: volume.Options && Object.keys(volume.Options).length > 0 ? volume.Options : undefined
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Check if a volume is in use by any containers
|
|
* Returns list of containers using the volume
|
|
*/
|
|
export async function getVolumeUsage(
|
|
volumeName: string,
|
|
envId?: number | null
|
|
): Promise<{ containerId: string; containerName: string; state: string }[]> {
|
|
const containers = await dockerJsonRequest<any[]>('/containers/json?all=true', {}, envId);
|
|
const usage: { containerId: string; containerName: string; state: string }[] = [];
|
|
|
|
for (const container of containers) {
|
|
// Skip our own helper containers
|
|
if (container.Labels?.['dockhand.volume.helper'] === 'true') {
|
|
continue;
|
|
}
|
|
|
|
const containerName = container.Names?.[0]?.replace(/^\//, '') || 'unnamed';
|
|
const containerId = container.Id;
|
|
const state = container.State || 'unknown';
|
|
|
|
for (const mount of container.Mounts || []) {
|
|
if (mount.Type === 'volume' && mount.Name === volumeName) {
|
|
usage.push({ containerId, containerName, state });
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return usage;
|
|
}
|
|
|
|
export async function removeVolume(name: string, force = false, envId?: number | null) {
|
|
const response = await dockerFetch(`/volumes/${encodeURIComponent(name)}?force=${force}`, { method: 'DELETE' }, envId);
|
|
if (!response.ok) {
|
|
const data = await response.json().catch(() => ({}));
|
|
const error: any = new Error(data.message || 'Failed to remove volume');
|
|
error.statusCode = response.status;
|
|
error.json = data;
|
|
throw error;
|
|
}
|
|
await drainResponse(response);
|
|
}
|
|
|
|
export async function inspectVolume(name: string, envId?: number | null) {
|
|
return dockerJsonRequest(`/volumes/${encodeURIComponent(name)}`, {}, envId);
|
|
}
|
|
|
|
export interface CreateVolumeOptions {
|
|
name: string;
|
|
driver?: string;
|
|
driverOpts?: { [key: string]: string };
|
|
labels?: { [key: string]: string };
|
|
}
|
|
|
|
export async function createVolume(options: CreateVolumeOptions, envId?: number | null) {
|
|
const volumeConfig = {
|
|
Name: options.name,
|
|
Driver: options.driver || 'local',
|
|
DriverOpts: options.driverOpts || {},
|
|
Labels: options.labels || {}
|
|
};
|
|
return dockerJsonRequest('/volumes/create', {
|
|
method: 'POST',
|
|
body: JSON.stringify(volumeConfig)
|
|
}, envId);
|
|
}
|
|
|
|
// Network operations
|
|
export interface NetworkInfo {
|
|
id: string;
|
|
name: string;
|
|
driver: string;
|
|
scope: string;
|
|
internal: boolean;
|
|
ipam: {
|
|
driver: string;
|
|
config: Array<{ subnet?: string; gateway?: string }>;
|
|
};
|
|
containers: { [key: string]: { name: string; ipv4Address: string } };
|
|
}
|
|
|
|
export async function listNetworks(envId?: number | null): Promise<NetworkInfo[]> {
|
|
const [networks, containers] = await Promise.all([
|
|
dockerJsonRequest<any[]>('/networks', {}, envId),
|
|
dockerJsonRequest<any[]>('/containers/json?all=true', {}, envId)
|
|
]);
|
|
|
|
// Build map of networkId -> container info from container network settings
|
|
const networkContainers = new Map<string, Record<string, { name: string; ipv4Address: string }>>();
|
|
for (const container of containers) {
|
|
const nets = container.NetworkSettings?.Networks;
|
|
if (!nets) continue;
|
|
const containerName = (container.Names?.[0] || '').replace(/^\//, '');
|
|
for (const [, netInfo] of Object.entries(nets as Record<string, any>)) {
|
|
const netId = netInfo.NetworkID;
|
|
if (!netId) continue;
|
|
if (!networkContainers.has(netId)) {
|
|
networkContainers.set(netId, {});
|
|
}
|
|
networkContainers.get(netId)![container.Id] = {
|
|
name: containerName,
|
|
ipv4Address: netInfo.IPAddress || ''
|
|
};
|
|
}
|
|
}
|
|
|
|
return networks.map((network: any) => ({
|
|
id: network.Id,
|
|
name: network.Name,
|
|
driver: network.Driver,
|
|
scope: network.Scope,
|
|
internal: network.Internal || false,
|
|
ipam: {
|
|
driver: network.IPAM?.Driver || 'default',
|
|
// Normalize IPAM config field names to lowercase for consistency
|
|
config: (network.IPAM?.Config || []).map((cfg: any) => ({
|
|
subnet: cfg.Subnet || cfg.subnet,
|
|
gateway: cfg.Gateway || cfg.gateway,
|
|
ipRange: cfg.IPRange || cfg.ipRange,
|
|
auxAddress: cfg.AuxAddress || cfg.auxAddress
|
|
}))
|
|
},
|
|
containers: networkContainers.get(network.Id) || {}
|
|
}));
|
|
}
|
|
|
|
export async function removeNetwork(id: string, envId?: number | null) {
|
|
const response = await dockerFetch(`/networks/${id}`, { method: 'DELETE' }, envId);
|
|
if (!response.ok) {
|
|
const data = await response.json().catch(() => ({}));
|
|
const error: any = new Error(data.message || 'Failed to remove network');
|
|
error.statusCode = response.status;
|
|
error.json = data;
|
|
throw error;
|
|
}
|
|
await drainResponse(response);
|
|
}
|
|
|
|
export async function inspectNetwork(id: string, envId?: number | null) {
|
|
return dockerJsonRequest(`/networks/${id}`, {}, envId);
|
|
}
|
|
|
|
export interface CreateNetworkOptions {
|
|
name: string;
|
|
driver?: string;
|
|
internal?: boolean;
|
|
attachable?: boolean;
|
|
ingress?: boolean;
|
|
enableIPv6?: boolean;
|
|
ipam?: {
|
|
driver?: string;
|
|
config?: Array<{
|
|
subnet?: string;
|
|
ipRange?: string;
|
|
gateway?: string;
|
|
auxAddress?: { [key: string]: string };
|
|
}>;
|
|
options?: { [key: string]: string };
|
|
};
|
|
options?: { [key: string]: string };
|
|
labels?: { [key: string]: string };
|
|
}
|
|
|
|
export async function createNetwork(options: CreateNetworkOptions, envId?: number | null) {
|
|
const networkConfig: any = {
|
|
Name: options.name,
|
|
Driver: options.driver || 'bridge',
|
|
Internal: options.internal || false,
|
|
Attachable: options.attachable || false,
|
|
Ingress: options.ingress || false,
|
|
EnableIPv6: options.enableIPv6 || false,
|
|
Options: options.options || {},
|
|
Labels: options.labels || {}
|
|
};
|
|
|
|
if (options.ipam) {
|
|
networkConfig.IPAM = {
|
|
Driver: options.ipam.driver || 'default',
|
|
Config: options.ipam.config?.map(cfg => ({
|
|
Subnet: cfg.subnet,
|
|
IPRange: cfg.ipRange,
|
|
Gateway: cfg.gateway,
|
|
AuxiliaryAddresses: cfg.auxAddress
|
|
})).filter(cfg => cfg.Subnet || cfg.Gateway) || [],
|
|
Options: options.ipam.options || {}
|
|
};
|
|
}
|
|
|
|
return dockerJsonRequest('/networks/create', {
|
|
method: 'POST',
|
|
body: JSON.stringify(networkConfig)
|
|
}, envId);
|
|
}
|
|
|
|
// Network connect/disconnect operations
|
|
export interface NetworkConnectOptions {
|
|
aliases?: string[];
|
|
ipv4Address?: string;
|
|
ipv6Address?: string;
|
|
gwPriority?: number;
|
|
}
|
|
|
|
export async function connectContainerToNetwork(
|
|
networkId: string,
|
|
containerId: string,
|
|
envId?: number | null,
|
|
options?: NetworkConnectOptions
|
|
): Promise<void> {
|
|
const body: any = { Container: containerId };
|
|
|
|
// Add EndpointConfig for aliases, static IP, and gateway priority
|
|
if (options?.aliases || options?.ipv4Address || options?.ipv6Address || options?.gwPriority !== undefined) {
|
|
body.EndpointConfig = {};
|
|
|
|
if (options.aliases && options.aliases.length > 0) {
|
|
body.EndpointConfig.Aliases = options.aliases;
|
|
}
|
|
|
|
if (options.ipv4Address || options.ipv6Address) {
|
|
body.EndpointConfig.IPAMConfig = {};
|
|
if (options.ipv4Address) {
|
|
body.EndpointConfig.IPAMConfig.IPv4Address = options.ipv4Address;
|
|
}
|
|
if (options.ipv6Address) {
|
|
body.EndpointConfig.IPAMConfig.IPv6Address = options.ipv6Address;
|
|
}
|
|
}
|
|
|
|
// Gateway priority (Docker Engine 28+)
|
|
if (options.gwPriority !== undefined) {
|
|
body.EndpointConfig.GwPriority = options.gwPriority;
|
|
}
|
|
}
|
|
|
|
const response = await dockerFetch(
|
|
`/networks/${networkId}/connect`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
},
|
|
envId
|
|
);
|
|
if (!response.ok) {
|
|
const data = await response.json().catch(() => ({}));
|
|
throw new Error(data.message || 'Failed to connect container to network');
|
|
}
|
|
await drainResponse(response);
|
|
}
|
|
|
|
/**
|
|
* Connect a container to a network using a raw EndpointSettings object from inspect data.
|
|
* Passes the full EndpointSettings as-is, preserving all fields (Links, DriverOpts,
|
|
* IPAMConfig.LinkLocalIPs, MacAddress, etc.) without manual field extraction.
|
|
*/
|
|
export async function connectContainerToNetworkRaw(
|
|
networkId: string,
|
|
containerId: string,
|
|
endpointSettings: any,
|
|
envId?: number | null
|
|
): Promise<void> {
|
|
const body: any = {
|
|
Container: containerId,
|
|
EndpointConfig: endpointSettings
|
|
};
|
|
|
|
const response = await dockerFetch(
|
|
`/networks/${networkId}/connect`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
},
|
|
envId
|
|
);
|
|
if (!response.ok) {
|
|
const data = await response.json().catch(() => ({}));
|
|
throw new Error(data.message || 'Failed to connect container to network');
|
|
}
|
|
await drainResponse(response);
|
|
}
|
|
|
|
export async function disconnectContainerFromNetwork(
|
|
networkId: string,
|
|
containerId: string,
|
|
force = false,
|
|
envId?: number | null
|
|
): Promise<void> {
|
|
const response = await dockerFetch(
|
|
`/networks/${networkId}/disconnect`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ Container: containerId, Force: force })
|
|
},
|
|
envId
|
|
);
|
|
if (!response.ok) {
|
|
const data = await response.json().catch(() => ({}));
|
|
throw new Error(data.message || 'Failed to disconnect container from network');
|
|
}
|
|
await drainResponse(response);
|
|
}
|
|
|
|
// Container exec operations
|
|
export interface ExecOptions {
|
|
containerId: string;
|
|
cmd: string[];
|
|
user?: string;
|
|
workingDir?: string;
|
|
envId?: number | null;
|
|
}
|
|
|
|
export async function createExec(options: ExecOptions): Promise<{ Id: string }> {
|
|
const execConfig = {
|
|
Cmd: options.cmd,
|
|
AttachStdin: true,
|
|
AttachStdout: true,
|
|
AttachStderr: true,
|
|
Tty: true,
|
|
User: options.user || 'root',
|
|
WorkingDir: options.workingDir
|
|
};
|
|
|
|
return dockerJsonRequest(`/containers/${options.containerId}/exec`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(execConfig)
|
|
}, options.envId);
|
|
}
|
|
|
|
export async function resizeExec(execId: string, cols: number, rows: number, envId?: number | null) {
|
|
try {
|
|
const response = await dockerFetch(`/exec/${execId}/resize?h=${rows}&w=${cols}`, { method: 'POST' }, envId);
|
|
await drainResponse(response);
|
|
} catch {
|
|
// Resize may fail if exec is not running, ignore
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get Docker connection info for direct WebSocket connections from the client
|
|
* This is used by the terminal to connect directly to the Docker API
|
|
*/
|
|
export async function getDockerConnectionInfo(envId?: number | null): Promise<{
|
|
type: 'socket' | 'http' | 'https';
|
|
socketPath?: string;
|
|
host?: string;
|
|
port?: number;
|
|
}> {
|
|
const config = await getDockerConfig(envId);
|
|
return {
|
|
type: config.type,
|
|
socketPath: config.socketPath,
|
|
host: config.host,
|
|
port: config.port
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// Global handlers for server.js terminal WebSocket connections
|
|
// =============================================================================
|
|
// server.js cannot import SvelteKit modules directly, so we expose these
|
|
// functions via globalThis (same pattern as Hawser handlers).
|
|
// =============================================================================
|
|
|
|
declare global {
|
|
var __terminalGetTarget: ((envId?: number) => Promise<{
|
|
type: 'socket' | 'http' | 'https';
|
|
connectionType?: string;
|
|
socketPath?: string;
|
|
host?: string;
|
|
port?: number;
|
|
hawserToken?: string;
|
|
environmentId?: number;
|
|
tls?: { ca?: string; cert?: string; key?: string; rejectUnauthorized: boolean };
|
|
}>) | undefined;
|
|
var __terminalCreateExec: ((containerId: string, shell: string, user: string, envId?: number) => Promise<string>) | undefined;
|
|
var __terminalResizeExec: ((execId: string, cols: number, rows: number, envId?: number) => Promise<void>) | undefined;
|
|
}
|
|
|
|
globalThis.__terminalGetTarget = async (envId?: number) => {
|
|
if (!envId) {
|
|
// No environment = local socket
|
|
return { type: 'socket', connectionType: 'socket', socketPath: '/var/run/docker.sock' };
|
|
}
|
|
const config = await getDockerConfig(envId);
|
|
const result: Awaited<ReturnType<NonNullable<typeof globalThis.__terminalGetTarget>>> = {
|
|
type: config.type,
|
|
connectionType: config.connectionType,
|
|
socketPath: config.socketPath,
|
|
host: config.host,
|
|
port: config.port,
|
|
hawserToken: config.hawserToken,
|
|
environmentId: config.environmentId,
|
|
};
|
|
if (config.type === 'https') {
|
|
result.tls = {
|
|
ca: config.ca,
|
|
cert: config.cert,
|
|
key: config.key,
|
|
rejectUnauthorized: !config.skipVerify,
|
|
};
|
|
}
|
|
return result;
|
|
};
|
|
|
|
globalThis.__terminalCreateExec = async (containerId, shell, user, envId) => {
|
|
const exec = await createExec({ containerId, cmd: [shell], user, envId });
|
|
return exec.Id;
|
|
};
|
|
|
|
globalThis.__terminalResizeExec = async (execId, cols, rows, envId) => {
|
|
await resizeExec(execId, cols, rows, envId);
|
|
};
|
|
|
|
// System disk usage
|
|
export async function getDiskUsage(envId?: number | null) {
|
|
return dockerJsonRequest('/system/df', {}, envId);
|
|
}
|
|
|
|
// Prune operations
|
|
export async function pruneContainers(envId?: number | null) {
|
|
return dockerJsonRequest('/containers/prune', { method: 'POST' }, envId);
|
|
}
|
|
|
|
export async function pruneImages(dangling = true, envId?: number | null) {
|
|
// dangling=true: only remove untagged images. Pass straight through —
|
|
// scanner images are always tagged so they can't be dangling anyway.
|
|
if (dangling) {
|
|
return dockerJsonRequest(
|
|
`/images/prune?filters=${encodeURIComponent('{"dangling":["true"]}')}`,
|
|
{ method: 'POST' },
|
|
envId
|
|
);
|
|
}
|
|
|
|
// dangling=false: "prune all unused." When the scanner-protection setting
|
|
// is on, shield grype + trivy with stopped holder containers (Docker's
|
|
// "in use" check keeps them) then tear them down in a finally (#625).
|
|
const protect = (await getSetting('protect_scanner_images')) !== false;
|
|
if (!protect) {
|
|
return dockerJsonRequest(
|
|
`/images/prune?filters=${encodeURIComponent('{"dangling":["false"]}')}`,
|
|
{ method: 'POST' },
|
|
envId
|
|
);
|
|
}
|
|
|
|
const { DEFAULT_GRYPE_IMAGE, DEFAULT_TRIVY_IMAGE } = await import('./scanner');
|
|
const { DEFAULT_HELPER_IMAGE } = await import('./backups/restic');
|
|
const grypeImg = (await getSetting('default_grype_image')) ?? DEFAULT_GRYPE_IMAGE;
|
|
const trivyImg = (await getSetting('default_trivy_image')) ?? DEFAULT_TRIVY_IMAGE;
|
|
// The backup/restore helper image is pulled once and reused for every backup; shield it from
|
|
// prune too (same holder-container trick), so an unused-image prune between backups doesn't
|
|
// force a slow re-pull (and a fail on an air-tight/offline host).
|
|
const helperImg = (await getSetting('default_backup_image')) || DEFAULT_HELPER_IMAGE;
|
|
|
|
const holderIds: string[] = [];
|
|
for (const image of [grypeImg, trivyImg, helperImg]) {
|
|
try {
|
|
const safeName = `dockhand-prune-keep-${image.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}`;
|
|
const created = await dockerJsonRequest<{ Id: string }>(
|
|
`/containers/create?name=${encodeURIComponent(safeName)}`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({ Image: image, Cmd: ['/bin/true'] })
|
|
},
|
|
envId
|
|
);
|
|
holderIds.push(created.Id);
|
|
} catch {
|
|
// Image not present locally → nothing to protect → no-op
|
|
}
|
|
}
|
|
|
|
try {
|
|
return await dockerJsonRequest(
|
|
`/images/prune?filters=${encodeURIComponent('{"dangling":["false"]}')}`,
|
|
{ method: 'POST' },
|
|
envId
|
|
);
|
|
} finally {
|
|
for (const id of holderIds) {
|
|
try {
|
|
await dockerJsonRequest(
|
|
`/containers/${id}?force=true`,
|
|
{ method: 'DELETE' },
|
|
envId
|
|
);
|
|
} catch {
|
|
// Best-effort cleanup; orphan holders are visible but harmless
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function pruneVolumes(
|
|
envId?: number | null
|
|
): Promise<{ VolumesDeleted: string[]; SpaceReclaimed: number }> {
|
|
// Docker's own /volumes/prune only removes ANONYMOUS volumes since API v1.42
|
|
// (without filters={"all":["true"]}), so named dangling volumes survive — the
|
|
// bug in #1289. We prune explicitly instead: list volumes, pick the unused,
|
|
// unprotected ones, and remove each. This also lets us EXCLUDE the scanner
|
|
// cache volumes (dockhand-grype-db / -trivy-db) by name, which a plain
|
|
// all=true prune would wrongly delete.
|
|
const { selectVolumesToPrune, PROTECTED_VOLUME_NAMES } = await import('./volume-prune-core');
|
|
const volumes = await listVolumes(envId);
|
|
const toDelete = selectVolumesToPrune(volumes, PROTECTED_VOLUME_NAMES);
|
|
|
|
const deleted: string[] = [];
|
|
for (const name of toDelete) {
|
|
try {
|
|
// force=false: a volume that races into "in use" between the list and the
|
|
// delete errors out and is skipped, rather than being force-removed —
|
|
// matching Docker prune's non-forcing semantics.
|
|
await removeVolume(name, false, envId);
|
|
deleted.push(name);
|
|
} catch (err) {
|
|
console.error(`[Prune] Failed to remove volume "${name}":`, err instanceof Error ? err.message : err);
|
|
// Continue — one stubborn volume must not abort the whole prune.
|
|
}
|
|
}
|
|
|
|
// SpaceReclaimed is a placeholder 0: the /volumes list API carries no per-volume
|
|
// size, so we don't fabricate a byte count. The field is kept for backward
|
|
// compatibility (callers/audit and the test only check it is a number).
|
|
return { VolumesDeleted: deleted, SpaceReclaimed: 0 };
|
|
}
|
|
|
|
export async function pruneNetworks(envId?: number | null) {
|
|
return dockerJsonRequest('/networks/prune', { method: 'POST' }, envId);
|
|
}
|
|
|
|
export async function pruneAll(envId?: number | null) {
|
|
const containers = await pruneContainers(envId);
|
|
const images = await pruneImages(false, envId);
|
|
const volumes = await pruneVolumes(envId);
|
|
const networks = await pruneNetworks(envId);
|
|
return { containers, images, volumes, networks };
|
|
}
|
|
|
|
// Registry operations
|
|
export async function searchImages(term: string, limit = 25, envId?: number | null) {
|
|
return dockerJsonRequest(`/images/search?term=${encodeURIComponent(term)}&limit=${limit}`, {}, envId);
|
|
}
|
|
|
|
// List containers with size info (slower operation)
|
|
export async function listContainersWithSize(all = true, envId?: number | null): Promise<Record<string, { sizeRw: number; sizeRootFs: number }>> {
|
|
const containers = await dockerJsonRequest<any[]>(
|
|
`/containers/json?all=${all}&size=true`,
|
|
{},
|
|
envId
|
|
);
|
|
|
|
const sizes: Record<string, { sizeRw: number; sizeRootFs: number }> = {};
|
|
for (const container of containers) {
|
|
sizes[container.Id] = {
|
|
sizeRw: container.SizeRw || 0,
|
|
sizeRootFs: container.SizeRootFs || 0
|
|
};
|
|
}
|
|
return sizes;
|
|
}
|
|
|
|
// Get container top (process list)
|
|
export async function getContainerTop(id: string, envId?: number | null): Promise<{ Titles: string[]; Processes: string[][] }> {
|
|
return dockerJsonRequest(`/containers/${id}/top`, {}, envId);
|
|
}
|
|
|
|
// Execute a command in a container and return the output
|
|
export async function execInContainer(
|
|
containerId: string,
|
|
cmd: string[],
|
|
envId?: number | null,
|
|
user?: string | null
|
|
): Promise<string> {
|
|
const execBody: any = {
|
|
Cmd: cmd,
|
|
AttachStdout: true,
|
|
AttachStderr: true,
|
|
Tty: false
|
|
};
|
|
|
|
if (user) {
|
|
execBody.User = user;
|
|
}
|
|
|
|
const execCreate = await dockerJsonRequest<{ Id: string }>(
|
|
`/containers/${containerId}/exec`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify(execBody)
|
|
},
|
|
envId
|
|
);
|
|
|
|
// Start exec and get output
|
|
const response = await dockerFetch(
|
|
`/exec/${execCreate.Id}/start`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ Detach: false, Tty: false })
|
|
},
|
|
envId
|
|
);
|
|
|
|
if (!response.ok) await throwDockerError(response);
|
|
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
const output = demuxDockerStream(buffer) as string;
|
|
|
|
// Check exit code by inspecting the exec instance
|
|
const execInfo = await dockerJsonRequest<{ ExitCode: number }>(
|
|
`/exec/${execCreate.Id}/json`,
|
|
{},
|
|
envId
|
|
);
|
|
|
|
if (execInfo.ExitCode !== 0) {
|
|
const errorMsg = output.trim() || `Command failed with exit code ${execInfo.ExitCode}`;
|
|
throw new Error(errorMsg);
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
// Get Docker events as a stream (for SSE)
|
|
// For streaming mode: call with just filters
|
|
// For polling mode: call with since and until to get a finite window of events
|
|
export async function getDockerEvents(
|
|
filters: Record<string, string[]>,
|
|
envId?: number | null,
|
|
options?: { since?: string; until?: string }
|
|
): Promise<ReadableStream<Uint8Array> | null> {
|
|
const filterJson = JSON.stringify(filters);
|
|
|
|
// Build query string with optional since/until for polling mode
|
|
let queryString = `filters=${encodeURIComponent(filterJson)}`;
|
|
if (options?.since) {
|
|
queryString += `&since=${encodeURIComponent(options.since)}`;
|
|
}
|
|
if (options?.until) {
|
|
queryString += `&until=${encodeURIComponent(options.until)}`;
|
|
}
|
|
|
|
try {
|
|
// Use streaming: true for this long-lived connection.
|
|
// The Docker events API keeps the connection open indefinitely, sending events as they occur.
|
|
const response = await dockerFetch(
|
|
`/events?${queryString}`,
|
|
{ streaming: true },
|
|
envId
|
|
);
|
|
|
|
if (!response.ok) {
|
|
await drainResponse(response);
|
|
throw new Error(`Docker events API returned ${response.status}`);
|
|
}
|
|
|
|
return response.body;
|
|
} catch (error: any) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Check if volume exists
|
|
export async function volumeExists(volumeName: string, envId?: number | null): Promise<boolean> {
|
|
try {
|
|
const volumes = await listVolumes(envId);
|
|
return volumes.some(v => v.name === volumeName);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Generate a random suffix for container names (avoids conflicts)
|
|
function randomSuffix(): string {
|
|
return Math.random().toString(36).substring(2, 8);
|
|
}
|
|
|
|
// Run a short-lived container and return stdout
|
|
export async function runContainer(options: {
|
|
image: string;
|
|
cmd: string[];
|
|
binds?: string[];
|
|
env?: string[];
|
|
extraHosts?: string[];
|
|
name?: string;
|
|
envId?: number | null;
|
|
networkMode?: string; // Docker network mode (e.g., 'host', 'bridge', custom network name)
|
|
dns?: string[]; // Custom DNS servers; undefined = inherit from Docker daemon
|
|
}): Promise<{ stdout: string; stderr: string }> {
|
|
// Add random suffix to avoid naming conflicts
|
|
const baseName = options.name || `dockhand-temp-${Date.now()}`;
|
|
const containerName = `${baseName}-${randomSuffix()}`;
|
|
|
|
// Create container - disable AutoRemove since we fetch logs after exit
|
|
// and clean up manually. AutoRemove causes race condition where container
|
|
// is removed before we can fetch logs.
|
|
const containerConfig: any = {
|
|
Image: options.image,
|
|
Cmd: options.cmd,
|
|
Env: options.env || [],
|
|
Tty: false,
|
|
HostConfig: {
|
|
Binds: options.binds || [],
|
|
AutoRemove: false // Never use AutoRemove - we clean up manually after fetching logs
|
|
}
|
|
};
|
|
|
|
if (options.extraHosts && options.extraHosts.length > 0) {
|
|
containerConfig.HostConfig.ExtraHosts = options.extraHosts;
|
|
}
|
|
|
|
if (options.networkMode) {
|
|
containerConfig.HostConfig.NetworkMode = options.networkMode;
|
|
}
|
|
|
|
if (options.dns && options.dns.length > 0) {
|
|
containerConfig.HostConfig.Dns = options.dns;
|
|
}
|
|
|
|
const createResult = await dockerJsonRequest<{ Id: string }>(
|
|
`/containers/create?name=${encodeURIComponent(containerName)}`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify(containerConfig)
|
|
},
|
|
options.envId
|
|
);
|
|
|
|
const containerId = createResult.Id;
|
|
console.log(`[runContainer] Created container ${containerId} for image ${options.image}`);
|
|
|
|
try {
|
|
// Start container
|
|
console.log(`[runContainer] Starting container ${containerId}...`);
|
|
await assertDockerResponse(await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, options.envId));
|
|
|
|
// Wait for container to finish
|
|
console.log(`[runContainer] Waiting for container ${containerId} to finish...`);
|
|
const waitResponse = await dockerFetch(`/containers/${containerId}/wait`, { method: 'POST', streaming: true }, options.envId);
|
|
if (!waitResponse.ok) await throwDockerError(waitResponse);
|
|
const waitResult = await waitResponse.json().catch(() => ({}));
|
|
console.log(`[runContainer] Container ${containerId} finished with exit code:`, waitResult?.StatusCode);
|
|
|
|
// Get logs - container is stopped but NOT removed yet since AutoRemove is false
|
|
console.log(`[runContainer] Fetching logs for container ${containerId}...`);
|
|
const logsResponse = await dockerFetch(
|
|
`/containers/${containerId}/logs?stdout=true&stderr=true`,
|
|
{},
|
|
options.envId
|
|
);
|
|
|
|
if (!logsResponse.ok) await throwDockerError(logsResponse);
|
|
|
|
const buffer = Buffer.from(await logsResponse.arrayBuffer());
|
|
console.log(`[runContainer] Got logs buffer, size: ${buffer.length} bytes`);
|
|
|
|
const result = demuxDockerStream(buffer, { separateStreams: true }) as { stdout: string; stderr: string };
|
|
console.log(`[runContainer] Demuxed: stdout=${result.stdout.length} chars, stderr=${result.stderr.length} chars`);
|
|
if (result.stdout.length === 0 && result.stderr.length === 0 && buffer.length > 0) {
|
|
console.log(`[runContainer] WARNING: Buffer has data but demux returned empty. First 100 bytes:`, buffer.slice(0, 100));
|
|
}
|
|
return result;
|
|
} finally {
|
|
// Always cleanup container manually
|
|
try {
|
|
await drainResponse(await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, options.envId));
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run a container with attached streams (for scanners that need real-time output)
|
|
/**
|
|
* DIAGNOSTIC (issue #1344): when a helper's exit code can't be determined, pull the
|
|
* recent `docker events` for that container so the log shows WHAT acted on it (a
|
|
* `die` with exitCode, a `kill` with the signal, a `destroy`/`remove` and its actor).
|
|
* `docker events` carries `Actor.Attributes` incl. the signal and, on some daemons, the
|
|
* execID/name of who issued it. Bounded, non-streaming (since..until), best-effort.
|
|
*/
|
|
async function logRecentContainerEvents(containerId: string, name: string, sinceISO: string | undefined, envId: number | null | undefined): Promise<void> {
|
|
try {
|
|
const sinceSec = sinceISO ? Math.floor(new Date(sinceISO).getTime() / 1000) - 2 : Math.floor(Date.now() / 1000) - 120;
|
|
const untilSec = Math.floor(Date.now() / 1000) + 1;
|
|
const filters = encodeURIComponent(JSON.stringify({ container: [containerId] }));
|
|
const resp = await dockerFetch(`/events?since=${sinceSec}&until=${untilSec}&filters=${filters}`, {}, envId);
|
|
if (!resp.ok) { console.warn(`[runContainerWithStreaming] events fetch for ${name}: HTTP ${resp.status}`); return; }
|
|
const body = await resp.text();
|
|
// The events endpoint returns newline-delimited JSON objects.
|
|
const lines = body.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
if (lines.length === 0) { console.warn(`[runContainerWithStreaming] no docker events for ${name} in the window (nothing recorded who touched it)`); return; }
|
|
for (const line of lines) {
|
|
try {
|
|
const ev = JSON.parse(line) as { status?: string; Action?: string; Actor?: { Attributes?: Record<string, string> }; time?: number };
|
|
const action = ev.Action ?? ev.status ?? '(unknown)';
|
|
const attrs = ev.Actor?.Attributes ?? {};
|
|
const sig = attrs.signal ? ` signal=${attrs.signal}` : '';
|
|
const code = attrs.exitCode ? ` exitCode=${attrs.exitCode}` : '';
|
|
const exec = attrs.execID ? ` execID=${attrs.execID}` : '';
|
|
console.warn(`[runContainerWithStreaming] EVENT ${name}: ${action}${sig}${code}${exec}`);
|
|
} catch { /* skip a non-JSON line */ }
|
|
}
|
|
} catch (err) {
|
|
console.warn(`[runContainerWithStreaming] events fetch failed for ${name}: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
|
|
export async function runContainerWithStreaming(options: {
|
|
image: string;
|
|
cmd: string[];
|
|
binds?: string[];
|
|
env?: string[];
|
|
extraHosts?: string[];
|
|
name?: string;
|
|
user?: string;
|
|
envId?: number | null;
|
|
onStdout?: (data: string) => void;
|
|
onStderr?: (data: string) => void;
|
|
timeout?: number; // Overall timeout in ms (0 or undefined = no timeout)
|
|
networkMode?: string; // Docker network mode (e.g., network name for TCP access)
|
|
dns?: string[]; // Custom DNS servers; undefined = inherit from Docker daemon
|
|
labels?: Record<string, string>; // Container labels (e.g. ownership tags for safe reaping)
|
|
// Hook run after the container is created but BEFORE it starts. Used by the
|
|
// backup helper to put-archive large metadata/stack files into the container
|
|
// (streamed via the Docker archive API) instead of embedding them in the Cmd,
|
|
// which would blow ARG_MAX. Receives the created container's id.
|
|
beforeStart?: (containerId: string) => Promise<void>;
|
|
}): Promise<string> {
|
|
const baseName = options.name || `dockhand-stream-${Date.now()}`;
|
|
const containerName = `${baseName}-${randomSuffix()}`;
|
|
|
|
// Create container WITHOUT AutoRemove - we need to fetch logs after it exits
|
|
const containerConfig: any = {
|
|
Image: options.image,
|
|
Cmd: options.cmd,
|
|
Env: options.env || [],
|
|
// Every helper spawned here (backup/restore/probe, image scanner) is ephemeral and exits
|
|
// on purpose, so it must NOT fire a "Container died/exited" notification like a user's
|
|
// container would. dockhand.notify=false makes the event handler skip it
|
|
// (isNotifyDisabledByLabel). Caller labels win if they set notify explicitly.
|
|
Labels: { 'dockhand.notify': 'false', ...(options.labels || {}) },
|
|
Tty: false,
|
|
HostConfig: {
|
|
Binds: options.binds || [],
|
|
AutoRemove: false,
|
|
LogConfig: {
|
|
Type: 'json-file',
|
|
Config: {}
|
|
}
|
|
}
|
|
};
|
|
|
|
if (options.extraHosts && options.extraHosts.length > 0) {
|
|
containerConfig.HostConfig.ExtraHosts = options.extraHosts;
|
|
}
|
|
|
|
// Set user if specified (needed for rootless Docker socket access)
|
|
if (options.user) {
|
|
containerConfig.User = options.user;
|
|
}
|
|
|
|
// Set network mode if specified (e.g., for scanner containers accessing Docker via TCP)
|
|
if (options.networkMode) {
|
|
containerConfig.HostConfig.NetworkMode = options.networkMode;
|
|
}
|
|
|
|
if (options.dns && options.dns.length > 0) {
|
|
containerConfig.HostConfig.Dns = options.dns;
|
|
}
|
|
|
|
const createResult = await dockerJsonRequest<{ Id: string }>(
|
|
`/containers/create?name=${encodeURIComponent(containerName)}`,
|
|
{ method: 'POST', body: JSON.stringify(containerConfig) },
|
|
options.envId
|
|
);
|
|
|
|
const containerId = createResult.Id;
|
|
const config = await getDockerConfig(options.envId ?? undefined);
|
|
|
|
try {
|
|
const doWork = async () => {
|
|
// Populate the container's filesystem (e.g. put-archive metadata files)
|
|
// before it starts, so the entrypoint sees them without inflating the Cmd.
|
|
if (options.beforeStart) await options.beforeStart(containerId);
|
|
|
|
// Start container
|
|
await drainResponse(await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, options.envId));
|
|
|
|
// DIAGNOSTIC (issue #1344): one line confirming the helper actually started,
|
|
// with the config that could explain an early death (bind count, network,
|
|
// user). Pairs with the "exited with code" / "EXIT-CODE INDETERMINATE" lines
|
|
// so a user's log shows the full lifecycle of a helper that dies mid-run.
|
|
console.log(`[runContainerWithStreaming] Started ${containerName} (binds=${(options.binds || []).length}, netMode=${options.networkMode ?? 'default'}, user=${options.user ?? 'default'})`);
|
|
|
|
// Create abort controller to cancel stderr stream when container exits
|
|
// On some Docker hosts (e.g. Synology NAS with older kernels), follow=true
|
|
// streams don't close when the container exits, causing indefinite hangs.
|
|
const abortController = new AbortController();
|
|
|
|
// Start stderr streaming (non-blocking - may hang on some hosts). When the
|
|
// caller wants live stdout too (backup helper → restic --json progress), the
|
|
// local path follows both streams; edge keeps stderr-only for now (its
|
|
// progress still lands after exit, an acceptable fallback for the minority
|
|
// edge transport). onStdout is opt-in, so all other callers are unaffected.
|
|
const stderrPromise = (config.connectionType === 'hawser-edge' && config.environmentId)
|
|
? streamEdgeStderr(config.environmentId, containerId, options.onStderr, abortController.signal)
|
|
: streamLocalStderr(containerId, options.envId, options.onStderr, abortController.signal, options.onStdout);
|
|
stderrPromise.catch(() => {}); // Suppress unhandled rejection
|
|
|
|
// Wait for the container to exit — this is the reliable exit signal.
|
|
// A failed /wait must NOT be treated as success: on flaky hosts (Synology,
|
|
// older kernels) the stream can drop, and returning stdout as success would
|
|
// let a backup/restore whose OUTCOME IS UNKNOWN be recorded as a good run.
|
|
// Bounded-retry the wait; if it still can't be determined, fall back to
|
|
// inspecting the container's final State, and if that's also unavailable
|
|
// leave exitCode undefined — the guard below fails closed on undefined.
|
|
// HELPERS_WAIT_MODE (default 'poll'): the exit signal is read by POLLING short
|
|
// inspect calls - nothing stays open for a proxy to cut. The alternative, a single
|
|
// streaming `POST /wait` held open with no bytes for the whole run, gets severed
|
|
// mid-run by a socket-proxy / reverse-proxy / tcp DOCKER_HOST with a short idle
|
|
// timeout, leaving the exit code unreadable even though the helper is fine (#1344).
|
|
// Poll is the safe default; set HELPERS_WAIT_MODE=wait to force the streaming /wait.
|
|
// Applies to every helper this function runs — backup/restore AND the image scanner.
|
|
let exitCode: number | undefined;
|
|
if (process.env.HELPERS_WAIT_MODE !== 'wait') {
|
|
// Bound by the caller's timeout (backup passes a long 'data' tier, the
|
|
// scanner passes 600_000). Fall back to 1h so poll mode never hangs.
|
|
// One line so the log confirms which exit-signal mode is active (#1344).
|
|
console.log(`[runContainerWithStreaming] Awaiting exit of ${options.name ?? containerId.slice(0, 12)} via POLL mode`);
|
|
const deadline = Date.now() + (options.timeout && options.timeout > 0 ? options.timeout : 3_600_000);
|
|
while (exitCode === undefined && Date.now() < deadline) {
|
|
try {
|
|
const insp = await dockerFetch(`/containers/${containerId}/json`, {}, options.envId);
|
|
if (insp.ok) {
|
|
const data = await insp.json() as { State?: { Status?: string; Running?: boolean; Dead?: boolean; ExitCode?: number } };
|
|
const st = data.State;
|
|
if (st && st.Running === false && st.Status === 'exited' && typeof st.ExitCode === 'number') {
|
|
exitCode = st.ExitCode;
|
|
console.log(`[runContainerWithStreaming] Container exited with code: ${exitCode} (poll mode)`);
|
|
break;
|
|
}
|
|
if (st?.Dead || st?.Status === 'removing') break; // gone -> fail-closed diagnostics below
|
|
} else if (insp.status === 404) {
|
|
console.warn(`[runContainerWithStreaming] Poll: ${options.name ?? containerId.slice(0, 12)} not found (404) — container removed`);
|
|
break;
|
|
}
|
|
} catch (err) {
|
|
// A dropped short inspect is cheap to retry — keep polling to the deadline
|
|
// rather than bailing (unlike the streaming /wait path).
|
|
console.warn(`[runContainerWithStreaming] Poll inspect failed for ${options.name ?? containerId.slice(0, 12)}: ${(err as Error).message}`);
|
|
}
|
|
if (exitCode === undefined) await new Promise((r) => setTimeout(r, 1500));
|
|
}
|
|
} else {
|
|
for (let attempt = 0; attempt < 3 && exitCode === undefined; attempt++) {
|
|
try {
|
|
const waitResult = await dockerFetch(`/containers/${containerId}/wait`, { method: 'POST', streaming: true }, options.envId);
|
|
if (!waitResult.ok) await throwDockerError(waitResult);
|
|
const waitData = await waitResult.json() as { StatusCode?: number };
|
|
exitCode = waitData.StatusCode;
|
|
console.log(`[runContainerWithStreaming] Container exited with code: ${exitCode}`);
|
|
} catch (err) {
|
|
// DIAGNOSTIC (issue #1344): include the attempt count so a user whose
|
|
// helper's exit can't be read can tell us whether /wait failed once
|
|
// (transient) or all 3 times (the container vanished / daemon churn).
|
|
console.warn(`[runContainerWithStreaming] Wait attempt ${attempt + 1}/3 failed for ${options.name ?? containerId.slice(0, 12)}: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
}
|
|
if (exitCode === undefined) {
|
|
// Fallback: inspect the container. Only trust a definitively-exited
|
|
// state; a still-running/restarting/unknown state stays undefined.
|
|
let containerStartedAt: string | undefined;
|
|
try {
|
|
const insp = await dockerFetch(`/containers/${containerId}/json`, {}, options.envId);
|
|
if (insp.ok) {
|
|
const data = await insp.json() as { State?: { Status?: string; Running?: boolean; Restarting?: boolean; Dead?: boolean; Paused?: boolean; ExitCode?: number; OOMKilled?: boolean; Error?: string; StartedAt?: string; FinishedAt?: string; Pid?: number } };
|
|
const st = data.State;
|
|
containerStartedAt = st?.StartedAt;
|
|
// DIAGNOSTIC (issue #1344): /wait gave no code and the fallback is
|
|
// about to leave exitCode undefined -> the user sees "could not
|
|
// determine container exit status". Log the FULL final State so a
|
|
// user hitting this can tell us WHY (OOMKilled, Dead, removed by an
|
|
// external reaper, still Running = /wait raced, or a daemon Error).
|
|
console.warn(
|
|
`[runContainerWithStreaming] EXIT-CODE INDETERMINATE for ${options.name ?? containerId.slice(0, 12)} ` +
|
|
`State=${JSON.stringify({
|
|
Status: st?.Status, Running: st?.Running, Restarting: st?.Restarting,
|
|
Dead: st?.Dead, Paused: st?.Paused, ExitCode: st?.ExitCode,
|
|
OOMKilled: st?.OOMKilled, Error: st?.Error,
|
|
StartedAt: st?.StartedAt, FinishedAt: st?.FinishedAt, Pid: st?.Pid,
|
|
})}`
|
|
);
|
|
if (st && st.Running === false && st.Status === 'exited' && typeof st.ExitCode === 'number') {
|
|
exitCode = st.ExitCode;
|
|
console.log(`[runContainerWithStreaming] Resolved exit code via inspect: ${exitCode}`);
|
|
}
|
|
} else {
|
|
console.warn(`[runContainerWithStreaming] EXIT-CODE INDETERMINATE for ${options.name ?? containerId.slice(0, 12)}: inspect returned HTTP ${insp.status} (container may already be gone/removed)`);
|
|
}
|
|
} catch (err) {
|
|
console.warn(`[runContainerWithStreaming] Inspect fallback failed: ${(err as Error).message}`);
|
|
}
|
|
// Still indeterminate -> pull docker events so the log shows WHO/WHAT
|
|
// acted on the container (die/kill/destroy + actor). Missing piece when
|
|
// something external removes the helper mid-run (#1344).
|
|
if (exitCode === undefined) {
|
|
await logRecentContainerEvents(containerId, options.name ?? containerId.slice(0, 12), containerStartedAt, options.envId);
|
|
}
|
|
}
|
|
|
|
// Container exited - abort stderr stream (it may be hanging on some Docker hosts)
|
|
abortController.abort();
|
|
// Give brief moment for any final stderr data to flush
|
|
await Promise.race([stderrPromise, new Promise(r => setTimeout(r, 1000))]);
|
|
|
|
// Container has exited. Now fetch stdout reliably (no race condition).
|
|
const stdout = await fetchContainerStdout(containerId, config, options.envId);
|
|
|
|
// Fail closed on a non-zero OR indeterminate exit. Callers (backup/restore
|
|
// helpers) explicitly assume a non-throwing return means a clean run, so an
|
|
// unknown outcome must be an error, not a silent success.
|
|
if (exitCode === undefined || exitCode !== 0) {
|
|
let stderrText = '';
|
|
try {
|
|
const stderrResponse = await dockerFetch(
|
|
`/containers/${containerId}/logs?stdout=false&stderr=true&follow=false`,
|
|
{},
|
|
options.envId
|
|
);
|
|
const stderrBuffer = Buffer.from(await stderrResponse.arrayBuffer());
|
|
const stderrOutput = demuxDockerStream(stderrBuffer, { separateStreams: true });
|
|
stderrText = typeof stderrOutput === 'string' ? stderrOutput : stderrOutput.stderr;
|
|
} catch {
|
|
// Ignore stderr fetch errors
|
|
}
|
|
// Include stdout in the detail too — restic often writes its error
|
|
// summary there — so callers' error matching (e.g. repo-not-init) works.
|
|
const detail = (stderrText || stdout || 'no output').substring(0, 1000);
|
|
const code = exitCode === undefined ? 'unknown (could not determine container exit status)' : String(exitCode);
|
|
throw new Error(`Container exited with code ${code}: ${detail}`);
|
|
}
|
|
|
|
return stdout;
|
|
};
|
|
|
|
const effectiveTimeout = options.timeout ?? 0;
|
|
if (effectiveTimeout > 0) {
|
|
return await Promise.race([
|
|
doWork(),
|
|
new Promise<never>((_, reject) =>
|
|
setTimeout(() => reject(new Error(
|
|
`Container execution timed out after ${Math.round(effectiveTimeout / 1000)}s`
|
|
)), effectiveTimeout)
|
|
)
|
|
]);
|
|
} else {
|
|
return await doWork();
|
|
}
|
|
} finally {
|
|
// Always cleanup container
|
|
try {
|
|
await drainResponse(await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, options.envId));
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stream only stderr for real-time progress (local/standard mode)
|
|
async function streamLocalStderr(
|
|
containerId: string,
|
|
envId: number | null | undefined,
|
|
onStderr?: (data: string) => void,
|
|
signal?: AbortSignal,
|
|
// Opt-in live STDOUT streaming. When provided, we follow stdout too and route
|
|
// frames by their Docker stream type (processStreamFrames already demuxes 1=stdout
|
|
// / 2=stderr). Callers that don't pass it (terminal/exec/logs) get the exact prior
|
|
// behaviour: stderr-only follow, stdout untouched. Used by the backup helper so
|
|
// restic's --json progress (which it writes to stdout) shows LIVE instead of
|
|
// arriving buffered after the container exits.
|
|
onStdout?: (data: string) => void
|
|
): Promise<void> {
|
|
const wantStdout = onStdout ? 'true' : 'false';
|
|
const response = await dockerFetch(
|
|
`/containers/${containerId}/logs?stdout=${wantStdout}&stderr=true&follow=true`,
|
|
{ streaming: true },
|
|
envId
|
|
);
|
|
|
|
if (!response.ok) await throwDockerError(response);
|
|
|
|
const reader = response.body?.getReader();
|
|
if (!reader) return;
|
|
|
|
// Cancel reader when abort signal fires (container exited)
|
|
signal?.addEventListener('abort', () => reader.cancel(), { once: true });
|
|
|
|
try {
|
|
let buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buffer = Buffer.concat([buffer, Buffer.from(value)]);
|
|
const result = processStreamFrames(buffer, onStdout, onStderr);
|
|
buffer = result.remaining;
|
|
}
|
|
} catch {
|
|
// Reader was cancelled via abort signal - expected
|
|
}
|
|
}
|
|
|
|
// Stream only stderr for real-time progress (edge mode)
|
|
async function streamEdgeStderr(
|
|
environmentId: number,
|
|
containerId: string,
|
|
onStderr?: (data: string) => void,
|
|
signal?: AbortSignal
|
|
): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
let buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
|
|
let resolved = false;
|
|
const finish = () => { if (!resolved) { resolved = true; resolve(); } };
|
|
|
|
// Resolve when abort signal fires (container exited)
|
|
signal?.addEventListener('abort', finish, { once: true });
|
|
|
|
sendEdgeStreamRequest(
|
|
environmentId,
|
|
'GET',
|
|
`/containers/${containerId}/logs?stdout=false&stderr=true&follow=true`,
|
|
{
|
|
onData: (data: string) => {
|
|
if (resolved) return;
|
|
try {
|
|
const decoded = Buffer.from(data, 'base64');
|
|
buffer = Buffer.concat([buffer, decoded]);
|
|
const result = processStreamFrames(buffer, undefined, onStderr);
|
|
buffer = result.remaining;
|
|
} catch {
|
|
// Ignore decode errors
|
|
}
|
|
},
|
|
onEnd: () => finish(),
|
|
onError: (error: string) => {
|
|
// Container exited = success
|
|
if (error.includes('container') && (error.includes('exited') || error.includes('not running'))) {
|
|
finish();
|
|
} else if (!resolved) {
|
|
resolved = true;
|
|
reject(new Error(error));
|
|
}
|
|
}
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
// Extract stdout from a buffer, with raw fallback if frame parsing returns nothing.
|
|
// Mirrors demuxDockerStream's fallback (line ~202-205) for non-multiplexed Docker output.
|
|
function extractStdoutFromBuffer(buffer: Buffer): string {
|
|
const result = processStreamFrames(buffer, undefined, undefined);
|
|
|
|
if (buffer.length > 100000) {
|
|
console.log(
|
|
`[extractStdoutFromBuffer] Raw buffer: ${buffer.length} bytes, stdout: ${result.stdout.length} chars, ` +
|
|
`remaining: ${result.remaining.length} bytes`
|
|
);
|
|
}
|
|
|
|
if (result.stdout.length === 0 && buffer.length > 0) {
|
|
console.warn(
|
|
`[extractStdoutFromBuffer] Frame parsing empty but buffer has ${buffer.length} bytes. ` +
|
|
`First 16 bytes: [${Array.from(buffer.slice(0, 16)).join(', ')}]. Falling back to raw.`
|
|
);
|
|
return buffer.toString('utf-8').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
|
|
}
|
|
|
|
// If there's remaining data after frame parsing, append it as raw text
|
|
if (result.remaining.length > 0) {
|
|
console.warn(
|
|
`[extractStdoutFromBuffer] ${result.remaining.length} bytes remaining after frame parsing, appending as raw`
|
|
);
|
|
const rawTail = result.remaining.toString('utf-8').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
|
|
return result.stdout + rawTail;
|
|
}
|
|
|
|
return result.stdout;
|
|
}
|
|
|
|
// Fetch stdout after container has exited (reliable, no race)
|
|
async function fetchContainerStdout(
|
|
containerId: string,
|
|
config: Awaited<ReturnType<typeof getDockerConfig>>,
|
|
envId: number | null | undefined
|
|
): Promise<string> {
|
|
if (config.connectionType === 'hawser-edge' && config.environmentId) {
|
|
const response = await sendEdgeRequest(
|
|
config.environmentId,
|
|
'GET',
|
|
`/containers/${containerId}/logs?stdout=true&stderr=false&follow=false`
|
|
);
|
|
if (!response.body) return '';
|
|
const bodyData = typeof response.body === 'string'
|
|
? Buffer.from(response.body, 'base64')
|
|
: Buffer.from(response.body);
|
|
return extractStdoutFromBuffer(bodyData);
|
|
}
|
|
|
|
// Local/standard mode - read via streaming to handle large Docker log responses
|
|
const response = await dockerFetch(
|
|
`/containers/${containerId}/logs?stdout=true&stderr=false&follow=false`,
|
|
{ streaming: true },
|
|
envId
|
|
);
|
|
|
|
if (!response.ok) await throwDockerError(response);
|
|
|
|
const reader = response.body?.getReader();
|
|
if (!reader) {
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
return extractStdoutFromBuffer(buffer);
|
|
}
|
|
const chunks: Uint8Array[] = [];
|
|
let totalSize = 0;
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
if (value) {
|
|
chunks.push(value);
|
|
totalSize += value.length;
|
|
}
|
|
}
|
|
const buffer = Buffer.concat(chunks, totalSize);
|
|
|
|
return extractStdoutFromBuffer(buffer);
|
|
}
|
|
|
|
// Push image to registry
|
|
export async function pushImage(
|
|
imageTag: string,
|
|
authConfig: { username?: string; password?: string; serveraddress: string },
|
|
onProgress?: (data: any) => void,
|
|
envId?: number | null
|
|
): Promise<void> {
|
|
// Parse tag to get registry info
|
|
const [repo, tag = 'latest'] = imageTag.split(':');
|
|
|
|
const authHeader = encodeRegistryAuth(authConfig);
|
|
logAuthDiagnostics(
|
|
'[Push]',
|
|
authConfig.serveraddress,
|
|
authConfig.serveraddress,
|
|
authConfig.username ?? '',
|
|
authConfig.password ?? '',
|
|
authHeader
|
|
);
|
|
|
|
const pushUrl = `/images/${encodeURIComponent(imageTag)}/push`;
|
|
console.log(`[Push] POST ${pushUrl} headers=X-Registry-Auth`);
|
|
|
|
const response = await dockerFetch(
|
|
pushUrl,
|
|
{
|
|
method: 'POST',
|
|
streaming: true,
|
|
headers: {
|
|
'X-Registry-Auth': authHeader
|
|
}
|
|
},
|
|
envId
|
|
);
|
|
|
|
console.log(`[Push] response status=${response.status} ${response.statusText}`);
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text();
|
|
console.error(`[Push] error body: ${error}`);
|
|
throw new Error(`Failed to push image: ${error}`);
|
|
}
|
|
|
|
// Stream the response for progress updates
|
|
const reader = response.body?.getReader();
|
|
if (!reader) return;
|
|
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() || '';
|
|
|
|
for (const line of lines) {
|
|
if (line.trim()) {
|
|
try {
|
|
const data = JSON.parse(line);
|
|
if (data.error) {
|
|
console.error(`[Push] stream error: ${line}`);
|
|
throw new Error(data.error);
|
|
}
|
|
if (onProgress) onProgress(data);
|
|
} catch (e: any) {
|
|
if (e.message && !e.message.includes('JSON')) {
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Container filesystem operations
|
|
export interface FileEntry {
|
|
name: string;
|
|
type: 'file' | 'directory' | 'symlink' | 'other';
|
|
size: number;
|
|
permissions: string;
|
|
owner: string;
|
|
group: string;
|
|
modified: string;
|
|
linkTarget?: string;
|
|
readonly?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Parse ls -la output into FileEntry array
|
|
* Handles multiple formats:
|
|
* - GNU ls with --time-style=long-iso: drwxr-xr-x 2 root root 4096 2024-12-08 10:30 dirname
|
|
* - Standard GNU ls: drwxr-xr-x 2 root root 4096 Dec 8 10:30 dirname
|
|
* - Busybox ls: drwxr-xr-x 2 root root 4096 Dec 8 10:30 dirname
|
|
*/
|
|
function parseLsOutput(output: string): FileEntry[] {
|
|
const lines = output.trim().split('\n');
|
|
const entries: FileEntry[] = [];
|
|
const currentYear = new Date().getFullYear();
|
|
|
|
// Month name to number mapping
|
|
const monthMap: Record<string, string> = {
|
|
Jan: '01',
|
|
Feb: '02',
|
|
Mar: '03',
|
|
Apr: '04',
|
|
May: '05',
|
|
Jun: '06',
|
|
Jul: '07',
|
|
Aug: '08',
|
|
Sep: '09',
|
|
Oct: '10',
|
|
Nov: '11',
|
|
Dec: '12'
|
|
};
|
|
|
|
for (const line of lines) {
|
|
// Skip total line, empty lines, and error messages
|
|
if (!line || line.startsWith('total ') || line.includes('cannot access') || line.includes('Permission denied')) continue;
|
|
|
|
let typeChar: string;
|
|
let perms: string;
|
|
let owner: string;
|
|
let group: string;
|
|
let sizeStr: string;
|
|
let date: string;
|
|
let time: string;
|
|
let nameAndLink: string;
|
|
|
|
// Try ISO format first (GNU ls with --time-style=long-iso)
|
|
// Format: drwxr-xr-x 2 root root 4096 2024-12-08 10:30 dirname
|
|
// With ACL: drwxr-xr-x+ 2 root root 4096 2024-12-08 10:30 dirname
|
|
// With extended attrs: drwxr-xr-x@ 2 root root 4096 2024-12-08 10:30 dirname
|
|
const isoMatch = line.match(
|
|
/^([dlcbps-])([rwxsStT-]{9})[+@.]?\s+\d+\s+(\S+)\s+(\S+)\s+(\d+)\s+(\d{2,4}-\d{2}(?:-\d{2})?)\s+(\d{2}:\d{2})\s+(.+)$/
|
|
);
|
|
|
|
if (isoMatch) {
|
|
[, typeChar, perms, owner, group, sizeStr, date, time, nameAndLink] = isoMatch;
|
|
// Normalize date to YYYY-MM-DD format
|
|
if (date.length <= 5) {
|
|
// Format: MM-DD (no year)
|
|
date = `${currentYear}-${date}`;
|
|
} else if (!date.includes('-', 4)) {
|
|
// Format: YYYY-MM (no day)
|
|
date = `${date}-01`;
|
|
}
|
|
} else {
|
|
// Try standard format (GNU/busybox without --time-style)
|
|
// Format: drwxr-xr-x 2 root root 4096 Dec 8 10:30 dirname
|
|
// Or: drwxr-xr-x 2 root root 4096 Dec 8 10:30 dirname
|
|
// Or with year: drwxr-xr-x 2 root root 4096 Dec 8 2023 dirname
|
|
// With ACL/attrs: drwxr-xr-x+ or drwxr-xr-x@ or drwxr-xr-x.
|
|
const stdMatch = line.match(
|
|
/^([dlcbps-])([rwxsStT-]{9})[+@.]?\s+\d+\s+(\S+)\s+(\S+)\s+(\d+)\s+(\w{3})\s+(\d{1,2})\s+(\d{1,2}:\d{2}|\d{4})\s+(.+)$/
|
|
);
|
|
|
|
if (!stdMatch) {
|
|
// Try device file format (block/char devices have major,minor instead of size)
|
|
// Format: crw-rw-rw- 1 root root 1, 3 Dec 8 10:30 null
|
|
const deviceMatch = line.match(
|
|
/^([cb])([rwxsStT-]{9})[+@.]?\s+\d+\s+(\S+)\s+(\S+)\s+(\d+),\s*(\d+)\s+(\w{3})\s+(\d{1,2})\s+(\d{1,2}:\d{2}|\d{4})\s+(.+)$/
|
|
);
|
|
|
|
if (deviceMatch) {
|
|
let monthStr: string;
|
|
let dayStr: string;
|
|
let timeOrYear: string;
|
|
[, typeChar, perms, owner, group, , , monthStr, dayStr, timeOrYear, nameAndLink] = deviceMatch;
|
|
sizeStr = '0'; // Device files don't have a traditional size
|
|
|
|
const month = monthMap[monthStr] || '01';
|
|
const day = dayStr.padStart(2, '0');
|
|
|
|
if (timeOrYear.includes(':')) {
|
|
time = timeOrYear;
|
|
date = `${currentYear}-${month}-${day}`;
|
|
} else {
|
|
time = '00:00';
|
|
date = `${timeOrYear}-${month}-${day}`;
|
|
}
|
|
} else {
|
|
continue;
|
|
}
|
|
} else {
|
|
let monthStr: string;
|
|
let dayStr: string;
|
|
let timeOrYear: string;
|
|
[, typeChar, perms, owner, group, sizeStr, monthStr, dayStr, timeOrYear, nameAndLink] =
|
|
stdMatch;
|
|
|
|
const month = monthMap[monthStr] || '01';
|
|
const day = dayStr.padStart(2, '0');
|
|
|
|
// timeOrYear is either "HH:MM" or "YYYY"
|
|
if (timeOrYear.includes(':')) {
|
|
time = timeOrYear;
|
|
date = `${currentYear}-${month}-${day}`;
|
|
} else {
|
|
time = '00:00';
|
|
date = `${timeOrYear}-${month}-${day}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
let type: FileEntry['type'];
|
|
switch (typeChar) {
|
|
case 'd':
|
|
type = 'directory';
|
|
break;
|
|
case 'l':
|
|
type = 'symlink';
|
|
break;
|
|
case '-':
|
|
type = 'file';
|
|
break;
|
|
default:
|
|
type = 'other';
|
|
}
|
|
|
|
let name = nameAndLink;
|
|
let linkTarget: string | undefined;
|
|
|
|
// Handle symlinks: "name -> target"
|
|
if (type === 'symlink' && nameAndLink.includes(' -> ')) {
|
|
const parts = nameAndLink.split(' -> ');
|
|
name = parts[0];
|
|
linkTarget = parts.slice(1).join(' -> ');
|
|
}
|
|
|
|
// Skip . and .. entries
|
|
if (name === '.' || name === '..') continue;
|
|
|
|
// Check if file is read-only (owner doesn't have write permission)
|
|
// perms format: rwxrwxrwx - index 1 is owner write
|
|
const isReadonly = perms.charAt(1) !== 'w';
|
|
|
|
entries.push({
|
|
name,
|
|
type,
|
|
size: parseInt(sizeStr, 10),
|
|
permissions: perms,
|
|
owner,
|
|
group,
|
|
modified: `${date}T${time}:00`,
|
|
linkTarget,
|
|
readonly: isReadonly
|
|
});
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
/**
|
|
* List files in a container directory
|
|
* Tries multiple ls command variants for compatibility with different containers.
|
|
*/
|
|
export async function listContainerDirectory(
|
|
containerId: string,
|
|
path: string,
|
|
envId?: number | null,
|
|
useSimpleLs?: boolean
|
|
): Promise<{ path: string; entries: FileEntry[] }> {
|
|
// Sanitize path to prevent command injection
|
|
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
|
|
|
// Commands to try in order of preference (includes /usr/sbin/ls for Wolfi/busybox images)
|
|
const commands = useSimpleLs
|
|
? [
|
|
['ls', '-la', safePath],
|
|
['/bin/ls', '-la', safePath],
|
|
['/usr/bin/ls', '-la', safePath],
|
|
['/usr/sbin/ls', '-la', safePath],
|
|
]
|
|
: [
|
|
['ls', '-la', '--time-style=long-iso', safePath],
|
|
['ls', '-la', safePath],
|
|
['/bin/ls', '-la', safePath],
|
|
['/usr/bin/ls', '-la', safePath],
|
|
['/usr/sbin/ls', '-la', safePath],
|
|
];
|
|
|
|
let lastError: Error | null = null;
|
|
|
|
for (const cmd of commands) {
|
|
try {
|
|
const output = await execInContainer(containerId, cmd, envId);
|
|
const entries = parseLsOutput(output);
|
|
return { path: safePath, entries };
|
|
} catch (err: any) {
|
|
lastError = err;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
throw lastError || new Error('Failed to list directory: no working ls command found');
|
|
}
|
|
|
|
/**
|
|
* Get file/directory archive from container (for download)
|
|
* Returns the raw Docker API response for streaming
|
|
*/
|
|
export async function getContainerArchive(
|
|
containerId: string,
|
|
path: string,
|
|
envId?: number | null
|
|
): Promise<Response> {
|
|
// Sanitize path
|
|
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
|
|
|
const response = await dockerFetch(
|
|
`/containers/${containerId}/archive?path=${encodeURIComponent(safePath)}`,
|
|
{ streaming: true },
|
|
envId
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text();
|
|
throw new Error(`Failed to get archive: ${error}`);
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* Upload files to container (tar archive)
|
|
*/
|
|
export async function putContainerArchive(
|
|
containerId: string,
|
|
path: string,
|
|
tarData: ArrayBuffer | Uint8Array,
|
|
envId?: number | null
|
|
): Promise<void> {
|
|
// Sanitize path
|
|
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
|
|
|
const response = await dockerFetch(
|
|
`/containers/${containerId}/archive?path=${encodeURIComponent(safePath)}`,
|
|
{
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/x-tar'
|
|
},
|
|
body: tarData as BodyInit
|
|
},
|
|
envId
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text();
|
|
throw new Error(`Failed to upload archive: ${error}`);
|
|
}
|
|
await drainResponse(response);
|
|
}
|
|
|
|
/**
|
|
* Get stat info for a file/directory in container
|
|
*/
|
|
export async function statContainerPath(
|
|
containerId: string,
|
|
path: string,
|
|
envId?: number | null
|
|
): Promise<{ name: string; size: number; mode: number; mtime: string; linkTarget?: string; isDir: boolean }> {
|
|
// Sanitize path
|
|
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
|
|
|
const response = await dockerFetch(
|
|
`/containers/${containerId}/archive?path=${encodeURIComponent(safePath)}`,
|
|
{ method: 'HEAD' },
|
|
envId
|
|
);
|
|
|
|
if (!response.ok) {
|
|
await drainResponse(response);
|
|
throw new Error(`Path not found: ${safePath}`);
|
|
}
|
|
|
|
// Docker returns stat info in X-Docker-Container-Path-Stat header as base64 JSON
|
|
const statHeader = response.headers.get('X-Docker-Container-Path-Stat');
|
|
if (!statHeader) {
|
|
throw new Error('No stat info returned');
|
|
}
|
|
|
|
const statJson = Buffer.from(statHeader, 'base64').toString('utf-8');
|
|
const stat = JSON.parse(statJson);
|
|
// Go's os.FileMode encodes the file type in the high bits. ModeDir = 1<<31.
|
|
// Docker emits mode in that format, so the directory bit lives at 0x80000000.
|
|
return { ...stat, isDir: (stat.mode & 0x80000000) !== 0 };
|
|
}
|
|
|
|
/**
|
|
* Read file content from container
|
|
* Uses cat command via exec to read file contents
|
|
*/
|
|
export async function readContainerFile(
|
|
containerId: string,
|
|
path: string,
|
|
envId?: number | null
|
|
): Promise<string> {
|
|
// Sanitize path to prevent command injection
|
|
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
|
|
|
// Use cat to read file content
|
|
const output = await execInContainer(containerId, ['cat', safePath], envId);
|
|
return output;
|
|
}
|
|
|
|
/**
|
|
* Write file content to container
|
|
* Uses Docker archive API to write file
|
|
*/
|
|
export async function writeContainerFile(
|
|
containerId: string,
|
|
path: string,
|
|
content: string,
|
|
envId?: number | null
|
|
): Promise<void> {
|
|
// Sanitize path
|
|
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
|
|
|
// Get directory and filename
|
|
const parts = safePath.split('/');
|
|
const filename = parts.pop() || 'file';
|
|
const directory = parts.join('/') || '/';
|
|
|
|
// Create a minimal tar archive with the file
|
|
// Tar format: 512-byte header + file content + padding to 512-byte boundary
|
|
const contentBytes = new TextEncoder().encode(content);
|
|
const fileSize = contentBytes.length;
|
|
|
|
// Calculate total tar size (header + content + padding + two 512-byte end blocks)
|
|
const paddedContentSize = Math.ceil(fileSize / 512) * 512;
|
|
const tarSize = 512 + paddedContentSize + 1024; // header + padded content + end blocks
|
|
|
|
const tarData = new Uint8Array(tarSize);
|
|
|
|
// Write tar header (512 bytes)
|
|
// File name (100 bytes)
|
|
const filenameBytes = new TextEncoder().encode(filename);
|
|
tarData.set(filenameBytes.slice(0, 100), 0);
|
|
|
|
// File mode (8 bytes octal) - 0644
|
|
tarData.set(new TextEncoder().encode('0000644\0'), 100);
|
|
|
|
// UID (8 bytes octal) - 0
|
|
tarData.set(new TextEncoder().encode('0000000\0'), 108);
|
|
|
|
// GID (8 bytes octal) - 0
|
|
tarData.set(new TextEncoder().encode('0000000\0'), 116);
|
|
|
|
// File size (12 bytes octal)
|
|
const sizeOctal = fileSize.toString(8).padStart(11, '0') + '\0';
|
|
tarData.set(new TextEncoder().encode(sizeOctal), 124);
|
|
|
|
// Mtime (12 bytes octal) - current time
|
|
const mtime = Math.floor(Date.now() / 1000).toString(8).padStart(11, '0') + '\0';
|
|
tarData.set(new TextEncoder().encode(mtime), 136);
|
|
|
|
// Checksum placeholder (8 bytes) - filled with spaces initially
|
|
tarData.set(new TextEncoder().encode(' '), 148);
|
|
|
|
// Type flag (1 byte) - '0' for regular file
|
|
tarData[156] = 48; // ASCII '0'
|
|
|
|
// Link name (100 bytes) - empty for regular files
|
|
// Already zeros
|
|
|
|
// USTAR magic (6 bytes) + version (2 bytes)
|
|
tarData.set(new TextEncoder().encode('ustar\0'), 257);
|
|
tarData.set(new TextEncoder().encode('00'), 263);
|
|
|
|
// Owner name (32 bytes) - root
|
|
tarData.set(new TextEncoder().encode('root'), 265);
|
|
|
|
// Group name (32 bytes) - root
|
|
tarData.set(new TextEncoder().encode('root'), 297);
|
|
|
|
// Calculate and write checksum
|
|
let checksum = 0;
|
|
for (let i = 0; i < 512; i++) {
|
|
checksum += tarData[i];
|
|
}
|
|
const checksumOctal = checksum.toString(8).padStart(6, '0') + '\0 ';
|
|
tarData.set(new TextEncoder().encode(checksumOctal), 148);
|
|
|
|
// Write file content after header
|
|
tarData.set(contentBytes, 512);
|
|
|
|
// Upload to container
|
|
await putContainerArchive(containerId, directory, tarData, envId);
|
|
}
|
|
|
|
/**
|
|
* Create an empty file in container
|
|
*/
|
|
export async function createContainerFile(
|
|
containerId: string,
|
|
path: string,
|
|
envId?: number | null
|
|
): Promise<void> {
|
|
// Sanitize path to prevent command injection
|
|
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
|
|
|
// Use touch to create empty file
|
|
await execInContainer(containerId, ['touch', safePath], envId);
|
|
}
|
|
|
|
/**
|
|
* Create a directory in container
|
|
*/
|
|
export async function createContainerDirectory(
|
|
containerId: string,
|
|
path: string,
|
|
envId?: number | null
|
|
): Promise<void> {
|
|
// Sanitize path to prevent command injection
|
|
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
|
|
|
// Use mkdir -p to create directory (and parents if needed)
|
|
await execInContainer(containerId, ['mkdir', '-p', safePath], envId);
|
|
}
|
|
|
|
/**
|
|
* Delete a file or directory in container
|
|
*/
|
|
export async function deleteContainerPath(
|
|
containerId: string,
|
|
path: string,
|
|
envId?: number | null
|
|
): Promise<void> {
|
|
// Sanitize path to prevent command injection
|
|
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
|
|
|
// Safety check: don't allow deleting root or critical paths
|
|
const dangerousPaths = ['/', '/bin', '/sbin', '/usr', '/lib', '/lib64', '/etc', '/var', '/root', '/home'];
|
|
if (dangerousPaths.includes(safePath) || safePath === '') {
|
|
throw new Error('Cannot delete critical system path');
|
|
}
|
|
|
|
// Use rm -rf to delete file or directory
|
|
await execInContainer(containerId, ['rm', '-rf', safePath], envId);
|
|
}
|
|
|
|
/**
|
|
* Rename/move a file or directory in container
|
|
*/
|
|
export async function renameContainerPath(
|
|
containerId: string,
|
|
oldPath: string,
|
|
newPath: string,
|
|
envId?: number | null
|
|
): Promise<void> {
|
|
// Sanitize paths to prevent command injection
|
|
const safeOldPath = oldPath.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
|
const safeNewPath = newPath.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
|
|
|
// Use mv to rename
|
|
await execInContainer(containerId, ['mv', safeOldPath, safeNewPath], envId);
|
|
}
|
|
|
|
/**
|
|
* Change permissions of a file or directory in container
|
|
*/
|
|
export async function chmodContainerPath(
|
|
containerId: string,
|
|
path: string,
|
|
mode: string,
|
|
recursive: boolean = false,
|
|
envId?: number | null
|
|
): Promise<void> {
|
|
// Sanitize path to prevent command injection
|
|
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
|
|
|
// Validate mode (should be octal like 755 or symbolic like u+x)
|
|
if (!/^[0-7]{3,4}$/.test(mode) && !/^[ugoa]*[+-=][rwxXst]+$/.test(mode)) {
|
|
throw new Error('Invalid chmod mode');
|
|
}
|
|
|
|
// Build command
|
|
const cmd = recursive ? ['chmod', '-R', mode, safePath] : ['chmod', mode, safePath];
|
|
await execInContainer(containerId, cmd, envId);
|
|
}
|
|
|
|
// Volume browsing and export helpers
|
|
|
|
const VOLUME_HELPER_IMAGE = 'busybox:latest';
|
|
const VOLUME_MOUNT_PATH = '/volume';
|
|
const VOLUME_HELPER_TTL_SECONDS = 300; // 5 minutes TTL for helper containers
|
|
|
|
// Cache for volume helper containers: key = `${volumeName}:${envId ?? 'local'}` -> containerId
|
|
const volumeHelperCache = new Map<string, { containerId: string; expiresAt: number }>();
|
|
|
|
/**
|
|
* Get cache key for a volume helper container
|
|
*/
|
|
function getVolumeCacheKey(volumeName: string, envId?: number | null): string {
|
|
return `${volumeName}:${envId ?? 'local'}`;
|
|
}
|
|
|
|
/**
|
|
* Ensure the volume helper image (busybox) is available, pulling if necessary
|
|
*/
|
|
export async function ensureVolumeHelperImage(envId?: number | null): Promise<void> {
|
|
// Check if image exists
|
|
const response = await dockerFetch(`/images/${encodeURIComponent(VOLUME_HELPER_IMAGE)}/json`, {}, envId);
|
|
|
|
if (response.ok) {
|
|
await drainResponse(response);
|
|
return; // Image exists
|
|
}
|
|
|
|
// Image not found, pull it
|
|
console.log(`Pulling ${VOLUME_HELPER_IMAGE} for volume browsing...`);
|
|
const authHeaders = await buildRegistryAuthHeader(VOLUME_HELPER_IMAGE);
|
|
const pullResponse = await dockerFetch(
|
|
`/images/create?fromImage=${encodeURIComponent(VOLUME_HELPER_IMAGE)}`,
|
|
{ method: 'POST', headers: authHeaders },
|
|
envId
|
|
);
|
|
|
|
if (!pullResponse.ok) {
|
|
const error = await pullResponse.text();
|
|
throw new Error(`Failed to pull ${VOLUME_HELPER_IMAGE}: ${error}`);
|
|
}
|
|
|
|
// Wait for pull to complete by consuming the stream
|
|
const reader = pullResponse.body?.getReader();
|
|
if (reader) {
|
|
while (true) {
|
|
const { done } = await reader.read();
|
|
if (done) break;
|
|
}
|
|
}
|
|
|
|
console.log(`Successfully pulled ${VOLUME_HELPER_IMAGE}`);
|
|
}
|
|
|
|
/**
|
|
* Check if a container exists and is running
|
|
*/
|
|
async function isContainerRunning(containerId: string, envId?: number | null): Promise<boolean> {
|
|
try {
|
|
const response = await dockerFetch(`/containers/${containerId}/json`, {}, envId);
|
|
if (!response.ok) {
|
|
await response.text(); // Consume body to release socket
|
|
return false;
|
|
}
|
|
const info = await response.json();
|
|
return info.State?.Running === true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get or create a helper container for volume browsing.
|
|
* Reuses existing containers from cache for better performance.
|
|
* Returns the container ID.
|
|
* @param readOnly - If true, mount volume read-only (default). If false, mount writable.
|
|
*/
|
|
export async function getOrCreateVolumeHelperContainer(
|
|
volumeName: string,
|
|
envId?: number | null,
|
|
readOnly: boolean = true
|
|
): Promise<string> {
|
|
// Include readOnly in cache key since we need different containers for ro/rw
|
|
const cacheKey = `${getVolumeCacheKey(volumeName, envId)}:${readOnly ? 'ro' : 'rw'}`;
|
|
const now = Date.now();
|
|
|
|
// Check cache for existing container
|
|
const cached = volumeHelperCache.get(cacheKey);
|
|
if (cached && cached.expiresAt > now) {
|
|
// Verify container is still running
|
|
if (await isContainerRunning(cached.containerId, envId)) {
|
|
// Refresh expiry time on access
|
|
cached.expiresAt = now + VOLUME_HELPER_TTL_SECONDS * 1000;
|
|
return cached.containerId;
|
|
}
|
|
// Container no longer running, remove from cache
|
|
volumeHelperCache.delete(cacheKey);
|
|
}
|
|
|
|
// Ensure helper image is available (auto-pull if missing)
|
|
await ensureVolumeHelperImage(envId);
|
|
|
|
// Generate a unique container name based on volume name
|
|
const safeVolumeName = volumeName.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 50);
|
|
const rwSuffix = readOnly ? 'ro' : 'rw';
|
|
const containerName = `dockhand-browse-${safeVolumeName}-${rwSuffix}-${Date.now().toString(36)}`;
|
|
|
|
// Create a temporary container with the volume mounted
|
|
const bindMount = readOnly
|
|
? `${volumeName}:${VOLUME_MOUNT_PATH}:ro`
|
|
: `${volumeName}:${VOLUME_MOUNT_PATH}`;
|
|
|
|
const containerConfig = {
|
|
Image: VOLUME_HELPER_IMAGE,
|
|
Cmd: ['sleep', 'infinity'], // Keep alive indefinitely (managed by cache TTL)
|
|
HostConfig: {
|
|
Binds: [bindMount],
|
|
AutoRemove: false
|
|
},
|
|
Labels: {
|
|
'dockhand.volume.helper': 'true',
|
|
'dockhand.volume.name': volumeName,
|
|
'dockhand.volume.readonly': String(readOnly)
|
|
}
|
|
};
|
|
|
|
const response = await dockerJsonRequest<{ Id: string }>(
|
|
`/containers/create?name=${encodeURIComponent(containerName)}`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify(containerConfig)
|
|
},
|
|
envId
|
|
);
|
|
|
|
const containerId = response.Id;
|
|
|
|
// Start the container
|
|
await drainResponse(await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, envId));
|
|
|
|
// Cache the container
|
|
volumeHelperCache.set(cacheKey, {
|
|
containerId,
|
|
expiresAt: now + VOLUME_HELPER_TTL_SECONDS * 1000
|
|
});
|
|
|
|
return containerId;
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use getOrCreateVolumeHelperContainer instead
|
|
* Create a temporary container with a volume mounted for browsing/export
|
|
* Returns the container ID. Caller is responsible for removing the container.
|
|
*/
|
|
export async function createVolumeHelperContainer(
|
|
volumeName: string,
|
|
envId?: number | null
|
|
): Promise<string> {
|
|
return getOrCreateVolumeHelperContainer(volumeName, envId);
|
|
}
|
|
|
|
/**
|
|
* Release a cached volume helper container when done browsing.
|
|
* This removes the container from cache and stops/removes it from Docker.
|
|
* Cleans up both ro and rw variants if they exist.
|
|
*/
|
|
export async function releaseVolumeHelperContainer(
|
|
volumeName: string,
|
|
envId?: number | null
|
|
): Promise<void> {
|
|
const baseCacheKey = getVolumeCacheKey(volumeName, envId);
|
|
|
|
// Clean up both read-only and read-write variants
|
|
for (const suffix of [':ro', ':rw']) {
|
|
const cacheKey = baseCacheKey + suffix;
|
|
const cached = volumeHelperCache.get(cacheKey);
|
|
|
|
if (cached) {
|
|
volumeHelperCache.delete(cacheKey);
|
|
await removeVolumeHelperContainer(cached.containerId, envId).catch(err => {
|
|
console.warn('Failed to cleanup volume helper container:', err);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleanup expired volume helper containers.
|
|
* Called periodically to remove containers that have exceeded their TTL.
|
|
*/
|
|
export async function cleanupExpiredVolumeHelpers(): Promise<void> {
|
|
const now = Date.now();
|
|
const expiredEntries: Array<{ key: string; containerId: string; envId?: number | null }> = [];
|
|
|
|
for (const [key, cached] of volumeHelperCache.entries()) {
|
|
if (cached.expiresAt <= now) {
|
|
// Parse envId from key: "volumeName:envId" or "volumeName:local"
|
|
const [, envIdStr] = key.split(':');
|
|
const envId = envIdStr === 'local' ? null : parseInt(envIdStr);
|
|
expiredEntries.push({ key, containerId: cached.containerId, envId });
|
|
}
|
|
}
|
|
|
|
// Remove from cache and cleanup containers
|
|
for (const { key, containerId, envId } of expiredEntries) {
|
|
volumeHelperCache.delete(key);
|
|
removeVolumeHelperContainer(containerId, envId ?? undefined).catch(err => {
|
|
console.warn('Failed to cleanup expired volume helper container:', err);
|
|
});
|
|
}
|
|
|
|
if (expiredEntries.length > 0) {
|
|
console.log(`Cleaned up ${expiredEntries.length} expired volume helper container(s)`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a volume helper container
|
|
*/
|
|
export async function removeVolumeHelperContainer(
|
|
containerId: string,
|
|
envId?: number | null
|
|
): Promise<void> {
|
|
try {
|
|
// Stop the container first (force)
|
|
await drainResponse(await dockerFetch(`/containers/${containerId}/stop?t=1`, { method: 'POST' }, envId));
|
|
} catch {
|
|
// Ignore stop errors
|
|
}
|
|
|
|
// Remove the container
|
|
await drainResponse(await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, envId));
|
|
}
|
|
|
|
/**
|
|
* Cleanup all stale volume helper containers on a specific environment.
|
|
* Finds containers with label dockhand.volume.helper=true and removes them.
|
|
* Called on startup to clean up containers from previous process runs.
|
|
*/
|
|
async function cleanupStaleVolumeHelpersForEnv(envId?: number | null): Promise<number> {
|
|
try {
|
|
// Query containers with our helper label
|
|
const filters = JSON.stringify({ label: ['dockhand.volume.helper=true'] });
|
|
const response = await dockerFetch(
|
|
`/containers/json?all=true&filters=${encodeURIComponent(filters)}`,
|
|
{},
|
|
envId
|
|
);
|
|
|
|
if (!response.ok) {
|
|
await response.text(); // Consume body to release socket
|
|
return 0;
|
|
}
|
|
|
|
const containers: Array<{ Id: string; Names: string[] }> = await response.json();
|
|
let removed = 0;
|
|
|
|
for (const container of containers) {
|
|
try {
|
|
await removeVolumeHelperContainer(container.Id, envId);
|
|
removed++;
|
|
} catch (err) {
|
|
console.warn(`Failed to remove stale helper container ${container.Names?.[0] || container.Id}:`, err);
|
|
}
|
|
}
|
|
|
|
return removed;
|
|
} catch (err: any) {
|
|
// Don't spam logs for expected connection failures (offline envs, TLS mismatches, etc.)
|
|
const msg = err?.message || String(err);
|
|
const isExpected = /not connected|offline|unreachable|fetch failed|EPROTO|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EHOSTUNREACH/i.test(msg);
|
|
if (!isExpected) {
|
|
console.warn(`Failed to query stale volume helpers for env ${envId}:`, msg);
|
|
}
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleanup stale volume helper containers across all environments.
|
|
* Should be called on startup to clean up orphaned containers.
|
|
* @param environments - Optional pre-fetched environments (avoids dynamic import in production)
|
|
*/
|
|
export async function cleanupStaleVolumeHelpers(environments: Array<{ id: number }>): Promise<void> {
|
|
if (!environments || environments.length === 0) return;
|
|
|
|
let totalRemoved = 0;
|
|
|
|
for (const env of environments) {
|
|
totalRemoved += await cleanupStaleVolumeHelpersForEnv(env.id);
|
|
}
|
|
|
|
if (totalRemoved > 0) {
|
|
console.log(`[Volume Helper] Removed ${totalRemoved} stale container(s)`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reap orphaned backup/restore helper containers on a specific environment left
|
|
* by a crashed process. Only removes helpers THIS installation owns (name prefix +
|
|
* matching dockhand.instance label); a missing/foreign label is skipped so a
|
|
* co-located Dockhand's live helper on a shared daemon is never touched.
|
|
*/
|
|
async function cleanupStaleBackupHelpersForEnv(myInstance: string, envId?: number | null): Promise<number> {
|
|
try {
|
|
const containers = await listContainers(true, envId ?? undefined);
|
|
let removed = 0;
|
|
for (const c of containers) {
|
|
if (!isOwnedBackupHelper(c.name, c.labels, myInstance)) continue;
|
|
try {
|
|
await removeVolumeHelperContainer(c.id, envId ?? undefined);
|
|
removed++;
|
|
} catch (err) {
|
|
console.warn(`Failed to remove orphan backup helper ${c.name || c.id}:`, err);
|
|
}
|
|
}
|
|
return removed;
|
|
} catch (err: any) {
|
|
// Don't spam logs for expected connection failures (offline envs, TLS mismatches, etc.)
|
|
const msg = err?.message || String(err);
|
|
const isExpected = /not connected|offline|unreachable|fetch failed|EPROTO|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EHOSTUNREACH/i.test(msg);
|
|
if (!isExpected) {
|
|
console.warn(`Failed to query orphan backup helpers for env ${envId}:`, msg);
|
|
}
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reap orphaned backup/restore helper containers across all environments (plus
|
|
* the local daemon when there are none). Call on startup, BEFORE the backup engine
|
|
* reconciles, so no live helper from a crashed run is still mid-operation. Returns
|
|
* the number removed. Only reaps helpers this installation owns (fail-closed on the
|
|
* instance label).
|
|
*/
|
|
export async function cleanupStaleBackupHelpers(environments: Array<{ id: number }>): Promise<number> {
|
|
const myInstance = await getInstanceId();
|
|
const envIds: (number | undefined)[] = environments.length ? environments.map((e) => e.id) : [undefined];
|
|
let totalRemoved = 0;
|
|
for (const envId of envIds) {
|
|
totalRemoved += await cleanupStaleBackupHelpersForEnv(myInstance, envId);
|
|
}
|
|
if (totalRemoved > 0) {
|
|
console.log(`[Backups] Reaped ${totalRemoved} orphan helper container(s) from a previous run`);
|
|
}
|
|
return totalRemoved;
|
|
}
|
|
|
|
/**
|
|
* List directory contents in a volume
|
|
* Uses cached helper containers for better performance.
|
|
*/
|
|
export async function listVolumeDirectory(
|
|
volumeName: string,
|
|
path: string,
|
|
envId?: number | null,
|
|
readOnly: boolean = true
|
|
): Promise<{ path: string; entries: FileEntry[]; containerId: string }> {
|
|
const containerId = await getOrCreateVolumeHelperContainer(volumeName, envId, readOnly);
|
|
|
|
// Sanitize path
|
|
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
|
const fullPath = `${VOLUME_MOUNT_PATH}${safePath.startsWith('/') ? safePath : '/' + safePath}`;
|
|
|
|
// Use simple ls since busybox doesn't support --time-style
|
|
const output = await execInContainer(containerId, ['ls', '-la', fullPath], envId);
|
|
const entries = parseLsOutput(output);
|
|
|
|
return {
|
|
path: safePath || '/',
|
|
entries,
|
|
containerId
|
|
};
|
|
// Note: Container is kept alive for reuse. It will be cleaned up
|
|
// when the cache TTL expires or when the volume browser modal closes.
|
|
}
|
|
|
|
/**
|
|
* Get archive of volume contents for download
|
|
* Uses cached helper containers for better performance.
|
|
*/
|
|
export async function getVolumeArchive(
|
|
volumeName: string,
|
|
path: string,
|
|
envId?: number | null,
|
|
readOnly: boolean = true
|
|
): Promise<{ response: Response; containerId: string }> {
|
|
const containerId = await getOrCreateVolumeHelperContainer(volumeName, envId, readOnly);
|
|
|
|
// Sanitize path
|
|
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
|
const fullPath = `${VOLUME_MOUNT_PATH}${safePath.startsWith('/') ? safePath : '/' + safePath}`;
|
|
|
|
const response = await dockerFetch(
|
|
`/containers/${containerId}/archive?path=${encodeURIComponent(fullPath)}`,
|
|
{ streaming: true },
|
|
envId
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text();
|
|
throw new Error(`Failed to get archive: ${error}`);
|
|
}
|
|
|
|
return { response, containerId };
|
|
// Note: Container is kept alive for reuse. Cache TTL will handle cleanup.
|
|
}
|
|
|
|
/**
|
|
* Stat a path inside a volume via the helper container.
|
|
* Returns the same shape as statContainerPath (#1180 raw download needs isDir).
|
|
*/
|
|
export async function statVolumePath(
|
|
volumeName: string,
|
|
path: string,
|
|
envId?: number | null,
|
|
readOnly: boolean = true
|
|
): Promise<{ name: string; size: number; mode: number; mtime: string; linkTarget?: string; isDir: boolean }> {
|
|
const containerId = await getOrCreateVolumeHelperContainer(volumeName, envId, readOnly);
|
|
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
|
const fullPath = `${VOLUME_MOUNT_PATH}${safePath.startsWith('/') ? safePath : '/' + safePath}`;
|
|
return statContainerPath(containerId, fullPath, envId);
|
|
}
|
|
|
|
/**
|
|
* Read file content from volume
|
|
* Uses cached helper containers for better performance.
|
|
*/
|
|
export async function readVolumeFile(
|
|
volumeName: string,
|
|
path: string,
|
|
envId?: number | null,
|
|
readOnly: boolean = true
|
|
): Promise<string> {
|
|
const containerId = await getOrCreateVolumeHelperContainer(volumeName, envId, readOnly);
|
|
|
|
// Sanitize path
|
|
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
|
const fullPath = `${VOLUME_MOUNT_PATH}${safePath.startsWith('/') ? safePath : '/' + safePath}`;
|
|
|
|
// Use cat to read file content
|
|
const output = await execInContainer(containerId, ['cat', fullPath], envId);
|
|
return output;
|
|
// Note: Container is kept alive for reuse. Cache TTL will handle cleanup.
|
|
}
|