diff --git a/.gitea/actions/quickstack-oci/deploy.mjs b/.gitea/actions/quickstack-oci/deploy.mjs index 0725130..2d9bea6 100644 --- a/.gitea/actions/quickstack-oci/deploy.mjs +++ b/.gitea/actions/quickstack-oci/deploy.mjs @@ -87,6 +87,33 @@ export function mergeEnvironment(source, overrides = {}) { 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; @@ -605,9 +632,12 @@ function promoteArtifactAliases(artifacts, releaseTag, options) { 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 })]), - ); + 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, diff --git a/.gitea/actions/quickstack-oci/deploy.test.mjs b/.gitea/actions/quickstack-oci/deploy.test.mjs index 77cacb4..27b4486 100644 --- a/.gitea/actions/quickstack-oci/deploy.test.mjs +++ b/.gitea/actions/quickstack-oci/deploy.test.mjs @@ -6,6 +6,7 @@ import { deployExactImage, expandTokens, mergeEnvironment, + resolveSecretEnvironment, orderApplications, resolveDeploymentBranch, resolvePullRequestHeadBranch, @@ -170,6 +171,24 @@ test("preserves response-only fields and existing environment secrets safely", ( assert.equal(mergeEnvironment(app().envVars, { ENVIRONMENT: "new" }), "SECRET=preserved\nENVIRONMENT=new"); }); +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( diff --git a/.gitea/scoped_workflows/quickstack-oci.yml b/.gitea/scoped_workflows/quickstack-oci.yml index 36902fc..48ebc20 100644 --- a/.gitea/scoped_workflows/quickstack-oci.yml +++ b/.gitea/scoped_workflows/quickstack-oci.yml @@ -34,3 +34,10 @@ jobs: QUICKSTACK_API_TOKEN: ${{ secrets.QUICKSTACK_API_TOKEN }} QUICKSTACK_BASE_URL: https://server.nuvisphere.de GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + MINIO_ENDPOINT: ${{ secrets.MINIO_ENDPOINT }} + NEXT_PUBLIC_MINIO_ENDPOINT: ${{ secrets.NEXT_PUBLIC_MINIO_ENDPOINT }} + MINIO_REGION: ${{ secrets.MINIO_REGION }} + MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }} + MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }} + MINIO_TLS_REJECT_UNAUTHORIZED: ${{ secrets.MINIO_TLS_REJECT_UNAUTHORIZED }} + MINIO_AVATAR_BUCKET: ${{ secrets.MINIO_AVATAR_BUCKET }}