import process from "node:process"; import Docker from "dockerode"; import tar from "tar-fs"; const docker = new Docker({ socketPath: "/var/run/docker.sock" }); const service = "rednote-tool-studio"; const candidate = `${service}-candidate`; const previous = `${service}-previous`; const volume = `${service}-data`; const sha = (process.env.GITHUB_SHA || process.env.CI_COMMIT_SHA || "dev").slice(0, 12); const image = `${service}:${sha}`; const port = process.env.DEPLOY_PORT || "18080"; const dockerfile = process.env.DEPLOY_DOCKERFILE || "Dockerfile"; const usePrebuiltOutput = dockerfile === "Dockerfile.deploy"; if (!/^\d{2,5}$/.test(port) || Number(port) > 65535) throw new Error("DEPLOY_PORT must be a valid TCP port"); async function removeContainer(name) { const container = docker.getContainer(name); try { const state = await container.inspect(); if (state.State.Running) await container.stop({ t: 10 }); await container.remove({ force: true }); } catch (error) { if (error.statusCode !== 404) throw error; } } async function waitForHealth(container, timeoutMs = 90_000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const state = await container.inspect(); const health = state.State.Health?.Status; if (health === "healthy") return; if (health === "unhealthy" || !state.State.Running) throw new Error(`Candidate container is ${health || state.State.Status}`); await new Promise((resolve) => setTimeout(resolve, 2_000)); } throw new Error("Timed out waiting for candidate health check"); } async function reportContainerFailure(container) { try { const state = await container.inspect(); console.error(`Candidate status: ${state.State.Status}; health: ${state.State.Health?.Status || "unavailable"}`); for (const check of state.State.Health?.Log?.slice(-3) || []) { const output = check.Output?.trim(); if (output) console.error(`Health check: ${output}`); } const logs = await container.logs({ stdout: true, stderr: true, tail: 100 }); if (logs.length) console.error(`Candidate logs:\n${logs.toString("utf8")}`); } catch (diagnosticError) { console.error(`Could not read candidate diagnostics: ${diagnosticError.message}`); } } async function followBuild(stream) { const output = await new Promise((resolve, reject) => { docker.modem.followProgress(stream, (error, events) => error ? reject(error) : resolve(events), (event) => { if (event.stream) process.stdout.write(event.stream); if (event.error) process.stderr.write(`${event.error}\n`); }); }); const failure = output.find((event) => event.error || event.errorDetail?.message); if (failure) throw new Error(failure.error || failure.errorDetail.message); } console.log(`Building ${image}`); const context = tar.pack(process.cwd(), { ignore: (name) => { const relative = name.replace(/^\.\//, ""); if (relative === ".git" || relative.startsWith(".git/")) return true; if (relative === "node_modules" || relative.startsWith("node_modules/")) return true; return !usePrebuiltOutput && (relative === ".next" || relative.startsWith(".next/")); }, }); await followBuild(await docker.buildImage(context, { t: image, dockerfile })); try { await docker.createVolume({ Name: volume }); } catch (error) { if (error.statusCode !== 409) throw error; } await removeContainer(candidate); await removeContainer(previous); const current = docker.getContainer(service); let hasCurrent = false; try { const state = await current.inspect(); hasCurrent = true; if (state.State.Running) await current.stop({ t: 15 }); await current.rename({ name: previous }); } catch (error) { if (error.statusCode !== 404) throw error; } let next; try { next = await docker.createContainer({ name: candidate, Image: image, Env: ["NODE_ENV=production"], ExposedPorts: { "3000/tcp": {} }, HostConfig: { RestartPolicy: { Name: "unless-stopped" }, PortBindings: { "3000/tcp": [{ HostIp: "0.0.0.0", HostPort: port }] }, Mounts: [{ Type: "volume", Source: volume, Target: "/app/data/projects" }], }, }); await next.start(); await waitForHealth(next); await next.rename({ name: service }); if (hasCurrent) await removeContainer(previous); console.log(`Deployed ${image} on port ${port}`); } catch (error) { if (next) { await reportContainerFailure(next); await removeContainer(candidate).catch(() => undefined); } if (hasCurrent) { const rollback = docker.getContainer(previous); await rollback.rename({ name: service }); await rollback.start(); console.error("Candidate failed; previous container restored"); } throw error; }