| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { existsSync, readFileSync } from 'node:fs'; |
| import { request as httpRequest } from 'node:http'; |
|
|
| const DOCKER_SOCK = '/var/run/docker.sock'; |
| const DEPLOYER_IMAGE = 'docker:24-cli'; |
| |
| |
| |
| const DEPLOYER_DELAY_SECONDS = 8; |
|
|
| |
| |
| |
| |
| |
| export function readSelfContainerId() { |
| try { |
| const hostname = readFileSync('/etc/hostname', 'utf8').trim(); |
| if (/^[0-9a-f]{12,64}$/.test(hostname)) return hostname; |
| } catch {} |
| try { |
| const cg = readFileSync('/proc/self/cgroup', 'utf8'); |
| |
| |
| |
| |
| const m = cg.match(/[0-9a-f]{64}/); |
| if (m) return m[0]; |
| } catch {} |
| return null; |
| } |
|
|
| 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) => { |
| const chunks = []; |
| res.on('data', (c) => chunks.push(c)); |
| res.on('end', () => { |
| const buf = Buffer.concat(chunks).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)}`)); |
| } |
| }); |
| }, |
| ); |
| req.on('error', reject); |
| req.on('timeout', () => req.destroy(new Error('docker API timeout'))); |
| if (data) req.write(data); |
| req.end(); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| function dockerPull(image) { |
| return new Promise((resolve, reject) => { |
| const req = httpRequest( |
| { |
| socketPath: DOCKER_SOCK, |
| method: 'POST', |
| path: `/images/create?fromImage=${encodeURIComponent(image)}`, |
| headers: { 'Content-Type': 'application/json' }, |
| timeout: 600000, |
| }, |
| (res) => { |
| const chunks = []; |
| res.on('data', (c) => chunks.push(c)); |
| res.on('end', () => { |
| if (res.statusCode >= 200 && res.statusCode < 300) { |
| resolve(Buffer.concat(chunks).toString('utf8')); |
| } else { |
| const buf = Buffer.concat(chunks).toString('utf8'); |
| reject(new Error(`docker pull ${image} -> ${res.statusCode}: ${buf.slice(0, 400)}`)); |
| } |
| }); |
| }, |
| ); |
| req.on('error', reject); |
| req.on('timeout', () => req.destroy(new Error('docker pull timeout (10min)'))); |
| req.end(); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| export async function detectDockerSelfUpdate() { |
| if (!existsSync(DOCKER_SOCK)) { |
| return { available: false, reason: 'no-docker-sock', detail: `${DOCKER_SOCK} not mounted` }; |
| } |
| const selfId = readSelfContainerId(); |
| if (!selfId) { |
| return { available: false, reason: 'no-self-id', detail: 'cannot resolve own container id from /etc/hostname or /proc/self/cgroup' }; |
| } |
| let inspect; |
| try { |
| inspect = await dockerRequest('GET', `/containers/${selfId}/json`); |
| } catch (e) { |
| return { available: false, reason: 'docker-api-unreachable', detail: e.message }; |
| } |
| const labels = inspect.body?.Config?.Labels || {}; |
| const project = labels['com.docker.compose.project']; |
| const workingDir = labels['com.docker.compose.project.working_dir']; |
| const image = inspect.body?.Config?.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, |
| }; |
| } |
| return { |
| available: true, |
| selfId, |
| image, |
| project, |
| workingDir, |
| }; |
| } |
|
|
| |
| |
| |
| |
| export async function runDockerSelfUpdate() { |
| const ctx = await detectDockerSelfUpdate(); |
| if (!ctx.available) return { ok: false, ...ctx }; |
|
|
| |
| |
| |
| |
| try { |
| await dockerPull(ctx.image); |
| } catch (e) { |
| return { ok: false, reason: 'pull-failed', detail: e.message }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| try { |
| await dockerPull(DEPLOYER_IMAGE); |
| } catch (e) { |
| return { ok: false, reason: 'deployer-pull-failed', detail: e.message }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| let createRes; |
| try { |
| createRes = await dockerRequest('POST', `/containers/create`, { |
| Image: DEPLOYER_IMAGE, |
| Cmd: [ |
| 'sh', '-c', |
| `set -e; sleep ${DEPLOYER_DELAY_SECONDS}; ` + |
| `docker compose -p ${shellQuote(ctx.project)} ` + |
| `--project-directory ${shellQuote(ctx.workingDir)} up -d`, |
| ], |
| Labels: { |
| 'com.windsurf-api.role': 'self-update-deployer', |
| 'com.windsurf-api.parent': ctx.selfId, |
| }, |
| HostConfig: { |
| AutoRemove: true, |
| Binds: [ |
| `${DOCKER_SOCK}:${DOCKER_SOCK}`, |
| `${ctx.workingDir}:${ctx.workingDir}:ro`, |
| ], |
| }, |
| }); |
| } catch (e) { |
| return { ok: false, reason: 'deployer-create-failed', detail: e.message }; |
| } |
|
|
| const deployerId = createRes.body?.Id; |
| if (!deployerId) { |
| return { ok: false, reason: 'deployer-create-no-id', detail: JSON.stringify(createRes.body).slice(0, 400) }; |
| } |
|
|
| try { |
| await dockerRequest('POST', `/containers/${deployerId}/start`, null); |
| } catch (e) { |
| return { ok: false, reason: 'deployer-start-failed', detail: e.message }; |
| } |
|
|
| return { |
| ok: true, |
| image: ctx.image, |
| project: ctx.project, |
| workingDir: ctx.workingDir, |
| deployerId: deployerId.slice(0, 12), |
| delaySeconds: DEPLOYER_DELAY_SECONDS, |
| message: `Pulled ${ctx.image}; deployer sidecar will recreate the container in ~${DEPLOYER_DELAY_SECONDS}s.`, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| function shellQuote(s) { |
| return "'" + String(s).replace(/'/g, "'\\''") + "'"; |
| } |
|
|