#!/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) }; } 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 || ""}` : ""}`; 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, 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 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 trigger(client, appId, sleep); 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 trigger(client, appId, sleep); 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}`); return { ...target, name, image, appId, 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, 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; }); }