diff --git a/.github/workflows/cloudflare-main.yml b/.github/workflows/cloudflare-main.yml index 86ee969..9e038a4 100644 --- a/.github/workflows/cloudflare-main.yml +++ b/.github/workflows/cloudflare-main.yml @@ -27,7 +27,7 @@ permissions: jobs: quality: - uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 with: quality-policy-path: ${{ inputs.quality-policy-path }} deployment-policy-path: ${{ inputs.deployment-policy-path }} @@ -46,7 +46,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: burnt-labs/github-workflows - ref: cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + ref: ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 path: .burnt-workflows - name: Read stable release tags env: @@ -76,7 +76,7 @@ jobs: permissions: contents: read deployments: write - uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 with: # Under single topology the candidate and the release are the same # Worker, so deploying here would serve the merge immediately and there @@ -98,7 +98,7 @@ jobs: permissions: contents: read deployments: write - uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 with: operation: preview target: release @@ -184,7 +184,7 @@ jobs: permissions: contents: read deployments: write - uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 with: operation: deploy target: release diff --git a/.github/workflows/cloudflare-pr.yml b/.github/workflows/cloudflare-pr.yml index f5ee200..0cf8a1c 100644 --- a/.github/workflows/cloudflare-pr.yml +++ b/.github/workflows/cloudflare-pr.yml @@ -31,7 +31,7 @@ jobs: github.event_name != 'pull_request' || (github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) - uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 with: quality-policy-path: ${{ inputs.quality-policy-path }} deployment-policy-path: ${{ inputs.deployment-policy-path }} @@ -62,7 +62,7 @@ jobs: permissions: contents: read deployments: write - uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 with: operation: preview target: candidate diff --git a/.github/workflows/cloudflare-release.yml b/.github/workflows/cloudflare-release.yml index 7611e36..2d8c332 100644 --- a/.github/workflows/cloudflare-release.yml +++ b/.github/workflows/cloudflare-release.yml @@ -56,7 +56,7 @@ jobs: quality: needs: validate - uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 with: quality-policy-path: ${{ inputs.quality-policy-path }} deployment-policy-path: ${{ inputs.deployment-policy-path }} @@ -89,7 +89,7 @@ jobs: permissions: contents: read deployments: write - uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 with: operation: preview target: release @@ -108,7 +108,7 @@ jobs: permissions: contents: read deployments: write - uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/cloudflare-version.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 with: operation: deploy target: release diff --git a/.github/workflows/npm-changesets.yml b/.github/workflows/npm-changesets.yml index cce9bf6..7e77217 100644 --- a/.github/workflows/npm-changesets.yml +++ b/.github/workflows/npm-changesets.yml @@ -96,7 +96,7 @@ permissions: jobs: quality: - uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 with: quality-policy-path: ${{ inputs.quality-policy-path }} diff --git a/.github/workflows/npm-main.yml b/.github/workflows/npm-main.yml index 8d4cfce..33ef01d 100644 --- a/.github/workflows/npm-main.yml +++ b/.github/workflows/npm-main.yml @@ -8,7 +8,7 @@ permissions: jobs: quality: - uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 metadata: needs: quality @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: burnt-labs/github-workflows - ref: cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + ref: ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 path: .burnt-workflows - name: Read stable release tags env: @@ -107,7 +107,7 @@ jobs: permissions: contents: read id-token: write - uses: burnt-labs/github-workflows/.github/workflows/npm-publish.yml@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/npm-publish.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 with: quality-policy: ${{ needs.quality.outputs.quality-policy }} npm-policy: ${{ needs.quality.outputs.npm-policy }} @@ -171,7 +171,7 @@ jobs: permissions: contents: read id-token: write - uses: burnt-labs/github-workflows/.github/workflows/npm-publish.yml@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/npm-publish.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 with: quality-policy: ${{ needs.quality.outputs.quality-policy }} npm-policy: ${{ needs.quality.outputs.npm-policy }} diff --git a/.github/workflows/npm-pr.yml b/.github/workflows/npm-pr.yml index fcb4da2..4456cf4 100644 --- a/.github/workflows/npm-pr.yml +++ b/.github/workflows/npm-pr.yml @@ -8,7 +8,7 @@ permissions: jobs: quality: - uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 pack: name: Package dry run diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 4c0fa26..d578359 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -15,7 +15,7 @@ permissions: jobs: quality: - uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 metadata: needs: quality @@ -52,7 +52,7 @@ jobs: permissions: contents: read id-token: write - uses: burnt-labs/github-workflows/.github/workflows/npm-publish.yml@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/npm-publish.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 with: quality-policy: ${{ needs.quality.outputs.quality-policy }} npm-policy: ${{ needs.quality.outputs.npm-policy }} diff --git a/.github/workflows/phala-deploy.yml b/.github/workflows/phala-deploy.yml index e643311..e32daa9 100644 --- a/.github/workflows/phala-deploy.yml +++ b/.github/workflows/phala-deploy.yml @@ -39,7 +39,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: burnt-labs/github-workflows - ref: 8373e9454803608c670431a67bd416178c633144 # v1.5.0 + ref: ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 path: .burnt-workflows - id: policy name: Require and validate Phala policy @@ -47,7 +47,7 @@ jobs: quality: needs: policy - uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@cf3eb33ac3deeb6aaff90165702ce5e4e73b6688 # v1.5.0 + uses: burnt-labs/github-workflows/.github/workflows/required-quality.yml@992944a4d890e517aa314204e5dceb49a42555c3 # v1.6.0 deploy: name: Phala ${{ inputs.target }} @@ -78,7 +78,10 @@ jobs: IMAGE_NAME: ${{ fromJSON(needs.policy.outputs.phala).image.name }} run: | repository="$(printf '%s' "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]')" - echo "image=ghcr.io/${repository}/${IMAGE_NAME}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + { + echo "repository=ghcr.io/${repository}/${IMAGE_NAME}" + echo "image=ghcr.io/${repository}/${IMAGE_NAME}:${GITHUB_SHA}" + } >> "$GITHUB_OUTPUT" - name: Collect deployment credentials and runtime configuration env: ALL_SECRETS: ${{ toJSON(secrets) }} @@ -87,8 +90,6 @@ jobs: REGISTRY_PASSWORD_NAME: ${{ fromJSON(needs.policy.outputs.phala).credentials.registryPasswordSecret }} SECRET_NAMES: ${{ toJSON(fromJSON(needs.policy.outputs.phala).runtimeSecrets) }} VARIABLE_NAMES: ${{ toJSON(fromJSON(needs.policy.outputs.phala).runtimeVariables) }} - IMAGE_VARIABLE: ${{ fromJSON(needs.policy.outputs.phala).image.composeVariable }} - IMAGE: ${{ steps.image.outputs.image }} REGISTRY_USERNAME: ${{ fromJSON(needs.policy.outputs.phala).image.registryUsername }} run: | node - <<'NODE' @@ -112,7 +113,6 @@ jobs: const environment = Object.fromEntries([ ...secretNames.map((name) => [name, secrets[name]]), ...variableNames.map((name) => [name, variables[name]]), - [process.env.IMAGE_VARIABLE, process.env.IMAGE], ["DSTACK_DOCKER_REGISTRY", "ghcr.io"], ["DSTACK_DOCKER_USERNAME", process.env.REGISTRY_USERNAME], ["DSTACK_DOCKER_PASSWORD", registryPassword], @@ -145,16 +145,66 @@ jobs: username: ${{ github.actor }} password: ${{ github.token }} - name: Build and push image + id: build uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: ${{ fromJSON(needs.policy.outputs.phala).image.context }} file: ${{ fromJSON(needs.policy.outputs.phala).image.dockerfile }} push: true tags: ${{ steps.image.outputs.image }} + # dstack hashes the compose text it is given, and that hash is what a + # relying party's allowlist checks. Substituting the image here rather + # than delivering it as a CVM environment variable is the whole point: + # expanded, the attested compose identity names the exact bytes running; + # left as a variable, it names the topology and nothing about the + # application. The reference is by digest, not by the commit tag, because + # a tag can be repointed after the deploy. + - name: Render the compose file + id: compose + env: + COMPOSE_FILE: ${{ fromJSON(needs.policy.outputs.phala).composeFile }} + WORKING_DIRECTORY: ${{ fromJSON(needs.policy.outputs.phala).workingDirectory }} + IMAGE_VARIABLE: ${{ fromJSON(needs.policy.outputs.phala).image.composeVariable }} + IMAGE_REPOSITORY: ${{ steps.image.outputs.repository }} + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + run: | + cd "$WORKING_DIRECTORY" + node - <<'NODE' + const fs = require("node:fs"); + const path = require("node:path"); + const variable = process.env.IMAGE_VARIABLE; + const digest = process.env.IMAGE_DIGEST; + if (!/^sha256:[0-9a-f]{64}$/.test(digest ?? "")) { + process.stderr.write(`::error::The image push reported no usable digest: ${digest}\n`); + process.exit(1); + } + const reference = `${process.env.IMAGE_REPOSITORY}@${digest}`; + const source = fs.readFileSync(process.env.COMPOSE_FILE, "utf8"); + // The two spellings a compose file may use: the bare ${VAR} and the + // required form ${VAR:?message}. The default form ${VAR:-fallback} + // is deliberately not matched — a default image reference is one a + // deploy could silently fall back to, which is the opposite of what + // an attested compose identity is for. It fails the check below + // instead. Anything else mentioning the variable is caught there too. + const placeholder = new RegExp(`\\$\\{${variable}(:\\?[^}]*)?\\}`, "g"); + const rendered = source.replace(placeholder, reference); + if (rendered === source) { + process.stderr.write(`::error::${process.env.COMPOSE_FILE} does not reference \${${variable}}\n`); + process.exit(1); + } + if (rendered.includes(variable)) { + process.stderr.write(`::error::${variable} still appears in the rendered compose file\n`); + process.exit(1); + } + const target = path.join(process.env.RUNNER_TEMP, "phala-compose.yaml"); + fs.writeFileSync(target, rendered); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `file=${target}\n`); + process.stdout.write(`Deploying ${reference}\n`); + NODE - name: Deploy CVM env: CVM_NAME: ${{ fromJSON(needs.policy.outputs.phala).targets[inputs.target].cvmName }} - COMPOSE_FILE: ${{ fromJSON(needs.policy.outputs.phala).composeFile }} + COMPOSE_FILE: ${{ steps.compose.outputs.file }} WORKING_DIRECTORY: ${{ fromJSON(needs.policy.outputs.phala).workingDirectory }} run: | cd "$WORKING_DIRECTORY" diff --git a/.github/workflows/required-quality.yml b/.github/workflows/required-quality.yml index 38e9fce..aa06a37 100644 --- a/.github/workflows/required-quality.yml +++ b/.github/workflows/required-quality.yml @@ -81,7 +81,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: burnt-labs/github-workflows - ref: 8373e9454803608c670431a67bd416178c633144 # v1.5.0 + ref: ed309eaa57dfea000dfe6f4fbcb3c5c16773cd6e # v1.6.0 path: .burnt-workflows - id: policy name: Validate policies diff --git a/AGENTS.md b/AGENTS.md index e219e36..5fb3c8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,7 +157,26 @@ orchestration. ``` The image is always tagged with the deploying commit SHA and pushed to GHCR. -The Actions job token pushes it. The secret named by +The Actions job token pushes it. + +`image.composeVariable` is expanded into the compose file before the deploy, +not delivered to the CVM as an environment variable. dstack hashes the compose +text it receives, and that hash is the only part of the attested identity that +says anything about the application — the platform measurement covers the base +dstack OS image. Left as a variable, the compose hash is stable across deploys +and binds the topology while the image arrives outside anything measured, so an +overwritten tag would run different bytes under an unchanged attested identity. +Expanded, and expanded _by digest_ rather than by the commit tag, the hash names +the exact bytes running. The consequence is intended: the compose hash changes +on every deploy, so a relying party's allowlist is re-pinned per deploy. That is +what pinning means. + +A compose file may write the placeholder as `${VAR}` or `${VAR:?message}`. +`${VAR:-fallback}` is rejected on purpose: a default image reference is one a +deploy could silently fall back to, which is the opposite of what an attested +compose identity is for. A compose file that does not reference the variable in +an accepted form fails the deploy, as does one where the name survives +substitution. The secret named by `credentials.registryPasswordSecret` is a separate durable read-package credential sealed into the CVM for future pulls. Never substitute the ephemeral job token for that credential. diff --git a/README.md b/README.md index ee4d997..2d4c10e 100644 --- a/README.md +++ b/README.md @@ -337,6 +337,12 @@ jobs: secrets: inherit ``` +`image.composeVariable` is substituted into the compose file before the deploy, +by digest, so the compose hash dstack measures names the exact image running. It +is not delivered to the CVM as an environment variable. The compose hash +therefore changes on every deploy and relying-party allowlists are re-pinned per +deploy. + The credential fields and `runtimeSecrets` select exact names from the target GitHub Environment's inherited secrets; `runtimeVariables` selects exact names from its variables. A missing declared value fails before build or deploy. The diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs index 2f26a65..a42f355 100644 --- a/tests/workflows.test.mjs +++ b/tests/workflows.test.mjs @@ -272,6 +272,60 @@ test("Phala seals only policy-allowlisted runtime configuration", () => { assert.doesNotMatch(source, /environment-json|CUE_|TEE_SERVICE_URL/); }); +test("Phala deploys a compose file that names the image by digest", () => { + // The compose text is what dstack hashes, and that hash is the only link + // between a relying party's allowlist and the application. Delivering the + // image as a CVM environment variable instead leaves the attested identity + // covering the topology and nothing about which build is running. + const workflow = parse( + fs.readFileSync(`${directory}/phala-deploy.yml`, "utf8"), + ); + const steps = workflow.jobs.deploy.steps; + const render = steps.find((step) => step.name === "Render the compose file"); + const deploy = steps.find((step) => step.name === "Deploy CVM"); + const collect = steps.find( + (step) => + step.name === "Collect deployment credentials and runtime configuration", + ); + + assert.ok(render, "the compose file must be rendered before the deploy"); + assert.match(render.env.IMAGE_DIGEST, /steps\.build\.outputs\.digest/); + assert.match(render.env.IMAGE_VARIABLE, /composeVariable/); + // By digest, never by the commit tag: a tag can be repointed after deploy, + // which is the whole failure this closes. + assert.match(render.run, /sha256:\[0-9a-f\]\{64\}/); + assert.match(render.run, /IMAGE_REPOSITORY\}@\$\{digest\}/); + + // Silence is the danger: a placeholder that does not match must fail rather + // than deploy a compose file with an unexpanded variable in it. + assert.match(render.run, /does not reference/); + assert.match(render.run, /still appears in the rendered compose file/); + // ${VAR:-fallback} must not be substituted. A default image reference is one + // a deploy could silently fall back to, which defeats the point of measuring + // the compose file at all; it has to fail the placeholder check instead. + assert.doesNotMatch(render.run, /\[-\?\]/); + + assert.match(deploy.env.COMPOSE_FILE, /steps\.compose\.outputs\.file/); + assert.doesNotMatch(deploy.env.COMPOSE_FILE, /policy\.outputs/); + + // The image must not also travel as an environment variable; two sources for + // one fact is how they drift apart. + assert.doesNotMatch(collect.run, /IMAGE_VARIABLE/); + assert.equal(collect.env.IMAGE, undefined); + + const build = steps.find((step) => step.name === "Build and push image"); + assert.ok(build, "the deploy must build and push the image it deploys"); + assert.equal( + build.id, + "build", + "the digest is only available from an id'd step", + ); + assert.ok( + steps.indexOf(build) < steps.indexOf(render), + "the image must be pushed before its digest is substituted", + ); +}); + test("Phala private-image credentials are durable and separate from the push token", () => { const workflow = parse( fs.readFileSync(`${directory}/phala-deploy.yml`, "utf8"),