1002 lines
42 KiB
JavaScript
1002 lines
42 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";
|
|
|
|
import {
|
|
ensureTag,
|
|
loadRelease,
|
|
publishRelease,
|
|
resolveGiteaApiUrl,
|
|
} from "./release.mjs";
|
|
|
|
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");
|
|
}
|
|
|
|
export function resolveSecretEnvironment(secretEnvironment = {}, environment = process.env) {
|
|
if (
|
|
secretEnvironment === null ||
|
|
typeof secretEnvironment !== "object" ||
|
|
Array.isArray(secretEnvironment)
|
|
) {
|
|
throw new Error("secretEnvironment must be an object.");
|
|
}
|
|
|
|
const resolved = {};
|
|
for (const [runtimeKey, rawSecretName] of Object.entries(secretEnvironment)) {
|
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(runtimeKey)) {
|
|
throw new Error(`Invalid secret environment key ${runtimeKey}.`);
|
|
}
|
|
const secretName = String(rawSecretName);
|
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(secretName)) {
|
|
throw new Error(`Invalid Actions secret name for ${runtimeKey}.`);
|
|
}
|
|
const value = environment[secretName];
|
|
if (value === undefined || String(value).length === 0) {
|
|
throw new Error(`Missing Actions secret ${secretName} for runtime environment ${runtimeKey}.`);
|
|
}
|
|
resolved[runtimeKey] = String(value);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
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 Object.assign(
|
|
new Error(`${method} ${pathname} failed with HTTP ${response.status}: ${parsed?.detail ?? response.statusText}`),
|
|
{ statusCode: response.status },
|
|
);
|
|
}
|
|
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) {
|
|
let deployment;
|
|
try {
|
|
deployment = await client.getDeployment(appId, deploymentId);
|
|
} catch (error) {
|
|
if (error?.statusCode === 404) {
|
|
console.log(`QuickStack deployment ${deploymentId}: NOT_VISIBLE_YET (${attempt}/180)`);
|
|
await sleep(5_000);
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
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 expandTokens(value, context) {
|
|
return String(value)
|
|
.replaceAll("$sha12", context.sha.slice(0, 12))
|
|
.replaceAll("$sha", context.sha)
|
|
.replaceAll("$version", context.version ?? "");
|
|
}
|
|
|
|
function validateImagePath(value, name) {
|
|
const image = requiredString(value, name).toLowerCase();
|
|
if (!/^[a-z0-9][a-z0-9._/-]*$/.test(image)) throw new Error(`Invalid OCI image path ${image}.`);
|
|
return image;
|
|
}
|
|
|
|
function normalizeBuildSecrets(buildSecrets = {}) {
|
|
if (buildSecrets === null || typeof buildSecrets !== "object" || Array.isArray(buildSecrets)) {
|
|
throw new Error("buildSecrets must be an object.");
|
|
}
|
|
return Object.fromEntries(
|
|
Object.entries(buildSecrets).map(([id, rawEnvironmentName]) => {
|
|
if (!/^[A-Za-z0-9_.-]+$/.test(id)) throw new Error(`Invalid BuildKit secret ID ${id}.`);
|
|
const environmentName = String(rawEnvironmentName);
|
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(environmentName)) {
|
|
throw new Error(`Invalid Actions secret environment name for BuildKit secret ${id}.`);
|
|
}
|
|
return [id, environmentName];
|
|
}),
|
|
);
|
|
}
|
|
|
|
export function resolveBuildSecretArguments(buildSecrets = {}, environment = process.env) {
|
|
return Object.entries(normalizeBuildSecrets(buildSecrets)).flatMap(([id, environmentName]) => {
|
|
const value = environment[environmentName];
|
|
if (value === undefined || String(value).length === 0) {
|
|
throw new Error(`Missing Actions secret ${environmentName} for BuildKit secret ${id}.`);
|
|
}
|
|
return ["--secret", `id=${id},env=${environmentName}`];
|
|
});
|
|
}
|
|
|
|
export function validateArtifact(artifact) {
|
|
const name = requiredString(artifact.name, "Artifact name");
|
|
const requiredFiles = (artifact.requiredFiles ?? []).map((file) => {
|
|
const normalized = requiredString(file, `Required container file for ${name}`);
|
|
if (!/^\/[A-Za-z0-9._/-]+$/.test(normalized) || normalized.includes("..")) {
|
|
throw new Error(`Required container file for ${name} must be a safe absolute path.`);
|
|
}
|
|
return normalized;
|
|
});
|
|
const buildArgs = Object.fromEntries(
|
|
Object.entries(artifact.buildArgs ?? {}).map(([key, value]) => {
|
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(`Invalid build argument ${key}.`);
|
|
return [key, String(value)];
|
|
}),
|
|
);
|
|
return {
|
|
...artifact,
|
|
name,
|
|
image: validateImagePath(artifact.image, `Image for ${name}`),
|
|
dockerfile: safeRelative(artifact.dockerfile ?? "Dockerfile", `Dockerfile for ${name}`),
|
|
context: safeRelative(artifact.context ?? ".", `Build context for ${name}`),
|
|
buildArgs,
|
|
buildSecrets: normalizeBuildSecrets(artifact.buildSecrets),
|
|
requiredFiles,
|
|
};
|
|
}
|
|
|
|
export function validateApplication(application, artifactNames) {
|
|
const name = requiredString(application.name, "Application name");
|
|
const artifact = requiredString(application.artifact, `Artifact for ${name}`);
|
|
if (!artifactNames.has(artifact)) throw new Error(`Application ${name} references unknown artifact ${artifact}.`);
|
|
const dependsOn = (application.dependsOn ?? []).map((dependency) => requiredString(dependency, `Dependency for ${name}`));
|
|
const deploymentAttempts = application.deploymentAttempts === undefined
|
|
? undefined
|
|
: Number(application.deploymentAttempts);
|
|
const deploymentRetrySeconds = application.deploymentRetrySeconds === undefined
|
|
? undefined
|
|
: Number(application.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 {
|
|
...application,
|
|
name,
|
|
artifact,
|
|
appId: requiredString(application.appId, `QuickStack app ID for ${name}`),
|
|
dependsOn,
|
|
...(deploymentAttempts === undefined ? {} : { deploymentAttempts }),
|
|
...(deploymentRetrySeconds === undefined ? {} : { deploymentRetrySeconds }),
|
|
};
|
|
}
|
|
|
|
export function orderApplications(applications) {
|
|
const byName = new Map(applications.map((application) => [application.name, application]));
|
|
if (byName.size !== applications.length) throw new Error("Application names must be unique.");
|
|
for (const application of applications) {
|
|
for (const dependency of application.dependsOn) {
|
|
if (!byName.has(dependency)) throw new Error(`Application ${application.name} depends on unknown application ${dependency}.`);
|
|
if (dependency === application.name) throw new Error(`Application ${application.name} cannot depend on itself.`);
|
|
}
|
|
}
|
|
const ordered = [];
|
|
const complete = new Set();
|
|
while (ordered.length < applications.length) {
|
|
const ready = applications.find(
|
|
(application) => !complete.has(application.name) && application.dependsOn.every((dependency) => complete.has(dependency)),
|
|
);
|
|
if (!ready) throw new Error("Application dependencies contain a cycle.");
|
|
ordered.push(ready);
|
|
complete.add(ready.name);
|
|
}
|
|
return ordered;
|
|
}
|
|
|
|
export function selectPipeline(config, branch) {
|
|
if (config.version !== 2 || !Array.isArray(config.artifacts) || !Array.isArray(config.pipelines)) {
|
|
throw new Error("Deployment manifest version 2 must contain artifacts[] and pipelines[].");
|
|
}
|
|
return config.pipelines.find((entry) => entry.branch === branch) ?? null;
|
|
}
|
|
|
|
export function validateVersion2Pipeline(config, branch) {
|
|
const pipeline = selectPipeline(config, branch);
|
|
if (!pipeline) return null;
|
|
const strategy = requiredString(pipeline.strategy, `Strategy for ${branch}`);
|
|
if (!new Set(["candidate", "promote"]).has(strategy)) {
|
|
throw new Error(`Unsupported strategy ${strategy} for ${branch}.`);
|
|
}
|
|
const artifacts = config.artifacts.map(validateArtifact);
|
|
const artifactNames = new Set(artifacts.map((artifact) => artifact.name));
|
|
if (artifactNames.size !== artifacts.length) throw new Error("Artifact names must be unique.");
|
|
const applications = orderApplications(
|
|
(pipeline.applications ?? []).map((application) => validateApplication(application, artifactNames)),
|
|
);
|
|
if (!applications.length && strategy !== "candidate") {
|
|
throw new Error(`Pipeline ${pipeline.name ?? branch} has no applications.`);
|
|
}
|
|
if (strategy === "promote" && !pipeline.release) {
|
|
throw new Error(`Promotion pipeline ${pipeline.name ?? branch} requires release configuration.`);
|
|
}
|
|
return { ...pipeline, strategy, artifacts, applications };
|
|
}
|
|
|
|
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",
|
|
);
|
|
}
|
|
|
|
export function resolvePullRequestHeadBranch({ environment = process.env, eventPayload } = {}) {
|
|
const direct = String(environment.GITHUB_HEAD_REF || environment.GITEA_HEAD_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?.head?.ref, "Pull request head branch");
|
|
}
|
|
|
|
export function classifyVersion2Execution(pipeline, eventName) {
|
|
if (eventName === "pull_request") {
|
|
return pipeline.strategy === "promote" ? "validate-candidate" : "build-validation";
|
|
}
|
|
return pipeline.strategy === "candidate" ? "build-deploy" : "promote";
|
|
}
|
|
|
|
export function resolveActionMode(value = "all") {
|
|
const mode = String(value || "all").trim().toLowerCase();
|
|
if (!new Set(["all", "build", "coordinate"]).has(mode)) {
|
|
throw new Error(`Unsupported QuickStack action mode ${mode}.`);
|
|
}
|
|
return mode;
|
|
}
|
|
|
|
export function resolveArtifactIndex(value = "0") {
|
|
const index = Number(value);
|
|
if (!Number.isInteger(index) || index < 0) {
|
|
throw new Error("QuickStack artifact index must be a non-negative integer.");
|
|
}
|
|
return index;
|
|
}
|
|
|
|
export function validatePromotionPullRequestSource(pipeline, headBranch) {
|
|
const sourceBranch = requiredString(pipeline.source?.branch, "Promotion source branch");
|
|
const actualHeadBranch = requiredString(headBranch, "Pull request head branch");
|
|
if (actualHeadBranch !== sourceBranch) {
|
|
throw new Error(`Production promotion pull requests must originate from ${sourceBranch}, not ${actualHeadBranch}.`);
|
|
}
|
|
return sourceBranch;
|
|
}
|
|
|
|
function appendSummary(text) {
|
|
const summary = process.env.GITHUB_STEP_SUMMARY ?? process.env.GITEA_STEP_SUMMARY;
|
|
if (summary) fs.appendFileSync(summary, `${text}\n`);
|
|
}
|
|
|
|
function dockerLogin(registry, dockerEnv) {
|
|
run(
|
|
"docker",
|
|
["login", registry, "--username", requiredString(process.env.REGISTRY_USERNAME, "Registry username"), "--password-stdin"],
|
|
{ env: dockerEnv, input: requiredString(process.env.REGISTRY_TOKEN, "Registry token") },
|
|
);
|
|
}
|
|
|
|
function exactDigestFromPush(pushOutput, taggedImage) {
|
|
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}.`);
|
|
return digest;
|
|
}
|
|
|
|
function verifyRequiredContainerFiles(taggedImage, requiredFiles, options) {
|
|
for (const filename of requiredFiles) {
|
|
run("docker", ["run", "--rm", "--entrypoint", "test", taggedImage, "-s", filename], options);
|
|
}
|
|
}
|
|
|
|
function buildArtifact({ artifact, registry, sha, workspace, dockerEnv, validationOnly }) {
|
|
const taggedImage = `${registry}/${artifact.image}:sha-${sha}`;
|
|
const cacheImage = `${registry}/${artifact.image}:buildcache`;
|
|
const buildArgs = [
|
|
"build", "--progress=plain", "--file", artifact.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(artifact.buildArgs)) {
|
|
buildArgs.push("--build-arg", `${key}=${expandTokens(value, { sha })}`);
|
|
}
|
|
buildArgs.push(...resolveBuildSecretArguments(artifact.buildSecrets, dockerEnv));
|
|
if (!validationOnly) {
|
|
try {
|
|
run("docker", ["pull", cacheImage], { cwd: workspace, env: dockerEnv, capture: true });
|
|
buildArgs.push("--cache-from", cacheImage);
|
|
console.log(`Using registry build cache ${cacheImage}.`);
|
|
} catch {
|
|
console.log(`No reusable registry build cache is available for ${artifact.name}.`);
|
|
}
|
|
buildArgs.push(
|
|
"--build-arg", "BUILDKIT_INLINE_CACHE=1",
|
|
"--tag", cacheImage,
|
|
);
|
|
}
|
|
buildArgs.push(artifact.context);
|
|
console.log(`Building ${artifact.name} from ${artifact.dockerfile} as ${taggedImage}.`);
|
|
run("docker", buildArgs, { cwd: workspace, env: dockerEnv });
|
|
verifyRequiredContainerFiles(taggedImage, artifact.requiredFiles, { cwd: workspace, env: dockerEnv });
|
|
if (validationOnly) return { artifact, taggedImage, exactImage: null, digest: null };
|
|
const pushOutput = run("docker", ["push", taggedImage], { cwd: workspace, env: dockerEnv, capture: true });
|
|
process.stdout.write(pushOutput);
|
|
const digest = exactDigestFromPush(pushOutput, taggedImage);
|
|
try {
|
|
const cacheOutput = run("docker", ["push", cacheImage], { cwd: workspace, env: dockerEnv, capture: true });
|
|
process.stdout.write(cacheOutput);
|
|
} catch (error) {
|
|
console.log(`Registry cache publication failed for ${artifact.name}; the immutable image remains valid: ${error instanceof Error ? error.message : error}`);
|
|
}
|
|
return { artifact, taggedImage, digest, exactImage: `${registry}/${artifact.image}@${digest}` };
|
|
}
|
|
|
|
function pullCandidateArtifact({ artifact, registry, sourceSha, workspace, dockerEnv }) {
|
|
const taggedImage = `${registry}/${artifact.image}:sha-${sourceSha}`;
|
|
run("docker", ["pull", taggedImage], { cwd: workspace, env: dockerEnv });
|
|
verifyRequiredContainerFiles(taggedImage, artifact.requiredFiles, { cwd: workspace, env: dockerEnv });
|
|
const repoDigests = run(
|
|
"docker",
|
|
["image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", taggedImage],
|
|
{ cwd: workspace, env: dockerEnv, capture: true },
|
|
).split(/\r?\n/).map((value) => value.trim()).filter(Boolean);
|
|
const prefix = `${registry}/${artifact.image}@`;
|
|
const exactImage = repoDigests.find((value) => value.startsWith(prefix));
|
|
if (!exactImage || !/@sha256:[0-9a-f]{64}$/.test(exactImage)) {
|
|
throw new Error(`Could not resolve an immutable candidate digest for ${taggedImage}.`);
|
|
}
|
|
return { artifact, taggedImage, exactImage, digest: exactImage.slice(exactImage.lastIndexOf("@") + 1) };
|
|
}
|
|
|
|
function resolvePromotionSource(workspace, source = {}) {
|
|
const mergeParent = Number(source.mergeParent ?? 2);
|
|
if (!Number.isInteger(mergeParent) || mergeParent < 1) {
|
|
throw new Error("Promotion mergeParent must be a positive integer.");
|
|
}
|
|
let sourceSha;
|
|
try {
|
|
sourceSha = run("git", ["rev-parse", `HEAD^${mergeParent}`], { cwd: workspace, capture: true }).trim();
|
|
} catch {
|
|
throw new Error(`Promotion requires a merge commit with parent ${mergeParent}.`);
|
|
}
|
|
if (source.requireTreeMatch !== false) {
|
|
const releaseTree = run("git", ["rev-parse", "HEAD^{tree}"], { cwd: workspace, capture: true }).trim();
|
|
const candidateTree = run("git", ["rev-parse", `${sourceSha}^{tree}`], { cwd: workspace, capture: true }).trim();
|
|
if (releaseTree !== candidateTree) {
|
|
throw new Error("The production merge tree differs from its tested candidate parent.");
|
|
}
|
|
}
|
|
return sourceSha;
|
|
}
|
|
|
|
function promoteArtifactAliases(artifacts, releaseTag, options) {
|
|
for (const artifact of artifacts.values()) {
|
|
const releaseImage = `${artifact.exactImage.slice(0, artifact.exactImage.lastIndexOf("@"))}:${releaseTag}`;
|
|
run("docker", ["tag", artifact.taggedImage, releaseImage], options);
|
|
const pushOutput = run("docker", ["push", releaseImage], { ...options, capture: true });
|
|
process.stdout.write(pushOutput);
|
|
const releaseDigest = exactDigestFromPush(pushOutput, releaseImage);
|
|
if (releaseDigest !== artifact.digest) {
|
|
throw new Error(`Release alias ${releaseImage} does not match the verified candidate digest.`);
|
|
}
|
|
console.log(`Promoted ${artifact.exactImage} as ${releaseImage}.`);
|
|
}
|
|
}
|
|
|
|
async function deployApplications({ applications, artifacts, sha, client }) {
|
|
for (const application of applications) {
|
|
const artifact = artifacts.get(application.artifact);
|
|
const environment = {
|
|
...Object.fromEntries(
|
|
Object.entries(application.environment ?? {}).map(([key, value]) => [key, expandTokens(value, { sha })]),
|
|
),
|
|
...resolveSecretEnvironment(application.secretEnvironment),
|
|
};
|
|
const result = await deployExactImage({
|
|
client,
|
|
appId: application.appId,
|
|
image: artifact.exactImage,
|
|
registryUsername: process.env.REGISTRY_USERNAME,
|
|
registryToken: process.env.REGISTRY_TOKEN,
|
|
environment,
|
|
healthCheckTcpPort: application.healthCheckTcpPort,
|
|
postflight: application.postflight,
|
|
deploymentAttempts: application.deploymentAttempts,
|
|
deploymentRetrySeconds: application.deploymentRetrySeconds,
|
|
sha,
|
|
});
|
|
console.log(`Deployed ${application.name}: ${result.image}`);
|
|
appendSummary(`- ${application.name}: \`${result.image}\` (${result.status})`);
|
|
}
|
|
}
|
|
|
|
async function executeVersion2({
|
|
config,
|
|
pipeline,
|
|
eventName,
|
|
sha,
|
|
workspace,
|
|
actionMode = "all",
|
|
artifactIndex = 0,
|
|
}) {
|
|
const commitMessage = run("git", ["log", "-1", "--pretty=%B"], { cwd: workspace, capture: true });
|
|
const validationOnly = eventName === "pull_request";
|
|
const skipMarker = pipeline.strategy === "promote"
|
|
? String(pipeline.release?.skipMarker ?? "[skip prod-release]")
|
|
: "[skip quickstack-deploy]";
|
|
if (!validationOnly && eventName === "push" && commitMessage.includes(skipMarker)) {
|
|
console.log(`Deployment intentionally skipped by commit marker ${skipMarker}.`);
|
|
return;
|
|
}
|
|
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, DOCKER_BUILDKIT: "1" };
|
|
const execution = classifyVersion2Execution(pipeline, eventName);
|
|
try {
|
|
const needsRegistryLogin = actionMode === "all"
|
|
? execution !== "build-validation"
|
|
: actionMode === "build"
|
|
? execution === "build-deploy"
|
|
: execution !== "build-validation";
|
|
if (needsRegistryLogin) dockerLogin(registry, dockerEnv);
|
|
|
|
if (actionMode === "build") {
|
|
if (execution !== "build-validation" && execution !== "build-deploy") {
|
|
console.log(`Artifact build slot ${artifactIndex} is not needed for ${execution}.`);
|
|
return;
|
|
}
|
|
const artifact = pipeline.artifacts[artifactIndex];
|
|
if (!artifact) {
|
|
console.log(`Artifact build slot ${artifactIndex} is unused for ${pipeline.name ?? pipeline.branch}.`);
|
|
return;
|
|
}
|
|
const built = buildArtifact({
|
|
artifact,
|
|
registry,
|
|
sha,
|
|
workspace,
|
|
dockerEnv,
|
|
validationOnly: execution === "build-validation",
|
|
});
|
|
appendSummary(
|
|
execution === "build-validation"
|
|
? `Validated OCI artifact \`${artifact.name}\` for \`${pipeline.branch}\`.`
|
|
: `Built OCI artifact \`${artifact.name}\`: \`${built.exactImage}\`.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (actionMode === "coordinate" && execution === "build-validation") {
|
|
appendSummary(`Validated ${pipeline.artifacts.length} immutable OCI artifact build(s) for \`${pipeline.branch}\`.`);
|
|
return;
|
|
}
|
|
|
|
if (actionMode === "coordinate" && execution === "build-deploy") {
|
|
const artifacts = new Map();
|
|
for (const artifact of pipeline.artifacts) {
|
|
artifacts.set(artifact.name, pullCandidateArtifact({
|
|
artifact,
|
|
registry,
|
|
sourceSha: sha,
|
|
workspace,
|
|
dockerEnv,
|
|
}));
|
|
}
|
|
const client = createQuickStackClient({
|
|
baseUrl: process.env.QUICKSTACK_BASE_URL,
|
|
token: process.env.QUICKSTACK_API_TOKEN,
|
|
});
|
|
await deployApplications({ applications: pipeline.applications, artifacts, sha, client });
|
|
return;
|
|
}
|
|
|
|
if (execution === "build-validation" || execution === "build-deploy") {
|
|
const artifacts = new Map();
|
|
for (const artifact of pipeline.artifacts) {
|
|
artifacts.set(artifact.name, buildArtifact({
|
|
artifact,
|
|
registry,
|
|
sha,
|
|
workspace,
|
|
dockerEnv,
|
|
validationOnly: execution === "build-validation",
|
|
}));
|
|
}
|
|
if (execution === "build-validation") {
|
|
appendSummary(`Validated ${artifacts.size} immutable OCI artifact(s) for ${pipeline.branch}.`);
|
|
return;
|
|
}
|
|
const client = createQuickStackClient({
|
|
baseUrl: process.env.QUICKSTACK_BASE_URL,
|
|
token: process.env.QUICKSTACK_API_TOKEN,
|
|
});
|
|
await deployApplications({ applications: pipeline.applications, artifacts, sha, client });
|
|
return;
|
|
}
|
|
|
|
if (execution === "validate-candidate") {
|
|
const sourceBranch = validatePromotionPullRequestSource(pipeline, resolvePullRequestHeadBranch());
|
|
const release = loadRelease(workspace, pipeline.release);
|
|
const artifacts = new Map();
|
|
for (const artifact of pipeline.artifacts) {
|
|
artifacts.set(artifact.name, pullCandidateArtifact({
|
|
artifact,
|
|
registry,
|
|
sourceSha: sha,
|
|
workspace,
|
|
dockerEnv,
|
|
}));
|
|
}
|
|
console.log(`Validated ${release.tag} against ${artifacts.size} tested candidate artifact(s) from ${sourceBranch} at ${sha}; no rebuild or deployment performed.`);
|
|
appendSummary(`Validated release ${release.tag} against tested candidate \`${sha}\` from \`${sourceBranch}\` without rebuilding.`);
|
|
return;
|
|
}
|
|
|
|
const sourceSha = resolvePromotionSource(workspace, pipeline.source);
|
|
const release = loadRelease(workspace, pipeline.release);
|
|
const artifacts = new Map();
|
|
for (const artifact of pipeline.artifacts) {
|
|
artifacts.set(artifact.name, pullCandidateArtifact({
|
|
artifact,
|
|
registry,
|
|
sourceSha,
|
|
workspace,
|
|
dockerEnv,
|
|
}));
|
|
}
|
|
if (eventName !== "push") {
|
|
console.log(`Validated ${release.tag} against candidate ${sourceSha}; manual promotion does not mutate Production.`);
|
|
appendSummary(`Validated release ${release.tag} against candidate \`${sourceSha}\` without deployment.`);
|
|
return;
|
|
}
|
|
const gitea = {
|
|
apiUrl: resolveGiteaApiUrl(),
|
|
repository: requiredString(process.env.GITEA_REPOSITORY ?? process.env.GITHUB_REPOSITORY, "Gitea repository"),
|
|
token: requiredString(process.env.GITEA_TOKEN, "Gitea token"),
|
|
};
|
|
await ensureTag({ ...gitea, tag: release.tag, target: sha });
|
|
promoteArtifactAliases(artifacts, release.tag, { cwd: workspace, env: dockerEnv });
|
|
const client = createQuickStackClient({
|
|
baseUrl: process.env.QUICKSTACK_BASE_URL,
|
|
token: process.env.QUICKSTACK_API_TOKEN,
|
|
});
|
|
await deployApplications({ applications: pipeline.applications, artifacts, sha: sourceSha, client });
|
|
const published = await publishRelease({ ...gitea, release: release.release });
|
|
console.log(`Published Gitea release ${release.tag}: ${published?.html_url ?? "success"}`);
|
|
appendSummary(`- Release: ${release.tag}`);
|
|
} finally {
|
|
fs.rmSync(dockerConfig, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
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 actionMode = resolveActionMode(process.env.QUICKSTACK_ACTION_MODE);
|
|
const artifactIndex = resolveArtifactIndex(process.env.QUICKSTACK_ARTIFACT_INDEX);
|
|
if (config.version === 2) {
|
|
const pipeline = validateVersion2Pipeline(config, branch);
|
|
if (!pipeline) {
|
|
console.log(`No deployment is declared for branch ${branch}.`);
|
|
return;
|
|
}
|
|
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.");
|
|
await executeVersion2({
|
|
config,
|
|
pipeline,
|
|
eventName,
|
|
sha,
|
|
workspace,
|
|
actionMode,
|
|
artifactIndex,
|
|
});
|
|
return;
|
|
}
|
|
if (actionMode === "build") {
|
|
console.log("Manifest version 1 remains on the coordinator's serial compatibility path.");
|
|
return;
|
|
}
|
|
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;
|
|
});
|
|
}
|