src/dashboard/docker-self-update.js
Rama master · réplica de lectura del repositorio original.
/**
* In-container docker self-update via /var/run/docker.sock.
*
* Strategy: instead of installing the docker CLI inside our image (extra
* ~80MB) we talk to the docker daemon directly over the unix socket using
* node's built-in http client. The actual recreate-self-with-new-image
* step is handled by a one-shot deployer container we spawn — running
* `docker compose up -d` on the project; it sees the freshly-pulled
* image vs. our running container's image, stops us, and brings up a new
* container with the same name + config + new image.
*
* Security: this requires the user to mount /var/run/docker.sock into
* our container, which effectively grants host root (anyone with access
* to docker.sock can spawn privileged containers). That's why this code
* path is opt-in — if the socket isn't mounted we just report
* { available: false, reason: 'no-docker-sock' } and the dashboard
* falls back to the existing "run `docker compose pull && up -d` on the
* host" message.
*
* Compose label dependency: we need to know the compose project name and
* working_dir on the host to spawn the deployer with the right binds.
* Both come from the labels compose attaches to every container it
* creates: `com.docker.compose.project` and
* `com.docker.compose.project.working_dir`. If they're missing (e.g. user
* ran `docker run` directly without compose) we abort with a clear error
* — recreating a hand-managed container without losing its config is a
* separate problem we don't want to solve here.
*/
import { existsSync, readFileSync } from 'node:fs';
import { request as httpRequest } from 'node:http';
import { join } from 'node:path';
import { config, log } from '../config.js';
import { writeJsonAtomic } from '../fs-atomic.js';
const DOCKER_SOCK = '/var/run/docker.sock';
// Policy T (D9 §5.1 B4): the deployer runs with the host docker.sock, so it
// must never float on a mutable tag. Digest read from Docker Hub's
// `library/docker:24-cli` index (24.0.9-cli) on 2026-10-26; change it only by
// a reviewed commit, never at runtime.
export const DEPLOYER_IMAGE = 'docker:24-cli@sha256:2b62f89aaab9e4df0ff843f0d9a7bbd17340a9ba7c660ba6f40b1f00b38c2a4d';
const IMAGE_DIGEST_FILENAME = 'image-digest.txt';
const DOCKER_CONTAINER_ID_RE = /^[0-9a-f]{64}$/i;
const COMPOSE_CONFIG_FILES_LABEL = 'com.docker.compose.project.config_files';
const MAX_COMPOSE_CONFIG_FILES = 32;
const MAX_COMPOSE_CONFIG_FILES_LABEL_BYTES = 16 * 1024;
// D6: the deployer is now health-gated. These bound how long it can take
// before it either exits 0 (new image healthy), 1 (rolled back and healthy),
// 2 (rollback failed/unhealthy) or 3 (recreate failed).
const DEPLOYER_HEALTH_TIMEOUT_SECONDS = 120;
const DEPLOYER_HEALTH_INTERVAL_SECONDS = 3;
// Durable evidence survives the container recreate: the journal lives in the
// app's data dir (a host bind in the compose deployment), the deployer
// container is left behind with its exit code until the next reconcile.
const DOCKER_STATE_FILENAME = 'self-update-docker.json';
const DEPLOYER_ROLE_LABEL = 'com.windsurf-api.role';
const DEPLOYER_ROLE = 'self-update-deployer';
const DEPLOYER_PROJECT_LABEL = 'com.windsurf-api.project';
const DEPLOYER_OLD_IMAGE_LABEL = 'com.windsurf-api.rollback-image';
const DOCKER_PENDING_STATES = new Set(['pending-recreate', 'deployer-running']);
// A daemon error should fail the request rather than allowing an unbounded
// response to retain memory until the dashboard mutation timeout. Pull
// progress is noisier than the small inspect/create responses, so it gets a
// separate (still finite) ceiling below.
const DOCKER_API_BODY_LIMIT = 4 * 1024 * 1024;
const DOCKER_PULL_BODY_LIMIT = 16 * 1024 * 1024;
// Wait long enough for the dashboard's HTTP response to flush back to the
// browser before the deployer tears us down. 8s is enough for the toast +
// auto-refresh JS to land; longer waits just confuse the UX.
const DEPLOYER_DELAY_SECONDS = 8;
// Upper bound on the whole out-of-band deployer lifecycle (flush delay + two
// health gates). api.js holds the in-process mutation lock for this long so a
// retry cannot start while a deployer is still recreating/rolling back.
export const DOCKER_DEPLOYER_WINDOW_MS = (
DEPLOYER_DELAY_SECONDS + DEPLOYER_HEALTH_TIMEOUT_SECONDS * 2 + 60
) * 1000;
// Defence-in-depth (audit #2): compose labels come from the container
// runtime and the file's own contract says not to trust them blindly.
// shellQuote() already makes them shell-safe; these validators reject
// malformed/hostile shapes so they can't reach the deployer command at all.
export function isSafeComposeProject(name) {
return typeof name === 'string' && name.length <= 256 && /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name);
}
export function isSafeComposeWorkingDir(dir) {
if (typeof dir !== 'string' || dir.length === 0 || dir.length > 4096) return false;
if (!/^\/[^\0\r\n:]*$/.test(dir)) return false;
return !dir.split('/').includes('..');
}
function normalizeDockerContainerId(value) {
if (typeof value !== 'string' || !DOCKER_CONTAINER_ID_RE.test(value)) return null;
return value.toLowerCase();
}
const DOCKER_IMAGE_ID_RE = /^sha256:[0-9a-f]{64}$/i;
function normalizeDockerImageId(value) {
if (typeof value !== 'string' || !DOCKER_IMAGE_ID_RE.test(value)) return null;
return value.toLowerCase();
}
const IMAGE_DIGEST_RE = /^sha256:[0-9a-f]{64}$/i;
function normalizeImageDigest(value) {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return IMAGE_DIGEST_RE.test(trimmed) ? trimmed.toLowerCase() : null;
}
/**
* Split a Docker image reference into repository, tag and digest.
*
* `repo[:tag][@sha256:<64hex>]`: when a digest is present it is the only
* execution target; a tag is metadata. Returns null for references that
* cannot be expressed to the daemon without quoting ambiguity (whitespace,
* control characters, an empty repository or an invalid tag).
*/
export function parseImageReference(value) {
const raw = typeof value === 'string' ? value.trim() : '';
if (!raw || /[\0\r\n\t ]/.test(raw)) return null;
// A bare image ID is content-addressed but not a repository reference:
// Compose cannot recreate a service from it, so it is not an OTA target.
if (IMAGE_DIGEST_RE.test(raw)) return null;
const at = raw.indexOf('@');
if (at !== -1) {
const repository = raw.slice(0, at);
const digest = normalizeImageDigest(raw.slice(at + 1));
if (!repository || !digest) return null;
return { repository, tag: null, digest };
}
const slash = raw.lastIndexOf('/');
const colon = raw.lastIndexOf(':');
if (colon > slash) {
const repository = raw.slice(0, colon);
const tag = raw.slice(colon + 1);
if (!repository || !/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(tag)) return null;
return { repository, tag, digest: null };
}
return { repository: raw, tag: 'latest', digest: null };
}
/**
* Read the first non-empty line of an image-digest file. Accepts either the
* bare `sha256:<64hex>` form the release workflow writes or a full
* `repo@sha256:<64hex>` reference. Anything else is not a digest.
*/
export function parseImageDigest(text) {
const first = String(text ?? '').split(/\r?\n/).map((line) => line.trim()).find(Boolean);
if (!first) return null;
const match = /(?:^|[@\s])(sha256:[0-9a-f]{64})(?=$|[\s])/i.exec(first);
return match ? match[1].toLowerCase() : null;
}
/**
* Policy T (D9 §5.1 B2): the only digest a mutable tag may resolve to is the
* one recorded in the signed `image-digest.txt` release asset. Missing or
* malformed material is a refusal, never a fallback.
*/
export function readImageDigestFile({
file = join(config.dataDir, IMAGE_DIGEST_FILENAME),
readFile = readFileSync,
} = {}) {
let text;
try {
text = readFile(file, 'utf8');
} catch (e) {
return {
ok: false,
reason: 'image-mutable-ref',
cause: 'image-digest-missing',
detail: `no ${IMAGE_DIGEST_FILENAME} at ${file} (${e?.code || e?.message || 'unreadable'}); refusing a mutable image target`,
};
}
const digest = parseImageDigest(text);
if (!digest) {
return {
ok: false,
reason: 'image-mutable-ref',
cause: 'image-digest-missing',
detail: `${file} does not contain a sha256 image digest; refusing a mutable image target`,
};
}
return { ok: true, digest, file };
}
function dockerIdFromCgroup(cgroup) {
if (typeof cgroup !== 'string') return null;
const ids = new Set();
for (const line of cgroup.split(/\r?\n/)) {
if (!line) continue;
const firstColon = line.indexOf(':');
const secondColon = firstColon < 0 ? -1 : line.indexOf(':', firstColon + 1);
if (firstColon < 0 || secondColon < 0) continue;
const path = line.slice(secondColon + 1);
// Docker cgroupfs and systemd use distinct, fully-qualified forms. Do not
// search arbitrary cgroup text: a sibling service name may contain hex.
const match = /^\/docker\/([0-9a-f]{64})$/i.exec(path)
|| /^\/(?:[^/\0\r\n]+\/)*docker-([0-9a-f]{64})\.scope$/i.exec(path);
if (match) ids.add(match[1].toLowerCase());
}
return ids.size === 1 ? [...ids][0] : null;
}
function isPrivateCgroupV2(cgroup) {
if (typeof cgroup !== 'string') return false;
const lines = cgroup.split(/\r?\n/).filter(Boolean);
return lines.length === 1 && lines[0] === '0::/';
}
function dockerIdFromMountInfo(mountInfo) {
if (typeof mountInfo !== 'string') return null;
const expectedTargets = new Set(['/etc/hostname', '/etc/hosts', '/etc/resolv.conf']);
const matchedTargets = new Set();
const ids = new Set();
for (const line of mountInfo.split(/\r?\n/)) {
const fields = line.split(' ');
// mountinfo's root and mount-point fields are fields 4 and 5. The
// runtime-managed Docker files expose the container ID in the root path;
// require at least two independent files to agree before accepting it.
if (fields.length < 6 || !expectedTargets.has(fields[4])) continue;
const root = fields[3];
const match = /(?:^|\/)containers\/([0-9a-f]{64})\/(hostname|hosts|resolv\.conf)$/i.exec(root);
if (!match) continue;
const file = '/etc/' + match[2];
if (file !== fields[4]) continue;
matchedTargets.add(fields[4]);
ids.add(match[1].toLowerCase());
}
return matchedTargets.size >= 2 && ids.size === 1 ? [...ids][0] : null;
}
function isSafeAbsoluteComposePath(path) {
if (typeof path !== 'string' || path.length === 0 || path.length > 4096) return false;
if (!/^\/[^\0\r\n:]*$/.test(path) || path.endsWith('/')) return false;
const segments = path.split('/');
return segments.slice(1).every(segment => segment && segment !== '.' && segment !== '..');
}
export function isSafeComposeConfigFile(path, workingDir) {
if (!isSafeComposeWorkingDir(workingDir) || !isSafeAbsoluteComposePath(path)) return false;
const prefix = workingDir.endsWith('/') ? workingDir : workingDir + '/';
return path.startsWith(prefix) && path.length > prefix.length;
}
function parseComposeConfigFiles(value, workingDir) {
if (typeof value !== 'string' || value.length === 0
|| Buffer.byteLength(value, 'utf8') > MAX_COMPOSE_CONFIG_FILES_LABEL_BYTES) return null;
const files = value.split(',');
if (files.length === 0 || files.length > MAX_COMPOSE_CONFIG_FILES) return null;
const seen = new Set();
for (const file of files) {
if (!isSafeComposeConfigFile(file, workingDir) || seen.has(file)) return null;
seen.add(file);
}
return files;
}
function validateComposeConfigFiles(files, workingDir) {
if (!Array.isArray(files) || files.length === 0 || files.length > MAX_COMPOSE_CONFIG_FILES) return null;
const seen = new Set();
for (const file of files) {
if (!isSafeComposeConfigFile(file, workingDir) || seen.has(file)) return null;
seen.add(file);
}
return files;
}
/**
* Resolve our own container ID from a Docker-owned cgroup path, or from
* corroborating Docker runtime mounts when a private cgroup-v2 namespace
* intentionally hides it. A container hostname is operator-controlled
* (--hostname / Compose hostname:), so it is never an identity source.
*/
export function readSelfContainerId({ readFile = readFileSync } = {}) {
let cgroup;
try {
cgroup = readFile('/proc/self/cgroup', 'utf8');
} catch {}
const cgroupId = dockerIdFromCgroup(cgroup);
if (cgroupId) return cgroupId;
// With a private cgroup namespace Docker intentionally presents 0::/.
// Do not fall back to /etc/hostname: hostname is operator-controlled and
// can name a different container. Docker's own bind mounts for at least two
// of hostname/hosts/resolv.conf carry the real ID in mountinfo instead.
if (isPrivateCgroupV2(cgroup)) {
try {
const mountInfo = readFile('/proc/self/mountinfo', 'utf8');
return dockerIdFromMountInfo(mountInfo);
} catch {}
}
return null;
}
function collectDockerResponse(res, { maxBytes, label }) {
return new Promise((resolve, reject) => {
const chunks = [];
let bytes = 0;
let settled = false;
const settleReject = (error) => {
if (settled) return;
settled = true;
reject(error instanceof Error ? error : new Error(String(error)));
};
const settleResolve = () => {
if (settled) return;
settled = true;
resolve(Buffer.concat(chunks));
};
const destroy = () => {
if (typeof res.destroy === 'function') {
try { res.destroy(); } catch { /* the original failure is authoritative */ }
}
};
res.on('data', (chunk) => {
if (settled) return;
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
bytes += buf.length;
if (bytes > maxBytes) {
settleReject(new Error(`${label} response body exceeds ${maxBytes} bytes`));
destroy();
return;
}
chunks.push(buf);
});
// IncomingMessage emits `error` for transport failures. `aborted` is a
// distinct signal for a peer that truncates the body, while `close` can
// arrive without `end` when the socket disappears. All paths are
// single-settle so a late close/error cannot turn a prior success into a
// second resolution or leave the caller pending forever.
res.once('error', (error) => settleReject(error));
res.once('aborted', () => settleReject(new Error(`${label} response aborted`)));
res.once('close', () => {
if (!settled) settleReject(new Error(`${label} response closed before end`));
});
res.once('end', settleResolve);
});
}
function dockerRequest(method, path, body) {
return new Promise((resolve, reject) => {
const data = body ? JSON.stringify(body) : null;
const req = httpRequest(
{
socketPath: DOCKER_SOCK,
method,
path,
headers: {
'Content-Type': 'application/json',
...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}),
},
timeout: 60000,
},
(res) => {
collectDockerResponse(res, {
maxBytes: DOCKER_API_BODY_LIMIT,
label: `docker API ${method} ${path}`,
}).then((raw) => {
const buf = raw.toString('utf8');
let parsed;
try { parsed = buf ? JSON.parse(buf) : null; } catch { parsed = buf; }
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve({ status: res.statusCode, body: parsed });
} else {
reject(new Error(`docker API ${method} ${path} -> ${res.statusCode}: ${buf.slice(0, 400)}`));
}
}, reject);
},
);
req.on('error', reject);
req.on('timeout', () => req.destroy(new Error('docker API timeout')));
if (data) req.write(data);
req.end();
});
}
/**
* Resolve a tag to the manifest digest the registry currently serves, through
* the daemon's registry-metadata endpoint (no layer download). The tag is a
* lookup key only; the returned digest is the only thing that may be pulled.
*/
export async function resolveImageDigest(image, { request = dockerRequest } = {}) {
const ref = parseImageReference(image);
if (!ref) {
return {
ok: false,
reason: 'invalid-image-ref',
detail: `cannot parse image reference: ${String(image).slice(0, 200)}`,
};
}
if (ref.digest) {
return {
ok: true,
digest: ref.digest,
image: `${ref.repository}@${ref.digest}`,
repository: ref.repository,
tag: null,
operatorPinned: true,
};
}
let res;
try {
// Moby's DistributionInspect puts the whole reference in the path and
// sends no query parameters: the tag belongs in the path segment.
res = await request('GET', `/distribution/${ref.repository}:${ref.tag}/json`);
} catch (e) {
return { ok: false, reason: 'image-digest-resolve-failed', detail: e?.message || String(e) };
}
const digest = normalizeImageDigest(
res?.body?.Descriptor?.digest
?? res?.body?.descriptor?.digest
?? res?.body?.digest,
);
if (!digest) {
return {
ok: false,
reason: 'image-digest-resolve-failed',
detail: `docker distribution inspect for ${ref.repository}:${ref.tag} did not return a sha256 digest`,
};
}
return {
ok: true,
digest,
image: `${ref.repository}@${digest}`,
repository: ref.repository,
tag: ref.tag,
operatorPinned: false,
};
}
/**
* The /images/create endpoint streams a JSONL pull progress feed and
* doesn't terminate until the pull completes. Wait for the response body
* to drain before returning.
*/
export function dockerPull(image, { request = httpRequest } = {}) {
const ref = parseImageReference(image);
// Moby's client sends a digest pull as fromImage=<repo>&tag=sha256:<hex>;
// `fromImage=repo@sha256:…` is not the daemon API shape.
const fromImage = ref?.digest ? ref.repository : image;
const digestTag = ref?.digest ? `&tag=${encodeURIComponent(ref.digest)}` : '';
const pullPath = `/images/create?fromImage=${encodeURIComponent(fromImage)}${digestTag}`;
return new Promise((resolve, reject) => {
const req = request(
{
socketPath: DOCKER_SOCK,
method: 'POST',
path: pullPath,
headers: { 'Content-Type': 'application/json' },
timeout: 600000,
},
(res) => {
collectDockerResponse(res, {
maxBytes: DOCKER_PULL_BODY_LIMIT,
label: `docker pull ${image}`,
}).then((raw) => {
const buf = raw.toString('utf8');
if (!(res.statusCode >= 200 && res.statusCode < 300)) {
reject(new Error(`docker pull ${image} -> ${res.statusCode}: ${buf.slice(0, 400)}`));
return;
}
let sawRecord = false;
let lastRecordCompleted = false;
// audit #11: /images/create streams JSONL and returns HTTP 200 EVEN
// WHEN THE PULL FAILS (unknown tag, auth/registry error) — the failure
// is an {"error":...} / {"errorDetail":...} line in the body, not the
// status. Treating 200 as success left the deployer silently on the
// OLD image. Scan the stream and reject if any line reports an error.
for (const line of buf.split('\n')) {
const s = line.trim();
if (!s) continue;
let obj;
try { obj = JSON.parse(s); } catch {
reject(new Error(`docker pull ${image} returned malformed JSONL`));
return;
}
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
reject(new Error(`docker pull ${image} returned an invalid JSONL record`));
return;
}
sawRecord = true;
if (Object.prototype.hasOwnProperty.call(obj, 'error')
|| Object.prototype.hasOwnProperty.call(obj, 'errorDetail')) {
const detail = obj.errorDetail;
const msg = (detail && typeof detail === 'object' && detail.message)
|| detail || obj.error || 'unknown docker pull error';
reject(new Error(`docker pull ${image} failed: ${String(msg).slice(0, 400)}`));
return;
}
const status = typeof obj.status === 'string' ? obj.status.trim() : '';
const aux = obj.aux && typeof obj.aux === 'object' && !Array.isArray(obj.aux)
? obj.aux
: null;
const auxDigest = aux && (aux.Digest || aux.digest || aux.ID || aux.id);
const hasAuxDigest = typeof auxDigest === 'string'
&& /^sha256:[0-9a-f]{64}$/i.test(auxDigest.trim());
// Docker's pull stream is progress-only until a terminal status (or
// explicit auxiliary digest) arrives. HTTP 200 plus a clean EOF is
// not proof that the requested image was pulled: registries and
// daemon proxies can truncate the stream without an error record.
lastRecordCompleted = hasAuxDigest
|| /^(?:status:\s*)?(?:downloaded newer image|image is up to date)(?:\s+for\b.*)?$/i.test(status);
}
if (!sawRecord) {
reject(new Error(`docker pull ${image} returned an empty JSONL stream`));
return;
}
if (!lastRecordCompleted) {
reject(new Error(`docker pull ${image} returned no terminal success record`));
return;
}
resolve(buf);
}, reject);
},
);
req.on('error', reject);
req.on('timeout', () => req.destroy(new Error('docker pull timeout (10min)')));
req.end();
});
}
/**
* Detect whether docker self-update is feasible.
* Returns { available, reason, ...detail } so the dashboard can show
* the right hint when it's not.
*/
export async function detectDockerSelfUpdate({
exists = existsSync,
readSelfId = readSelfContainerId,
request = dockerRequest,
} = {}) {
if (!exists(DOCKER_SOCK)) {
return { available: false, reason: 'no-docker-sock', detail: `${DOCKER_SOCK} not mounted` };
}
let selfId;
try { selfId = normalizeDockerContainerId(readSelfId()); } catch {}
if (!selfId) {
return {
available: false,
reason: 'no-self-id',
detail: 'cannot prove own full container id from a Docker cgroup or corroborated runtime mounts',
};
}
let inspect;
try {
inspect = await request('GET', `/containers/${selfId}/json`);
} catch (e) {
return { available: false, reason: 'docker-api-unreachable', detail: e.message };
}
const inspectedId = normalizeDockerContainerId(inspect?.body?.Id);
if (inspectedId !== selfId) {
return {
available: false,
reason: 'self-id-mismatch',
detail: 'docker inspect did not return the exact container id proven by local runtime evidence',
};
}
const labels = inspect.body?.Config?.Labels || {};
const project = labels['com.docker.compose.project'];
const workingDir = labels['com.docker.compose.project.working_dir'];
const configFilesLabel = labels[COMPOSE_CONFIG_FILES_LABEL];
const image = inspect.body?.Config?.Image;
const service = labels['com.docker.compose.service'];
// The running image ID is the docker rollback point. Without it the deployer
// cannot restore a known-good image, so treat its absence as unavailable
// rather than starting a mutation with no way back.
const imageId = normalizeDockerImageId(inspect.body?.Image);
if (!project || !workingDir) {
return {
available: false,
reason: 'no-compose-labels',
detail: 'container has no com.docker.compose.* labels — was it started via `docker run` instead of `docker compose up`?',
image, selfId,
};
}
if (typeof configFilesLabel !== 'string' || configFilesLabel.length === 0) {
return {
available: false,
reason: 'no-compose-config-files',
detail: 'container has no Compose config_files label; the exact deployment configuration cannot be replayed safely',
image, selfId,
};
}
if (!isSafeComposeProject(project) || !isSafeComposeWorkingDir(workingDir)) {
return {
available: false,
reason: 'unsafe-compose-labels',
detail: 'compose project / working_dir label failed safety validation',
image, selfId,
};
}
const configFiles = parseComposeConfigFiles(configFilesLabel, workingDir);
if (!configFiles) {
return {
available: false,
reason: 'unsafe-compose-config-files',
detail: 'Compose config_files must be unique absolute files contained by the validated project working_dir',
image, selfId,
};
}
if (!service || !isSafeComposeProject(service)) {
return {
available: false,
reason: 'no-compose-service',
detail: 'container has no valid com.docker.compose.service label; the app service to recreate/roll back cannot be identified',
image, selfId,
};
}
if (!imageId) {
return {
available: false,
reason: 'no-image-id',
detail: 'docker inspect did not return the running image id; a rollback point cannot be recorded',
image, selfId,
};
}
return {
available: true,
selfId,
image,
imageId,
service,
project,
workingDir,
configFiles,
};
}
/**
* Run the full self-update flow. Returns after the deployer sidecar is
* started; the recreate happens out-of-band ~8s later and is health-gated by
* the deployer. `state:'pending-recreate'` + `verified:false` is deliberate:
* success is only confirmed by a later /self-update/check after the deployer
* exits, never by this response.
*/
export async function runDockerSelfUpdate({
detect = detectDockerSelfUpdate,
pull = dockerPull,
request = dockerRequest,
stateDir = config.dataDir,
digestFile,
readDigest = readImageDigestFile,
resolveDigest = resolveImageDigest,
} = {}) {
const ctx = await detect();
if (!ctx.available) return { ok: false, ...ctx };
const selfId = normalizeDockerContainerId(ctx.selfId);
const configFiles = validateComposeConfigFiles(ctx.configFiles, ctx.workingDir);
if (!selfId) {
return { ok: false, reason: 'no-self-id', detail: 'self-update context did not contain a full Docker container id' };
}
if (!isSafeComposeProject(ctx.project) || !isSafeComposeWorkingDir(ctx.workingDir)) {
return { ok: false, reason: 'unsafe-compose-labels', detail: 'self-update context contained unsafe Compose labels' };
}
if (!configFiles) {
const reason = Array.isArray(ctx.configFiles) ? 'unsafe-compose-config-files' : 'no-compose-config-files';
return { ok: false, reason, detail: 'self-update context did not prove the exact Compose config files to replay' };
}
if (typeof ctx.image !== 'string' || ctx.image.length === 0 || /[\0\r\n]/.test(ctx.image)) {
return { ok: false, reason: 'unsafe-image', detail: 'self-update context contained an invalid image reference' };
}
const imageId = normalizeDockerImageId(ctx.imageId);
if (!imageId) {
return { ok: false, reason: 'no-image-id', detail: 'self-update context did not contain the running image id; refusing a mutation with no rollback point' };
}
if (!ctx.service || !isSafeComposeProject(ctx.service)) {
return { ok: false, reason: 'no-compose-service', detail: 'self-update context did not contain a valid Compose service name' };
}
const rollbackRef = rollbackImageRef(ctx.image);
if (!rollbackRef) {
return { ok: false, reason: 'unsafe-rollback-ref', detail: 'could not derive a rollback tag from the running image reference' };
}
const deployerRef = parseImageReference(DEPLOYER_IMAGE);
if (!deployerRef?.digest) {
return {
ok: false,
reason: 'deployer-unpinned',
detail: 'the deployer helper image is not digest-pinned; refusing to run it with docker.sock',
};
}
// Policy T (D9 §5.1 B1/B2): a tag is only a lookup key. Resolve it through
// the daemon's registry-metadata endpoint and require the result to equal
// the digest recorded in the signed image-digest.txt release asset. A
// digest-pinned reference is an operator pin and is used verbatim.
const sourceRef = parseImageReference(ctx.image);
if (!sourceRef) {
return { ok: false, reason: 'unsafe-image', detail: 'self-update context contained an image reference that cannot be parsed' };
}
let targetImage;
let targetDigest;
if (sourceRef.digest) {
targetDigest = sourceRef.digest;
targetImage = `${sourceRef.repository}@${targetDigest}`;
} else {
let attested;
try {
attested = readDigest({ file: digestFile ?? join(stateDir, IMAGE_DIGEST_FILENAME) });
} catch (e) {
return { ok: false, reason: 'image-mutable-ref', cause: 'image-digest-missing', detail: e?.message || String(e) };
}
if (!attested.ok) return attested;
let resolved;
try {
resolved = await resolveDigest(ctx.image, { request });
} catch (e) {
return { ok: false, reason: 'image-digest-resolve-failed', detail: e?.message || String(e) };
}
if (!resolved.ok) return resolved;
if (resolved.digest !== attested.digest) {
log.warn(`self-update: image digest mismatch for ${ctx.image}: registry ${resolved.digest}, signed ${attested.digest}`);
return {
ok: false,
reason: 'image-digest-mismatch',
detail: `registry resolved ${ctx.image} to ${resolved.digest}, but the signed ${IMAGE_DIGEST_FILENAME} names ${attested.digest}; refusing to pull or deploy`,
};
}
targetDigest = resolved.digest;
targetImage = resolved.image;
}
// D6: durable rollback point + pending state BEFORE any mutation. This file
// lives in the app's data dir (a host bind in the compose deployment), so it
// survives the recreate and is what /self-update/check reconciles.
const journal = {
version: 1,
mode: 'docker',
state: 'pending-recreate',
oldImageId: imageId,
oldImageRef: ctx.image,
rollbackRef,
targetImage,
project: ctx.project,
service: ctx.service,
selfId,
requestedAt: Date.now(),
};
try {
writeDockerSelfUpdateState(journal, { stateDir });
} catch (e) {
return {
ok: false,
reason: 'rollback-point-write-failed',
detail: `Could not durably record the previous image; update was not attempted: ${e.message}`,
};
}
const fail = (reason, detail) => {
try {
updateDockerSelfUpdateState({ state: 'failed', reason, error: detail }, { stateDir });
} catch (err) {
log.warn(`self-update: could not record docker update failure (${reason}): ${err.message}`);
}
return { ok: false, reason, detail };
};
// Pull the new image by digest. This blocks until the pull finishes — could
// be 30s-2min for a fresh layer set, but the user is staring at the
// dashboard and a fast progress signal beats a confusing async
// "started, check back later" UX.
try {
await pull(targetImage);
} catch (e) {
return fail('pull-failed', e.message);
}
// B3: when the daemon reports RepoDigests, require the pulled image to be
// addressable by the exact digest we are about to deploy. (A daemon that
// omits the field is not treated as proof of a different image: the pull
// itself was content-addressed. A present list that lacks the digest is a
// hard failure.)
try {
const inspected = await request('GET', `/images/${targetImage}/json`);
const repoDigests = inspected?.body?.RepoDigests;
if (Array.isArray(repoDigests)
&& !repoDigests.some((entry) => String(entry).toLowerCase().endsWith(`@${targetDigest}`))) {
return fail('image-digest-unconfirmed', `docker pull ${targetImage} completed but RepoDigests does not contain ${targetDigest}`);
}
} catch (e) {
return fail('image-digest-unconfirmed', e.message);
}
// Also ensure the deployer sidecar image is local. First-time users on
// a host that has never pulled `docker:24-cli` will otherwise hit
// POST /containers/create -> 404: No such image: docker:24-cli
// (reported as the dashboard "一键更新并重启" failure path). Pull it
// explicitly. It's tiny (~30 MB) and only runs the one-shot
// `docker compose up -d`, so this is a one-time cost per host.
try {
await pull(DEPLOYER_IMAGE);
} catch (e) {
return fail('deployer-pull-failed', e.message);
}
// Spawn the deployer sidecar. It recreates the app, then health-gates the
// result and automatically restores the previous image when the new one is
// unhealthy (the same pattern as deploy/update.sh health_gate/restore_image).
// AutoRemove stays off so the exit code — 0 healthy, 1 rolled back healthy,
// 2 rollback failed, 3 recreate failed — survives as durable evidence until
// the next /self-update/check reconciles it.
//
// The sleep at the start gives the dashboard's HTTP response time to
// flush back to the browser before our container gets killed.
let createRes;
try {
createRes = await request('POST', `/containers/create`, {
Image: DEPLOYER_IMAGE,
Cmd: [
'sh', '-c',
buildDockerDeployerScript({
project: ctx.project,
workingDir: ctx.workingDir,
configFiles,
service: ctx.service,
oldImageId: imageId,
rollbackRef,
targetImage,
}),
],
Labels: {
[DEPLOYER_ROLE_LABEL]: DEPLOYER_ROLE,
[DEPLOYER_PROJECT_LABEL]: ctx.project,
[DEPLOYER_OLD_IMAGE_LABEL]: imageId,
'com.windsurf-api.parent': selfId,
},
HostConfig: {
AutoRemove: false,
Binds: [
`${DOCKER_SOCK}:${DOCKER_SOCK}`,
`${ctx.workingDir}:${ctx.workingDir}:ro`,
],
},
});
} catch (e) {
return fail('deployer-create-failed', e.message);
}
const deployerId = normalizeDockerContainerId(createRes.body?.Id);
if (!deployerId) {
return fail('deployer-create-no-id', JSON.stringify(createRes.body).slice(0, 400));
}
try {
await request('POST', `/containers/${deployerId}/start`, null);
} catch (e) {
const detail = e instanceof Error ? e.message : String(e);
try {
await request('DELETE', `/containers/${deployerId}?force=1`, null);
} catch {
// Cleanup is best-effort; the start error remains the authoritative result.
}
return fail('deployer-start-failed', detail);
}
try {
updateDockerSelfUpdateState({ deployerId, state: 'pending-recreate' }, { stateDir });
} catch (err) {
log.warn(`self-update: could not record the started deployer: ${err.message}`);
}
return {
ok: true,
state: 'pending-recreate',
verified: false,
image: targetImage,
sourceImage: ctx.image,
project: ctx.project,
workingDir: ctx.workingDir,
deployerId: deployerId.slice(0, 12),
delaySeconds: DEPLOYER_DELAY_SECONDS,
rollbackPoint: { imageId, imageRef: ctx.image, rollbackRef },
message: `Pulled ${targetImage}; the deployer sidecar will recreate the container in ~${DEPLOYER_DELAY_SECONDS}s and health-gate it. Success is confirmed only after /self-update/check reports state:confirmed.`,
};
}
// ─── Durable docker update state (D6) ─────────────────────
function dockerStatePath(stateDir) {
return join(stateDir, DOCKER_STATE_FILENAME);
}
export function readDockerSelfUpdateState({ stateDir = config.dataDir } = {}) {
try {
const value = JSON.parse(readFileSync(dockerStatePath(stateDir), 'utf8'));
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
} catch {
return null;
}
}
function writeDockerSelfUpdateState(record, { stateDir = config.dataDir } = {}) {
writeJsonAtomic(dockerStatePath(stateDir), record);
return record;
}
function updateDockerSelfUpdateState(patch, opts) {
const next = { ...(readDockerSelfUpdateState(opts) || {}), ...patch, updatedAt: Date.now() };
return writeDockerSelfUpdateState(next, opts);
}
// Mirror deploy/update.sh's rollback_target_ref(): keep the repository, swap
// the tag for a stable :rollback name so the previous image stays addressable.
function rollbackImageRef(image) {
const raw = String(image || '').trim();
if (!raw || /[\0\r\n\s]/.test(raw)) return '';
const withoutDigest = raw.split('@')[0];
const slash = withoutDigest.lastIndexOf('/');
const colon = withoutDigest.lastIndexOf(':');
const repo = colon > slash ? withoutDigest.slice(0, colon) : withoutDigest;
if (!repo) return '';
return `${repo}:rollback`;
}
/**
* Build the deployer's POSIX sh script. Exported for the hermetic test that
* executes it against a fake docker CLI.
*
* Exit codes: 0 = new image healthy, 1 = previous image restored and healthy,
* 2 = rollback failed or the restored image is unhealthy, 3 = recreate failed.
*/
export function buildDockerDeployerScript({
project,
workingDir,
configFiles,
service,
oldImageId,
rollbackRef,
targetImage = '',
// Overridable for the hermetic script test; production uses the constants.
delaySeconds = DEPLOYER_DELAY_SECONDS,
healthTimeoutSeconds = DEPLOYER_HEALTH_TIMEOUT_SECONDS,
healthIntervalSeconds = DEPLOYER_HEALTH_INTERVAL_SECONDS,
}) {
if (targetImage && !parseImageReference(targetImage)?.digest) {
throw new Error('targetImage must be a digest-pinned reference');
}
const compose = 'docker compose -p ' + shellQuote(project)
+ ' --project-directory ' + shellQuote(workingDir)
+ ' ' + configFiles.map(file => '-f ' + shellQuote(file)).join(' ');
// Run the probe inside the app container: the deployer image has no HTTP
// client and must not rely on host networking.
const probe = 'node -e ' + shellQuote(
"fetch('http://127.0.0.1:'+(process.env.PORT||3003)+'/health')"
+ '.then(async r=>{if(!r.ok)process.exit(1);const t=await r.text();process.stdout.write(t)})'
+ '.catch(()=>process.exit(1))',
);
const tries = Math.max(1, Math.ceil(healthTimeoutSeconds / healthIntervalSeconds));
return [
'set -e',
`sleep ${delaySeconds}`,
'health_gate() {',
' i=0',
` while [ "$i" -lt ${tries} ]; do`,
` if ${compose} exec -T ${shellQuote(service)} ${probe} >/dev/null 2>&1; then`,
' return 0',
' fi',
' i=$((i + 1))',
` sleep ${healthIntervalSeconds}`,
' done',
' return 1',
'}',
// Policy T (D9 §5.1 B4): --no-build is mandatory. docker-compose.yml
// defines build: for the app service, so a bare `up` can build from the
// on-disk checkout — code no trust policy has looked at.
`${targetImage ? `WINDSURFAPI_IMAGE=${shellQuote(targetImage)} ` : ''}${compose} up -d --no-build || exit 3`,
'if health_gate; then exit 0; fi',
`docker tag ${shellQuote(oldImageId)} ${shellQuote(rollbackRef)} || exit 2`,
`WINDSURFAPI_IMAGE=${shellQuote(rollbackRef)} ${compose} up -d --no-deps --no-build --force-recreate ${shellQuote(service)} || exit 2`,
'if health_gate; then exit 1; fi',
'exit 2',
].join('\n');
}
/**
* Read the deployer's durable outcome after a restart.
*
* The journal is the app-side record; the deployer container's exit code is
* the out-of-band evidence. Terminal mapping:
* 0 confirmed · 1 rolled-back · 2 rollback-failed · 3 recreate-failed
* Anything still running (or absent) is reported honestly instead of being
* rounded up to success.
*/
export async function reconcileDockerSelfUpdateState({
stateDir = config.dataDir,
request = dockerRequest,
readSelfId = readSelfContainerId,
} = {}) {
const journal = readDockerSelfUpdateState({ stateDir });
if (!journal) return null;
if (!DOCKER_PENDING_STATES.has(journal.state)) return journal;
const filters = encodeURIComponent(JSON.stringify({
label: [`${DEPLOYER_ROLE_LABEL}=${DEPLOYER_ROLE}`],
}));
let containers;
try {
const listed = await request('GET', `/containers/json?all=1&filters=${filters}`);
containers = Array.isArray(listed?.body) ? listed.body : [];
} catch (err) {
return { ...journal, deployerStatus: 'unreachable', deployerError: err.message };
}
const match = containers.find((entry) => {
const labels = entry?.Labels || {};
if (journal.deployerId && !String(entry?.Id || '').startsWith(journal.deployerId)) return false;
if (journal.project && labels[DEPLOYER_PROJECT_LABEL] !== journal.project) return false;
return true;
});
if (!match) {
// No deployer container left to read. Report what the running image
// proves; never claim a health-gated success without the exit code.
let runningImageId = '';
try {
const selfId = normalizeDockerContainerId(readSelfId());
if (selfId) {
const inspected = await request('GET', `/containers/${selfId}/json`);
runningImageId = normalizeDockerImageId(inspected?.body?.Image) || '';
}
} catch { /* the missing evidence is the answer */ }
const next = {
...journal,
state: 'unverified',
reason: 'deployer-evidence-missing',
runningImageId: runningImageId || undefined,
};
try { writeDockerSelfUpdateState(next, { stateDir }); } catch (err) {
log.warn(`self-update: could not record the missing deployer evidence: ${err.message}`);
}
return next;
}
const status = String(match.State || '');
if (status !== 'exited' && status !== 'dead') {
return { ...journal, state: 'deployer-running', deployerStatus: status || 'unknown' };
}
const exitCode = Number(match.ExitCode);
const state = exitCode === 0
? 'confirmed'
: exitCode === 1
? 'rolled-back'
: exitCode === 2
? 'rollback-failed'
: exitCode === 3
? 'recreate-failed'
: 'deployer-failed';
const next = {
...journal,
state,
deployerExitCode: Number.isFinite(exitCode) ? exitCode : null,
deployerStatus: status,
finishedAt: Date.now(),
};
try {
writeDockerSelfUpdateState(next, { stateDir });
} catch (err) {
log.warn(`self-update: could not record the docker deployer outcome: ${err.message}`);
return { ...journal, state: 'deployer-running', deployerStatus: status };
}
// The exit code is durable in the journal now; the container is not needed.
try { await request('DELETE', `/containers/${match.Id}?force=1`, null); } catch { /* best effort */ }
return next;
}
// Single-quote-wrap a value for safe injection into a `sh -c "..."`
// payload. Single quotes inside the value get terminated, escaped,
// re-opened: `'foo'` -> `'foo'\''bar'`. The compose project name and
// working_dir come from container labels which we don't fully control,
// so don't trust them blindly.
function shellQuote(s) {
return "'" + String(s).replace(/'/g, "'\\''") + "'";
}