From 1febd203d4859b30ff5d6824f6165bf1f74fc3da Mon Sep 17 00:00:00 2001 From: Vadime Date: Tue, 18 Aug 2026 14:58:18 +0200 Subject: [PATCH] Add centralized immutable QuickStack deployment workflow --- .gitea/actions/quickstack-oci/action.yml | 15 + .gitea/actions/quickstack-oci/deploy.mjs | 359 ++++++++++++++++++ .gitea/actions/quickstack-oci/deploy.test.mjs | 90 +++++ .gitea/scoped_workflows/quickstack-oci.yml | 32 ++ README.md | 44 +++ 5 files changed, 540 insertions(+) create mode 100644 .gitea/actions/quickstack-oci/action.yml create mode 100644 .gitea/actions/quickstack-oci/deploy.mjs create mode 100644 .gitea/actions/quickstack-oci/deploy.test.mjs create mode 100644 .gitea/scoped_workflows/quickstack-oci.yml create mode 100644 README.md diff --git a/.gitea/actions/quickstack-oci/action.yml b/.gitea/actions/quickstack-oci/action.yml new file mode 100644 index 0000000..2953aa4 --- /dev/null +++ b/.gitea/actions/quickstack-oci/action.yml @@ -0,0 +1,15 @@ +name: Immutable QuickStack OCI deployment +description: Build an OCI image once, publish it to Gitea and deploy its exact digest through QuickStack. +inputs: + config-path: + description: Path to the repository deployment manifest. + required: false + default: .quickstack/deploy.json +runs: + using: composite + steps: + - name: Execute immutable deployment + shell: bash + env: + QUICKSTACK_DEPLOY_CONFIG: ${{ inputs.config-path }} + run: node "$GITHUB_ACTION_PATH/deploy.mjs" diff --git a/.gitea/actions/quickstack-oci/deploy.mjs b/.gitea/actions/quickstack-oci/deploy.mjs new file mode 100644 index 0000000..81f728e --- /dev/null +++ b/.gitea/actions/quickstack-oci/deploy.mjs @@ -0,0 +1,359 @@ +#!/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}`), + }; +} + +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 = eventName === "pull_request" + ? process.env.GITHUB_BASE_REF ?? process.env.GITEA_BASE_REF + : process.env.GITHUB_REF_NAME ?? process.env.GITEA_REF_NAME; + const deployment = selectDeployment(config, requiredString(branch, "Git 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 }); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/.gitea/actions/quickstack-oci/deploy.test.mjs b/.gitea/actions/quickstack-oci/deploy.test.mjs new file mode 100644 index 0000000..8e60b2a --- /dev/null +++ b/.gitea/actions/quickstack-oci/deploy.test.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + deployExactImage, + mergeEnvironment, + selectDeployment, + toSavePayload, + validateTarget, + verifyEndpoint, +} from "./deploy.mjs"; + +function response(status, body, headers = {}) { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...headers } }); +} + +function app(overrides = {}) { + return { + id: "app-one", name: "One", appType: "APP", projectId: "project-one", + sourceType: "GIT", buildMethod: "DOCKERFILE", containerImageSource: null, + containerRegistryUsername: null, containerRegistryPassword: null, dockerfilePath: "./Dockerfile", + replicas: 1, envVars: "SECRET=preserved\nENVIRONMENT=old", ingressNetworkPolicy: "[]", + egressNetworkPolicy: "[]", useNetworkPolicy: false, healthCheckPeriodSeconds: 10, + healthCheckTimeoutSeconds: 2, healthCheckFailureThreshold: 3, appDomains: [], appPorts: [], + appNodePorts: [], appFileMounts: [], appVolumes: [], appBasicAuths: [], project: {}, + createdAt: "ignored", updatedAt: "ignored", ...overrides, + }; +} + +test("selects only the deployment matching the triggering branch", () => { + const config = { version: 1, deployments: [{ branch: "main" }, { branch: "v1" }] }; + assert.equal(selectDeployment(config, "v1")?.branch, "v1"); + assert.equal(selectDeployment(config, "other"), null); +}); + +test("validates OCI paths and repository-local build paths", () => { + assert.equal(validateTarget({ name: "Web", image: "Owner/Web", appId: "app-1" }).image, "owner/web"); + assert.throws(() => validateTarget({ name: "Web", image: "owner/web", appId: "app-1", context: "../secret" }), /inside/); +}); + +test("preserves response-only fields and existing environment secrets safely", () => { + const payload = toSavePayload(app()); + assert.equal(payload.createdAt, undefined); + assert.equal(mergeEnvironment(app().envVars, { ENVIRONMENT: "new" }), "SECRET=preserved\nENVIRONMENT=new"); +}); + +test("postflight waits for the exact expected identity", async () => { + let attempt = 0; + const result = await verifyEndpoint( + { url: "https://app.example", expectedHeader: "x-build", expectedValue: "$sha12", attempts: 2, intervalSeconds: 0 }, + { + sha: "1234567890abcdef1234567890abcdef12345678", + sleep: async () => {}, + fetchImpl: async () => { + attempt += 1; + return response(200, {}, { "x-build": attempt === 2 ? "1234567890ab" : "old" }); + }, + }, + ); + assert.equal(result.headerValue, "1234567890ab"); +}); + +test("failed public verification restores and redeploys the previous source", async () => { + const original = app(); + let current = original; + let deployments = 0; + const client = { + getApp: async () => current, + saveApp: async (payload) => { current = { ...current, ...payload }; }, + deployApp: async () => ({ deploymentId: `deployment-${++deployments}` }), + getDeployment: async (_appId, deploymentId) => ({ deploymentId, status: "DEPLOYED" }), + }; + await assert.rejects( + deployExactImage({ + client, + appId: "app-one", + image: `gitea.example/owner/app@sha256:${"a".repeat(64)}`, + registryUsername: "deploy", + registryToken: "token", + sha: "1".repeat(40), + sleep: async () => {}, + postflight: { url: "https://app.example", attempts: 1 }, + fetchImpl: async () => response(503, {}), + }), + /rolled back/, + ); + assert.equal(deployments, 2); + assert.equal(current.sourceType, original.sourceType); + assert.equal(current.containerImageSource, original.containerImageSource); +}); diff --git a/.gitea/scoped_workflows/quickstack-oci.yml b/.gitea/scoped_workflows/quickstack-oci.yml new file mode 100644 index 0000000..2abd638 --- /dev/null +++ b/.gitea/scoped_workflows/quickstack-oci.yml @@ -0,0 +1,32 @@ +name: Immutable QuickStack OCI deployment + +on: + push: + pull_request: + workflow_dispatch: + +concurrency: + group: quickstack-oci-${{ gitea.repository }}-${{ gitea.ref }} + cancel-in-progress: false + +jobs: + build_and_deploy: + name: Build once and deploy exact digest + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout consuming repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Build and deploy declared QuickStack targets + uses: ./.gitea/actions/quickstack-oci + with: + config-path: .quickstack/deploy.json + env: + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + QUICKSTACK_API_TOKEN: ${{ secrets.QUICKSTACK_API_TOKEN }} + QUICKSTACK_BASE_URL: https://server.nuvisphere.de diff --git a/README.md b/README.md new file mode 100644 index 0000000..67a3e52 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# Platform CI + +This repository is the central, public source for Nuvisphere's Gitea scoped +workflows. Workflow code contains no credentials. Each consuming owner or +organization supplies its own `REGISTRY_USERNAME`, `REGISTRY_TOKEN` and +`QUICKSTACK_API_TOKEN` Actions secrets. + +Repositories opt into deployment by committing `.quickstack/deploy.json`. +The central workflow builds every declared target once, publishes an immutable +`sha-` OCI tag to the Gitea Container Registry and changes QuickStack +to the exact `@sha256:` digest. A failed rollout or postflight automatically +restores the previous QuickStack configuration. + +Example: + +```json +{ + "version": 1, + "registry": "gitea.nuvisphere.de", + "deployments": [ + { + "name": "production", + "branch": "main", + "targets": [ + { + "name": "website", + "image": "vadimenovikau/example", + "dockerfile": "Dockerfile", + "context": ".", + "appId": "app-example-12345678", + "healthCheckTcpPort": 3000, + "postflight": { + "url": "https://example.com" + } + } + ] + } + ] +} +``` + +Pull requests build all matching targets without logging in, publishing or +deploying. Pushes and manual runs publish and deploy. The commit marker +`[skip quickstack-deploy]` skips a push deliberately.