Files
Platform-CI/.gitea/actions/quickstack-oci/deploy.mjs
T
vadimenovikau 2d3c7500b1
vadimenovikau/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (push) Successful in 24s
Platform CI tests / QuickStack deploy action tests (pull_request) Successful in 20s
vadimenovikau/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (pull_request) Successful in 19s
ci: retry transient QuickStack rollouts
2026-08-18 19:31:28 +02:00

438 lines
18 KiB
JavaScript

#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const SUCCESS = new Set(["DEPLOYED"]);
const FAILURE = new Set(["ERROR", "SHUTDOWN"]);
const APP_FIELDS = [
"id", "name", "appType", "projectId", "sourceType", "buildMethod",
"containerImageSource", "containerRegistryUsername", "containerRegistryPassword",
"containerCommand", "containerArgs", "securityContextRunAsUser",
"securityContextRunAsGroup", "securityContextFsGroup", "securityContextPrivileged",
"gitUrl", "gitBranch", "gitUsername", "gitToken", "dockerfilePath", "replicas",
"envVars", "memoryReservation", "memoryLimit", "cpuReservation", "cpuLimit",
"webhookId", "ingressNetworkPolicy", "egressNetworkPolicy", "useNetworkPolicy",
"healthChechHttpGetPath", "healthCheckHttpScheme", "healthCheckHttpHeadersJson",
"healthCheckHttpPort", "healthCheckPeriodSeconds", "healthCheckTimeoutSeconds",
"healthCheckFailureThreshold", "healthCheckTcpPort",
];
const COLLECTION_FIELDS = {
appDomains: ["id", "hostname", "port", "useSsl", "redirectHttps"],
appPorts: ["id", "port"],
appNodePorts: ["id", "port", "nodePort", "protocol"],
appFileMounts: ["id", "containerMountPath", "content"],
appVolumes: [
"id", "containerMountPath", "size", "accessMode", "storageClassName",
"shareWithOtherApps", "sharedVolumeId",
],
appBasicAuths: ["id", "username", "password"],
};
function requiredString(value, name) {
const normalized = String(value ?? "").trim();
if (!normalized) throw new Error(`${name} is required.`);
return normalized;
}
function pick(source, fields) {
return Object.fromEntries(
fields.filter((field) => source[field] !== undefined).map((field) => [field, source[field]]),
);
}
export function toSavePayload(app) {
const payload = pick(app, APP_FIELDS);
for (const [name, fields] of Object.entries(COLLECTION_FIELDS)) {
payload[name] = Array.isArray(app[name]) ? app[name].map((item) => pick(item, fields)) : [];
}
return payload;
}
export function mergeEnvironment(source, overrides = {}) {
const rows = [];
const positions = new Map();
for (const line of String(source ?? "").split(/\r?\n/)) {
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) {
if (line.trim()) rows.push({ raw: line });
continue;
}
positions.set(match[1], rows.length);
rows.push({ key: match[1], value: match[2] });
}
for (const [key, rawValue] of Object.entries(overrides)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(`Invalid environment key ${key}.`);
const row = { key, value: String(rawValue) };
const position = positions.get(key);
if (position === undefined) {
positions.set(key, rows.length);
rows.push(row);
} else {
rows[position] = row;
}
}
return rows.map((row) => row.raw ?? `${row.key}=${row.value}`).join("\n");
}
async function readBody(response) {
const text = await response.text();
if (!text) return null;
try { return JSON.parse(text); } catch { return { detail: text }; }
}
export function createQuickStackClient({ baseUrl, token, fetchImpl = fetch }) {
const base = requiredString(baseUrl, "QuickStack base URL").replace(/\/$/, "");
const bearer = requiredString(token, "QuickStack API token");
async function request(method, pathname, body) {
const response = await fetchImpl(`${base}${pathname}`, {
method,
headers: {
Accept: "application/json",
Authorization: `Bearer ${bearer}`,
...(body === undefined ? {} : { "Content-Type": "application/json" }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const parsed = await readBody(response);
if (!response.ok) {
throw new Error(`${method} ${pathname} failed with HTTP ${response.status}: ${parsed?.detail ?? response.statusText}`);
}
return parsed;
}
return {
getApp: (id) => request("GET", `/api/v1/apps/${encodeURIComponent(id)}`),
saveApp: (payload) => request("POST", "/api/v1/apps", payload),
deployApp: (id) => request("POST", `/api/v1/apps/${encodeURIComponent(id)}/deploy`),
getDeployment: (appId, deploymentId) => request(
"GET",
`/api/v1/apps/${encodeURIComponent(appId)}/deploy/${encodeURIComponent(deploymentId)}`,
),
};
}
async function waitForDeployment(client, appId, deploymentId, sleep) {
for (let attempt = 1; attempt <= 180; attempt += 1) {
const deployment = await client.getDeployment(appId, deploymentId);
const status = String(deployment?.status ?? "UNKNOWN").toUpperCase();
console.log(`QuickStack deployment ${deploymentId}: ${status} (${attempt}/180)`);
if (SUCCESS.has(status)) return deployment;
if (FAILURE.has(status)) throw new Error(`QuickStack deployment ${deploymentId} ended with ${status}.`);
await sleep(5_000);
}
throw new Error(`QuickStack deployment ${deploymentId} timed out.`);
}
async function trigger(client, appId, sleep) {
const started = await client.deployApp(appId);
const deploymentId = requiredString(started?.deploymentId, "QuickStack deployment ID");
return { deploymentId, deployment: await waitForDeployment(client, appId, deploymentId, sleep) };
}
async function triggerWithRetries({
client, appId, sleep, attempts = 1, retryDelayMs = 15_000, label = "deployment", restoreConfig,
}) {
let latestError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await trigger(client, appId, sleep);
} catch (error) {
latestError = error;
if (attempt >= attempts) throw error;
console.log(
`QuickStack ${label} attempt ${attempt}/${attempts} failed; retrying in ${retryDelayMs / 1000}s: ${error instanceof Error ? error.message : error}`,
);
await sleep(retryDelayMs);
await restoreConfig();
}
}
throw latestError;
}
export async function verifyEndpoint(postflight, { sha, fetchImpl = fetch, sleep }) {
if (!postflight) return null;
const url = requiredString(postflight.url, "Postflight URL");
const attempts = Number(postflight.attempts ?? 60);
const intervalMs = Number(postflight.intervalSeconds ?? 10) * 1000;
const statusMax = Number(postflight.statusMax ?? 399);
const expectedHeader = String(postflight.expectedHeader ?? "").trim();
const expectedValue = String(postflight.expectedValue ?? "")
.replaceAll("$sha12", sha.slice(0, 12))
.replaceAll("$sha", sha);
if (expectedValue && !expectedHeader) throw new Error("Postflight expectedValue requires expectedHeader.");
let latest = "no response";
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const response = await fetchImpl(url, {
redirect: "follow",
signal: AbortSignal.timeout(20_000),
});
const actual = expectedHeader ? String(response.headers.get(expectedHeader) ?? "").trim() : "";
latest = `HTTP ${response.status}${expectedHeader ? ` ${expectedHeader}=${actual || "<missing>"}` : ""}`;
console.log(`Postflight ${attempt}/${attempts}: ${latest}`);
if (response.status >= 200 && response.status <= statusMax && (!expectedValue || actual === expectedValue)) {
return { status: response.status, headerValue: actual || null };
}
} catch (error) {
latest = error instanceof Error ? error.message : String(error);
console.log(`Postflight ${attempt}/${attempts}: ${latest}`);
}
if (attempt < attempts) await sleep(intervalMs);
}
throw new Error(`Postflight failed for ${url} (${latest}).`);
}
export async function deployExactImage({
client, appId, image, registryUsername, registryToken, environment = {},
healthCheckTcpPort, postflight, sha, deploymentAttempts = 1, deploymentRetrySeconds = 15,
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
fetchImpl = fetch,
}) {
if (!/@sha256:[0-9a-f]{64}$/.test(image)) throw new Error("Deployment image must use an exact sha256 digest.");
const attempts = Number(deploymentAttempts);
const retryDelaySeconds = Number(deploymentRetrySeconds);
if (!Number.isInteger(attempts) || attempts < 1) throw new Error("Deployment attempts must be a positive integer.");
if (!Number.isFinite(retryDelaySeconds) || retryDelaySeconds < 0) {
throw new Error("Deployment retry seconds must be a non-negative number.");
}
const current = await client.getApp(appId);
const previous = toSavePayload(current);
const next = {
...previous,
sourceType: "CONTAINER",
containerImageSource: image,
containerRegistryUsername: requiredString(registryUsername, "Registry username"),
containerRegistryPassword: requiredString(registryToken, "Registry token"),
envVars: mergeEnvironment(previous.envVars, environment),
...(healthCheckTcpPort === undefined ? {} : { healthCheckTcpPort: Number(healthCheckTcpPort) }),
};
await client.saveApp(next);
try {
const result = await triggerWithRetries({
client,
appId,
sleep,
attempts,
retryDelayMs: retryDelaySeconds * 1000,
label: "deployment",
restoreConfig: () => client.saveApp(next),
});
const persisted = await client.getApp(appId);
if (persisted.sourceType !== "CONTAINER" || persisted.containerImageSource !== image) {
throw new Error("QuickStack did not persist the exact image digest.");
}
await verifyEndpoint(postflight, { sha, fetchImpl, sleep });
return { appId, deploymentId: result.deploymentId, image, status: result.deployment.status };
} catch (error) {
await client.saveApp(previous);
try {
const rollback = await triggerWithRetries({
client,
appId,
sleep,
attempts,
retryDelayMs: retryDelaySeconds * 1000,
label: "rollback",
restoreConfig: () => client.saveApp(previous),
});
throw new Error(
`Deployment failed and was rolled back with ${rollback.deploymentId}: ${error instanceof Error ? error.message : error}`,
);
} catch (rollbackError) {
if (rollbackError instanceof Error && rollbackError.message.startsWith("Deployment failed and was rolled back")) {
throw rollbackError;
}
throw new AggregateError([error, rollbackError], `Deployment and rollback failed for ${appId}.`);
}
}
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd,
env: options.env,
input: options.input,
encoding: "utf8",
maxBuffer: 100 * 1024 * 1024,
stdio: options.capture ? ["pipe", "pipe", "pipe"] : ["pipe", "inherit", "inherit"],
});
if (result.error) throw result.error;
if (result.status !== 0) {
if (options.capture) {
process.stdout.write(result.stdout ?? "");
process.stderr.write(result.stderr ?? "");
}
throw new Error(`${command} ${args.join(" ")} exited with ${result.status}.`);
}
return `${result.stdout ?? ""}${result.stderr ?? ""}`;
}
function safeRelative(value, name) {
const normalized = requiredString(value, name).replace(/^\.\//, "");
if (path.isAbsolute(normalized) || normalized.split(/[\\/]/).includes("..")) {
throw new Error(`${name} must stay inside the repository.`);
}
return normalized || ".";
}
export function selectDeployment(config, branch) {
if (config.version !== 1 || !Array.isArray(config.deployments)) {
throw new Error("Deployment manifest must use version 1 and contain deployments[].");
}
return config.deployments.find((entry) => entry.branch === branch) ?? null;
}
export function validateTarget(target) {
const name = requiredString(target.name, "Target name");
const image = requiredString(target.image, `Image for ${name}`).toLowerCase();
if (!/^[a-z0-9][a-z0-9._/-]*$/.test(image)) throw new Error(`Invalid OCI image path ${image}.`);
const appId = requiredString(target.appId, `QuickStack app ID for ${name}`);
const deploymentAttempts = target.deploymentAttempts === undefined ? undefined : Number(target.deploymentAttempts);
const deploymentRetrySeconds = target.deploymentRetrySeconds === undefined
? undefined
: Number(target.deploymentRetrySeconds);
if (deploymentAttempts !== undefined && (!Number.isInteger(deploymentAttempts) || deploymentAttempts < 1)) {
throw new Error(`Deployment attempts for ${name} must be a positive integer.`);
}
if (deploymentRetrySeconds !== undefined && (!Number.isFinite(deploymentRetrySeconds) || deploymentRetrySeconds < 0)) {
throw new Error(`Deployment retry seconds for ${name} must be a non-negative number.`);
}
return {
...target,
name,
image,
appId,
...(deploymentAttempts === undefined ? {} : { deploymentAttempts }),
...(deploymentRetrySeconds === undefined ? {} : { deploymentRetrySeconds }),
dockerfile: safeRelative(target.dockerfile ?? "Dockerfile", `Dockerfile for ${name}`),
context: safeRelative(target.context ?? ".", `Build context for ${name}`),
};
}
export function resolveDeploymentBranch({ eventName, environment = process.env, eventPayload } = {}) {
if (eventName !== "pull_request") {
return requiredString(environment.GITHUB_REF_NAME || environment.GITEA_REF_NAME, "Git branch");
}
const direct = String(environment.GITHUB_BASE_REF || environment.GITEA_BASE_REF || "").trim();
if (direct) return direct;
let payload = eventPayload;
if (payload === undefined) {
const eventPath = String(environment.GITHUB_EVENT_PATH || environment.GITEA_EVENT_PATH || "").trim();
if (eventPath) {
payload = JSON.parse(fs.readFileSync(eventPath, "utf8"));
}
}
return requiredString(
payload?.pull_request?.base?.ref || payload?.pull_request?.base?.repo?.default_branch,
"Pull request base branch",
);
}
function appendSummary(text) {
const summary = process.env.GITHUB_STEP_SUMMARY ?? process.env.GITEA_STEP_SUMMARY;
if (summary) fs.appendFileSync(summary, `${text}\n`);
}
async function main() {
const workspace = process.env.GITHUB_WORKSPACE ?? process.cwd();
const configPath = path.resolve(workspace, process.env.QUICKSTACK_DEPLOY_CONFIG ?? ".quickstack/deploy.json");
if (!fs.existsSync(configPath)) {
console.log(`No ${path.relative(workspace, configPath)} manifest; scoped deployment does not apply.`);
return;
}
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
const eventName = process.env.GITHUB_EVENT_NAME ?? process.env.GITEA_EVENT_NAME ?? "";
const branch = resolveDeploymentBranch({ eventName });
const deployment = selectDeployment(config, branch);
if (!deployment) {
console.log(`No deployment is declared for branch ${branch}.`);
return;
}
const targets = (deployment.targets ?? []).map(validateTarget);
if (!targets.length) throw new Error(`Deployment ${deployment.name ?? branch} has no targets.`);
const sha = requiredString(process.env.GITHUB_SHA ?? process.env.GITEA_SHA, "Git SHA").toLowerCase();
if (!/^[0-9a-f]{40,64}$/.test(sha)) throw new Error("Git SHA must be a full hexadecimal commit ID.");
const commitMessage = run("git", ["log", "-1", "--pretty=%B"], { cwd: workspace, capture: true });
if (eventName === "push" && commitMessage.includes("[skip quickstack-deploy]")) {
console.log("Deployment intentionally skipped by commit marker.");
return;
}
const validationOnly = eventName === "pull_request";
const registry = requiredString(config.registry ?? "gitea.nuvisphere.de", "OCI registry").replace(/\/$/, "");
const dockerConfig = fs.mkdtempSync(path.join(os.tmpdir(), "quickstack-docker-"));
const dockerEnv = { ...process.env, DOCKER_CONFIG: dockerConfig };
try {
if (!validationOnly) {
run(
"docker",
["login", registry, "--username", requiredString(process.env.REGISTRY_USERNAME, "Registry username"), "--password-stdin"],
{ env: dockerEnv, input: requiredString(process.env.REGISTRY_TOKEN, "Registry token") },
);
}
const client = validationOnly ? null : createQuickStackClient({
baseUrl: process.env.QUICKSTACK_BASE_URL,
token: process.env.QUICKSTACK_API_TOKEN,
});
for (const target of targets) {
const taggedImage = `${registry}/${target.image}:sha-${sha}`;
const buildArgs = [
"build", "--progress=plain", "--file", target.dockerfile,
"--label", `org.opencontainers.image.source=${process.env.GITHUB_SERVER_URL ?? process.env.GITEA_SERVER_URL}/${process.env.GITHUB_REPOSITORY ?? process.env.GITEA_REPOSITORY}`,
"--label", `org.opencontainers.image.revision=${sha}`,
"--tag", taggedImage,
];
for (const [key, value] of Object.entries(target.buildArgs ?? {})) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(`Invalid build argument ${key}.`);
buildArgs.push("--build-arg", `${key}=${value}`);
}
buildArgs.push(target.context);
console.log(`Building ${target.name} from ${target.dockerfile} as ${taggedImage}.`);
run("docker", buildArgs, { cwd: workspace, env: dockerEnv });
if (validationOnly) continue;
const pushOutput = run("docker", ["push", taggedImage], { cwd: workspace, env: dockerEnv, capture: true });
process.stdout.write(pushOutput);
const digest = [...pushOutput.matchAll(/digest:\s*(sha256:[0-9a-f]{64})/g)].at(-1)?.[1];
if (!digest) throw new Error(`Registry did not return a digest for ${taggedImage}.`);
const exactImage = `${registry}/${target.image}@${digest}`;
const result = await deployExactImage({
client,
appId: target.appId,
image: exactImage,
registryUsername: process.env.REGISTRY_USERNAME,
registryToken: process.env.REGISTRY_TOKEN,
environment: target.environment,
healthCheckTcpPort: target.healthCheckTcpPort,
postflight: target.postflight,
deploymentAttempts: target.deploymentAttempts,
deploymentRetrySeconds: target.deploymentRetrySeconds,
sha,
});
console.log(`Deployed ${target.name}: ${result.image}`);
appendSummary(`- ${target.name}: \`${result.image}\` (${result.status})`);
}
if (validationOnly) appendSummary(`Validated ${targets.length} QuickStack OCI target(s) for ${branch}.`);
} finally {
fs.rmSync(dockerConfig, { recursive: true, force: true });
}
}
const invokedPath = process.argv[1] ? fs.realpathSync(process.argv[1]) : "";
const modulePath = fs.realpathSync(fileURLToPath(import.meta.url));
if (invokedPath === modulePath) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}