Files
Platform-CI/.gitea/actions/quickstack-oci/deploy.test.mjs
vadimenovikau b512035c07
Nuvisphere/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (push) Successful in 31s
Platform CI tests / QuickStack deploy action tests (pull_request) Successful in 36s
Nuvisphere/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (pull_request) Successful in 23s
feat(quickstack): inherit app runtime configuration safely
2026-08-25 18:51:17 +02:00

373 lines
13 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import {
applyVolumeMountPaths,
classifyVersion2Execution,
deployExactImage,
expandTokens,
mergeEnvironment,
normalizeVolumeMountPaths,
parseEnvironment,
resolveActionMode,
resolveArtifactIndex,
resolveBuildSecretArguments,
resolveSecretEnvironment,
orderApplications,
resolveDeploymentBranch,
resolvePullRequestHeadBranch,
selectDeployment,
selectPipeline,
toSavePayload,
validateArtifact,
validateApplication,
validatePromotionPullRequestSource,
validateTarget,
validateVersion2Pipeline,
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("resolves push and pull request deployment branches without accepting empty context values", () => {
assert.equal(
resolveDeploymentBranch({
eventName: "push",
environment: { GITHUB_REF_NAME: "", GITEA_REF_NAME: "main" },
}),
"main",
);
assert.equal(
resolveDeploymentBranch({
eventName: "pull_request",
environment: { GITHUB_BASE_REF: "", GITEA_BASE_REF: "" },
eventPayload: { pull_request: { base: { ref: "main" } } },
}),
"main",
);
assert.throws(
() => resolveDeploymentBranch({ eventName: "pull_request", environment: {}, eventPayload: {} }),
/Pull request base branch/,
);
});
test("resolves the pull request head branch from direct and payload context", () => {
assert.equal(
resolvePullRequestHeadBranch({ environment: { GITHUB_HEAD_REF: "staging" } }),
"staging",
);
assert.equal(
resolvePullRequestHeadBranch({
environment: {},
eventPayload: { pull_request: { head: { ref: "release-candidate" } } },
}),
"release-candidate",
);
assert.throws(
() => resolvePullRequestHeadBranch({ environment: {}, eventPayload: {} }),
/Pull request head branch/,
);
});
test("production pull requests validate tested candidates without rebuilding", () => {
assert.equal(classifyVersion2Execution({ strategy: "candidate" }, "pull_request"), "build-validation");
assert.equal(classifyVersion2Execution({ strategy: "candidate" }, "push"), "build-deploy");
assert.equal(classifyVersion2Execution({ strategy: "promote" }, "pull_request"), "validate-candidate");
assert.equal(classifyVersion2Execution({ strategy: "promote" }, "push"), "promote");
assert.equal(
validatePromotionPullRequestSource({ source: { branch: "staging" } }, "staging"),
"staging",
);
assert.throws(
() => validatePromotionPullRequestSource({ source: { branch: "staging" } }, "feature"),
/must originate from staging, not feature/,
);
});
test("validates split action modes and artifact build slots", () => {
assert.equal(resolveActionMode(undefined), "all");
assert.equal(resolveActionMode("BUILD"), "build");
assert.equal(resolveActionMode("coordinate"), "coordinate");
assert.throws(() => resolveActionMode("parallel"), /Unsupported QuickStack action mode/);
assert.equal(resolveArtifactIndex("2"), 2);
assert.throws(() => resolveArtifactIndex("-1"), /non-negative integer/);
assert.throws(() => resolveArtifactIndex("1.5"), /non-negative integer/);
});
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/);
assert.throws(
() => validateTarget({ name: "Web", image: "owner/web", appId: "app-1", deploymentAttempts: 0 }),
/positive integer/,
);
});
test("version 2 reuses one artifact across ordered applications", () => {
const config = {
version: 2,
artifacts: [
{ name: "webapp", image: "InnoMieter/Webapp", requiredFiles: ["/app/server.js"] },
{ name: "thumbnail", image: "innomieter/thumbnail", dockerfile: "Dockerfile.thumbnail" },
],
pipelines: [
{
branch: "staging",
strategy: "candidate",
applications: [
{ name: "worker", artifact: "webapp", appId: "app-worker", dependsOn: ["web"] },
{ name: "web", artifact: "webapp", appId: "app-web" },
],
},
],
};
assert.equal(selectPipeline(config, "staging")?.strategy, "candidate");
const pipeline = validateVersion2Pipeline(config, "staging");
assert.deepEqual(pipeline.artifacts.map((artifact) => artifact.name), ["webapp", "thumbnail"]);
assert.deepEqual(pipeline.applications.map((application) => application.name), ["web", "worker"]);
assert.equal(pipeline.applications[1].artifact, "webapp");
});
test("version 2 validates promotion contracts and token expansion", () => {
const sha = "1234567890abcdef1234567890abcdef12345678";
assert.equal(expandTokens("build-$sha12-$version", { sha, version: "v1.2.3" }), "build-1234567890ab-v1.2.3");
assert.throws(
() => validateVersion2Pipeline({
version: 2,
artifacts: [{ name: "web", image: "owner/web" }],
pipelines: [{ branch: "prod", strategy: "promote", applications: [{ name: "web", artifact: "web", appId: "app" }] }],
}, "prod"),
/requires release configuration/,
);
assert.throws(() => validateArtifact({ name: "web", image: "owner/web", requiredFiles: ["../secret"] }), /safe absolute path/);
assert.throws(
() => validateApplication({ name: "web", artifact: "missing", appId: "app" }, new Set(["known"])),
/unknown artifact/,
);
assert.throws(
() => validateApplication({
name: "worker",
artifact: "known",
appId: "app-worker",
environmentFromAppId: "app-worker",
}, new Set(["known"])),
/cannot inherit environment from itself/,
);
});
test("passes declared Actions secrets to Docker only through BuildKit secret mounts", () => {
const secretValue = "must-not-appear-in-docker-arguments";
const artifact = validateArtifact({
name: "web",
image: "owner/web",
buildSecrets: {
"next-server-actions-encryption-key": "NEXT_SERVER_ACTIONS_ENCRYPTION_KEY",
},
});
const args = resolveBuildSecretArguments(artifact.buildSecrets, {
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: secretValue,
});
assert.deepEqual(args, [
"--secret",
"id=next-server-actions-encryption-key,env=NEXT_SERVER_ACTIONS_ENCRYPTION_KEY",
]);
assert.doesNotMatch(JSON.stringify(args), new RegExp(secretValue));
assert.throws(
() => resolveBuildSecretArguments(artifact.buildSecrets, {}),
/Missing Actions secret NEXT_SERVER_ACTIONS_ENCRYPTION_KEY/,
);
assert.throws(
() => validateArtifact({ name: "web", image: "owner/web", buildSecrets: { "../invalid": "SECRET" } }),
/Invalid BuildKit secret ID/,
);
});
test("application dependencies reject missing nodes and cycles", () => {
assert.throws(
() => orderApplications([{ name: "web", dependsOn: ["missing"] }]),
/unknown application/,
);
assert.throws(
() => orderApplications([
{ name: "web", dependsOn: ["worker"] },
{ name: "worker", dependsOn: ["web"] },
]),
/cycle/,
);
});
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");
assert.deepEqual(parseEnvironment("SECRET=preserved\nAPP_DEPLOYMENT_ID=source\nENVIRONMENT=new\ninvalid line"), {
SECRET: "preserved",
ENVIRONMENT: "new",
});
});
test("overrides only declared existing QuickStack volume mount paths", () => {
const volumes = [
{ id: "volume-local", sharedVolumeId: "shared-volume", containerMountPath: "/old" },
{ id: "volume-untouched", containerMountPath: "/data" },
];
assert.deepEqual(
applyVolumeMountPaths(volumes, { "shared-volume": "/mnt/as4" }),
[
{ id: "volume-local", sharedVolumeId: "shared-volume", containerMountPath: "/mnt/as4" },
{ id: "volume-untouched", containerMountPath: "/data" },
],
);
assert.throws(() => applyVolumeMountPaths(volumes, { missing: "/mnt/data" }), /not found/);
assert.throws(() => normalizeVolumeMountPaths({ volume: "../data" }), /safe absolute path/);
});
test("maps only explicitly declared Actions secrets into runtime environment", () => {
assert.deepEqual(
resolveSecretEnvironment(
{ MINIO_ENDPOINT: "MINIO_ENDPOINT", MINIO_REGION: "MINIO_REGION" },
{ MINIO_ENDPOINT: "https://minio.example.test", MINIO_REGION: "us-east-1" },
),
{ MINIO_ENDPOINT: "https://minio.example.test", MINIO_REGION: "us-east-1" },
);
assert.throws(
() => resolveSecretEnvironment({ MINIO_ENDPOINT: "MINIO_ENDPOINT" }, {}),
/Missing Actions secret MINIO_ENDPOINT/,
);
assert.throws(
() => resolveSecretEnvironment({ "INVALID-KEY": "MINIO_ENDPOINT" }, { MINIO_ENDPOINT: "value" }),
/Invalid secret environment key/,
);
});
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);
});
test("transient QuickStack deployment failures retry the desired immutable image", async () => {
let current = app();
let deployments = 0;
const image = `gitea.example/owner/app@sha256:${"b".repeat(64)}`;
const client = {
getApp: async () => current,
saveApp: async (payload) => { current = { ...current, ...payload }; },
deployApp: async () => ({ deploymentId: `deployment-${++deployments}` }),
getDeployment: async (_appId, deploymentId) => ({
deploymentId,
status: deploymentId === "deployment-1" ? "ERROR" : "DEPLOYED",
}),
};
const result = await deployExactImage({
client,
appId: "app-one",
image,
registryUsername: "deploy",
registryToken: "token",
sha: "2".repeat(40),
deploymentAttempts: 2,
deploymentRetrySeconds: 0,
sleep: async () => {},
});
assert.equal(deployments, 2);
assert.equal(result.image, image);
assert.equal(current.sourceType, "CONTAINER");
assert.equal(current.containerImageSource, image);
});
test("a newly created deployment may be briefly unavailable without triggering rollback", async () => {
let current = app();
let deployments = 0;
let lookups = 0;
const image = `gitea.example/owner/app@sha256:${"c".repeat(64)}`;
const client = {
getApp: async () => current,
saveApp: async (payload) => { current = { ...current, ...payload }; },
deployApp: async () => ({ deploymentId: `deployment-${++deployments}` }),
getDeployment: async (_appId, deploymentId) => {
lookups += 1;
if (lookups === 1) throw Object.assign(new Error("not visible"), { statusCode: 404 });
return { deploymentId, status: "DEPLOYED" };
},
};
const result = await deployExactImage({
client,
appId: "app-one",
image,
registryUsername: "deploy",
registryToken: "token",
sha: "3".repeat(40),
sleep: async () => {},
});
assert.equal(deployments, 1);
assert.equal(lookups, 2);
assert.equal(result.image, image);
assert.equal(current.sourceType, "CONTAINER");
});