Files
rednote-tool-studio/scripts/deploy.mjs
T
2026-09-17 23:11:05 +08:00

127 lines
4.9 KiB
JavaScript

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 || "3000";
const password = process.env.STUDIO_PASSWORD;
const username = process.env.STUDIO_USERNAME || "studio";
const dockerfile = process.env.DEPLOY_DOCKERFILE || "Dockerfile";
const usePrebuiltOutput = dockerfile === "Dockerfile.deploy";
if (!password) throw new Error("STUDIO_PASSWORD secret is required");
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: [`STUDIO_USERNAME=${username}`, `STUDIO_PASSWORD=${password}`, "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;
}