2 Commits
Author SHA1 Message Date
vadimenovikau f39d803d7a Merge pull request #5 from codex/quickstack-environment-source
Platform CI tests / QuickStack deploy action tests (push) Successful in 25s
Nuvisphere/Platform-CI: Immutable QuickStack OCI deployment / Build once and deploy exact digest (push) Successful in 23s
QuickStack: Laufzeitumgebung und Volume-Mounts sicher vererben
2026-08-25 16:52:54 +00:00
vadimenovikau b512035c07 feat(quickstack): inherit app runtime configuration safely
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
2026-08-25 18:51:17 +02:00
3 changed files with 107 additions and 1 deletions
+65 -1
View File
@@ -87,6 +87,54 @@ export function mergeEnvironment(source, overrides = {}) {
return rows.map((row) => row.raw ?? `${row.key}=${row.value}`).join("\n"); return rows.map((row) => row.raw ?? `${row.key}=${row.value}`).join("\n");
} }
export function parseEnvironment(source, { exclude = ["APP_DEPLOYMENT_ID"] } = {}) {
const excluded = new Set(exclude);
const environment = {};
for (const line of String(source ?? "").split(/\r?\n/)) {
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (match && !excluded.has(match[1])) environment[match[1]] = match[2];
}
return environment;
}
export function normalizeVolumeMountPaths(volumeMountPaths = {}) {
if (
volumeMountPaths === null ||
typeof volumeMountPaths !== "object" ||
Array.isArray(volumeMountPaths)
) {
throw new Error("volumeMountPaths must be an object.");
}
return Object.fromEntries(
Object.entries(volumeMountPaths).map(([volumeId, rawPath]) => {
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(volumeId)) {
throw new Error(`Invalid QuickStack volume ID ${volumeId}.`);
}
const containerPath = String(rawPath).trim();
if (!/^\/[A-Za-z0-9._/-]+$/.test(containerPath) || containerPath.split("/").includes("..")) {
throw new Error(`Volume mount path for ${volumeId} must be a safe absolute path.`);
}
return [volumeId, containerPath];
}),
);
}
export function applyVolumeMountPaths(appVolumes = [], volumeMountPaths = {}) {
const normalized = normalizeVolumeMountPaths(volumeMountPaths);
const matched = new Set();
const volumes = appVolumes.map((volume) => {
const key = [volume.id, volume.sharedVolumeId].find((candidate) => candidate && normalized[candidate]);
if (!key) return volume;
matched.add(key);
return { ...volume, containerMountPath: normalized[key] };
});
const missing = Object.keys(normalized).filter((key) => !matched.has(key));
if (missing.length) {
throw new Error(`QuickStack volume not found: ${missing.join(", ")}.`);
}
return volumes;
}
export function resolveSecretEnvironment(secretEnvironment = {}, environment = process.env) { export function resolveSecretEnvironment(secretEnvironment = {}, environment = process.env) {
if ( if (
secretEnvironment === null || secretEnvironment === null ||
@@ -237,6 +285,7 @@ export async function verifyEndpoint(postflight, { sha, fetchImpl = fetch, sleep
export async function deployExactImage({ export async function deployExactImage({
client, appId, image, registryUsername, registryToken, environment = {}, client, appId, image, registryUsername, registryToken, environment = {},
healthCheckTcpPort, postflight, sha, deploymentAttempts = 1, deploymentRetrySeconds = 15, healthCheckTcpPort, postflight, sha, deploymentAttempts = 1, deploymentRetrySeconds = 15,
volumeMountPaths = {},
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
fetchImpl = fetch, fetchImpl = fetch,
}) { }) {
@@ -256,6 +305,7 @@ export async function deployExactImage({
containerRegistryUsername: requiredString(registryUsername, "Registry username"), containerRegistryUsername: requiredString(registryUsername, "Registry username"),
containerRegistryPassword: requiredString(registryToken, "Registry token"), containerRegistryPassword: requiredString(registryToken, "Registry token"),
envVars: mergeEnvironment(previous.envVars, environment), envVars: mergeEnvironment(previous.envVars, environment),
appVolumes: applyVolumeMountPaths(previous.appVolumes, volumeMountPaths),
...(healthCheckTcpPort === undefined ? {} : { healthCheckTcpPort: Number(healthCheckTcpPort) }), ...(healthCheckTcpPort === undefined ? {} : { healthCheckTcpPort: Number(healthCheckTcpPort) }),
}; };
await client.saveApp(next); await client.saveApp(next);
@@ -444,12 +494,21 @@ export function validateApplication(application, artifactNames) {
if (deploymentRetrySeconds !== undefined && (!Number.isFinite(deploymentRetrySeconds) || deploymentRetrySeconds < 0)) { if (deploymentRetrySeconds !== undefined && (!Number.isFinite(deploymentRetrySeconds) || deploymentRetrySeconds < 0)) {
throw new Error(`Deployment retry seconds for ${name} must be a non-negative number.`); throw new Error(`Deployment retry seconds for ${name} must be a non-negative number.`);
} }
const appId = requiredString(application.appId, `QuickStack app ID for ${name}`);
const environmentFromAppId = application.environmentFromAppId === undefined
? undefined
: requiredString(application.environmentFromAppId, `Environment source app ID for ${name}`);
if (environmentFromAppId === appId) {
throw new Error(`Application ${name} cannot inherit environment from itself.`);
}
return { return {
...application, ...application,
name, name,
artifact, artifact,
appId: requiredString(application.appId, `QuickStack app ID for ${name}`), appId,
dependsOn, dependsOn,
volumeMountPaths: normalizeVolumeMountPaths(application.volumeMountPaths),
...(environmentFromAppId === undefined ? {} : { environmentFromAppId }),
...(deploymentAttempts === undefined ? {} : { deploymentAttempts }), ...(deploymentAttempts === undefined ? {} : { deploymentAttempts }),
...(deploymentRetrySeconds === undefined ? {} : { deploymentRetrySeconds }), ...(deploymentRetrySeconds === undefined ? {} : { deploymentRetrySeconds }),
}; };
@@ -753,7 +812,11 @@ async function deployApplications({ applications, artifacts, sha, client }) {
await Promise.all( await Promise.all(
wave.map(async (application) => { wave.map(async (application) => {
const artifact = artifacts.get(application.artifact); const artifact = artifacts.get(application.artifact);
const inheritedEnvironment = application.environmentFromAppId
? parseEnvironment((await client.getApp(application.environmentFromAppId)).envVars)
: {};
const environment = { const environment = {
...inheritedEnvironment,
...Object.fromEntries( ...Object.fromEntries(
Object.entries(application.environment ?? {}).map(([key, value]) => [key, expandTokens(value, { sha })]), Object.entries(application.environment ?? {}).map(([key, value]) => [key, expandTokens(value, { sha })]),
), ),
@@ -770,6 +833,7 @@ async function deployApplications({ applications, artifacts, sha, client }) {
postflight: application.postflight, postflight: application.postflight,
deploymentAttempts: application.deploymentAttempts, deploymentAttempts: application.deploymentAttempts,
deploymentRetrySeconds: application.deploymentRetrySeconds, deploymentRetrySeconds: application.deploymentRetrySeconds,
volumeMountPaths: application.volumeMountPaths,
sha, sha,
}); });
console.log(`Deployed ${application.name}: ${result.image}`); console.log(`Deployed ${application.name}: ${result.image}`);
@@ -2,10 +2,13 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { import {
applyVolumeMountPaths,
classifyVersion2Execution, classifyVersion2Execution,
deployExactImage, deployExactImage,
expandTokens, expandTokens,
mergeEnvironment, mergeEnvironment,
normalizeVolumeMountPaths,
parseEnvironment,
resolveActionMode, resolveActionMode,
resolveArtifactIndex, resolveArtifactIndex,
resolveBuildSecretArguments, resolveBuildSecretArguments,
@@ -162,6 +165,15 @@ test("version 2 validates promotion contracts and token expansion", () => {
() => validateApplication({ name: "web", artifact: "missing", appId: "app" }, new Set(["known"])), () => validateApplication({ name: "web", artifact: "missing", appId: "app" }, new Set(["known"])),
/unknown artifact/, /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", () => { test("passes declared Actions secrets to Docker only through BuildKit secret mounts", () => {
@@ -210,6 +222,26 @@ test("preserves response-only fields and existing environment secrets safely", (
const payload = toSavePayload(app()); const payload = toSavePayload(app());
assert.equal(payload.createdAt, undefined); assert.equal(payload.createdAt, undefined);
assert.equal(mergeEnvironment(app().envVars, { ENVIRONMENT: "new" }), "SECRET=preserved\nENVIRONMENT=new"); 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", () => { test("maps only explicitly declared Actions secrets into runtime environment", () => {
+10
View File
@@ -68,6 +68,7 @@ Use manifest version 2 when one image serves multiple applications or Production
"artifact": "web", "artifact": "web",
"appId": "app-worker-staging", "appId": "app-worker-staging",
"dependsOn": ["web"], "dependsOn": ["web"],
"environmentFromAppId": "app-staging",
"environment": { "PROCESS": "worker" } "environment": { "PROCESS": "worker" }
} }
] ]
@@ -96,6 +97,15 @@ an Actions secret exposed to the workflow environment. Values are passed to
the command line as build arguments. The Dockerfile consumes them with the command line as build arguments. The Dockerfile consumes them with
`RUN --mount=type=secret,id=<id>,required=true ...`. `RUN --mount=type=secret,id=<id>,required=true ...`.
`environmentFromAppId` is optional and copies the existing QuickStack runtime
environment from another application before applying the target application's
declared `environment` and `secretEnvironment` overrides. This is intended for
workers that share backend credentials with an API without duplicating secret
values in Git or Actions. `volumeMountPaths` can map an existing QuickStack
volume ID or shared-volume ID to a safe absolute container path; it never
creates or replaces a volume. QuickStack's deployment identity variable is
never inherited from the source application.
Candidate pipelines build every artifact once, push `sha-<commit>`, resolve the registry digest and deploy applications in dependency order. A pull request into a promotion branch must originate from `source.branch`; it pulls and verifies the already tested `sha-<commit>` candidates without rebuilding or deploying them. Promotion pushes require a merge parent with an identical Git tree, pull the existing candidate, verify required container files, add the SemVer alias, deploy the exact digests, create the immutable tag and publish the canonical Gitea release. Tag creation does not trigger another scoped pipeline because the workflow listens only to branch pushes. Candidate pipelines build every artifact once, push `sha-<commit>`, resolve the registry digest and deploy applications in dependency order. A pull request into a promotion branch must originate from `source.branch`; it pulls and verifies the already tested `sha-<commit>` candidates without rebuilding or deploying them. Promotion pushes require a merge parent with an identical Git tree, pull the existing candidate, verify required container files, add the SemVer alias, deploy the exact digests, create the immutable tag and publish the canonical Gitea release. Tag creation does not trigger another scoped pipeline because the workflow listens only to branch pushes.
Scoped workflows can distribute version 2 artifact builds across independent Scoped workflows can distribute version 2 artifact builds across independent