Add centralized immutable QuickStack deployment workflow
This commit is contained in:
@@ -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"
|
||||||
@@ -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 || "<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, 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -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-<commit>` 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.
|
||||||
Reference in New Issue
Block a user