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
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
This commit is contained in:
@@ -87,6 +87,54 @@ export function mergeEnvironment(source, overrides = {}) {
|
||||
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) {
|
||||
if (
|
||||
secretEnvironment === null ||
|
||||
@@ -237,6 +285,7 @@ export async function verifyEndpoint(postflight, { sha, fetchImpl = fetch, sleep
|
||||
export async function deployExactImage({
|
||||
client, appId, image, registryUsername, registryToken, environment = {},
|
||||
healthCheckTcpPort, postflight, sha, deploymentAttempts = 1, deploymentRetrySeconds = 15,
|
||||
volumeMountPaths = {},
|
||||
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
fetchImpl = fetch,
|
||||
}) {
|
||||
@@ -256,6 +305,7 @@ export async function deployExactImage({
|
||||
containerRegistryUsername: requiredString(registryUsername, "Registry username"),
|
||||
containerRegistryPassword: requiredString(registryToken, "Registry token"),
|
||||
envVars: mergeEnvironment(previous.envVars, environment),
|
||||
appVolumes: applyVolumeMountPaths(previous.appVolumes, volumeMountPaths),
|
||||
...(healthCheckTcpPort === undefined ? {} : { healthCheckTcpPort: Number(healthCheckTcpPort) }),
|
||||
};
|
||||
await client.saveApp(next);
|
||||
@@ -444,12 +494,21 @@ export function validateApplication(application, artifactNames) {
|
||||
if (deploymentRetrySeconds !== undefined && (!Number.isFinite(deploymentRetrySeconds) || deploymentRetrySeconds < 0)) {
|
||||
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 {
|
||||
...application,
|
||||
name,
|
||||
artifact,
|
||||
appId: requiredString(application.appId, `QuickStack app ID for ${name}`),
|
||||
appId,
|
||||
dependsOn,
|
||||
volumeMountPaths: normalizeVolumeMountPaths(application.volumeMountPaths),
|
||||
...(environmentFromAppId === undefined ? {} : { environmentFromAppId }),
|
||||
...(deploymentAttempts === undefined ? {} : { deploymentAttempts }),
|
||||
...(deploymentRetrySeconds === undefined ? {} : { deploymentRetrySeconds }),
|
||||
};
|
||||
@@ -753,7 +812,11 @@ async function deployApplications({ applications, artifacts, sha, client }) {
|
||||
await Promise.all(
|
||||
wave.map(async (application) => {
|
||||
const artifact = artifacts.get(application.artifact);
|
||||
const inheritedEnvironment = application.environmentFromAppId
|
||||
? parseEnvironment((await client.getApp(application.environmentFromAppId)).envVars)
|
||||
: {};
|
||||
const environment = {
|
||||
...inheritedEnvironment,
|
||||
...Object.fromEntries(
|
||||
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,
|
||||
deploymentAttempts: application.deploymentAttempts,
|
||||
deploymentRetrySeconds: application.deploymentRetrySeconds,
|
||||
volumeMountPaths: application.volumeMountPaths,
|
||||
sha,
|
||||
});
|
||||
console.log(`Deployed ${application.name}: ${result.image}`);
|
||||
|
||||
@@ -2,10 +2,13 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
applyVolumeMountPaths,
|
||||
classifyVersion2Execution,
|
||||
deployExactImage,
|
||||
expandTokens,
|
||||
mergeEnvironment,
|
||||
normalizeVolumeMountPaths,
|
||||
parseEnvironment,
|
||||
resolveActionMode,
|
||||
resolveArtifactIndex,
|
||||
resolveBuildSecretArguments,
|
||||
@@ -162,6 +165,15 @@ test("version 2 validates promotion contracts and token expansion", () => {
|
||||
() => 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", () => {
|
||||
@@ -210,6 +222,26 @@ 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", () => {
|
||||
|
||||
Reference in New Issue
Block a user