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

This commit is contained in:
2026-08-25 18:51:17 +02:00
parent 9bc520ce64
commit b512035c07
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");
}
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}`);