This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
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";
|
||||
|
||||
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 followBuild(stream) {
|
||||
await new Promise((resolve, reject) => {
|
||||
docker.modem.followProgress(stream, (error, output) => error ? reject(error) : resolve(output), (event) => {
|
||||
if (event.stream) process.stdout.write(event.stream);
|
||||
if (event.error) process.stderr.write(`${event.error}\n`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Building ${image}`);
|
||||
const context = tar.pack(process.cwd(), {
|
||||
ignore: (name) => [".git", ".next", "node_modules"].some((part) => name === part || name.includes(`/${part}/`)),
|
||||
});
|
||||
await followBuild(await docker.buildImage(context, { t: image, dockerfile: "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 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;
|
||||
}
|
||||
Reference in New Issue
Block a user