From 85d7695d1fa045dc3bcd687a68e8eed829a3bc59 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Fri, 5 Jun 2026 21:54:40 -0600 Subject: [PATCH] ci(preview): render Cloudflare config from trusted template (#2207) --- .github/workflows/preview-deploy.yml | 116 +++++++++--------- bin/preview_deploy_security_check.rb | 44 +++++-- .../render_preview_config_test.cjs | 100 +++++++++++++++ .../preview/deploy/render_preview_config.cjs | 114 +++++++++++++++++ 4 files changed, 307 insertions(+), 67 deletions(-) create mode 100644 test/javascript/preview_deploy/render_preview_config_test.cjs create mode 100644 workers/preview/deploy/render_preview_config.cjs diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index 62d2266af..d6e356456 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -206,12 +206,14 @@ jobs: cp -R trusted/workers/preview/src "$preview_dir/src" mkdir -p "$preview_dir/deploy" cp trusted/workers/preview/deploy/redact_preview_log.sh "$preview_dir/deploy/redact_preview_log.sh" + cp trusted/workers/preview/deploy/render_preview_config.cjs "$preview_dir/deploy/render_preview_config.cjs" chmod 0755 "$preview_dir/deploy/redact_preview_log.sh" diagnostics_nonce="$(openssl rand -hex 32)" sed -i "s/\${PR_NUMBER}/${PR_NUMBER}/g" "$preview_dir/wrangler.toml" sed -i "s/\${PR_NUMBER}/${PR_NUMBER}/g" "$preview_dir/src/index.ts" sed -i "s/\${PREVIEW_DIAGNOSTICS_NONCE}/${diagnostics_nonce}/g" "$preview_dir/src/index.ts" + cp "$preview_dir/wrangler.toml" "$preview_dir/wrangler.source.toml" if grep -F "\${PREVIEW_DIAGNOSTICS_NONCE}" "$preview_dir/src/index.ts" >/dev/null; then echo "Preview diagnostics nonce placeholder was not replaced" >&2 @@ -248,6 +250,7 @@ jobs: set -euo pipefail cd "$RUNNER_TEMP/sure-preview-worker" + source_config="$RUNNER_TEMP/sure-preview-worker/wrangler.source.toml" config_path="$RUNNER_TEMP/sure-preview-worker/wrangler.toml" image_tag="sure-preview-pr-${PR_NUMBER}:${HEAD_SHA}" temporary_image_ref="registry.cloudflare.com/${CLOUDFLARE_ACCOUNT_ID}/${image_tag}" @@ -257,23 +260,8 @@ jobs: # wrangler containers push validates wrangler.toml, so point the trusted # config at a registry-shaped ref while it pushes the verified local image. - TEMPORARY_IMAGE_REF="$temporary_image_ref" node - "$config_path" <<'NODE' - const fs = require('node:fs'); - - const configPath = process.argv[2]; - const imageRef = process.env.TEMPORARY_IMAGE_REF; - - if (!/^registry\.cloudflare\.com\/[A-Za-z0-9_-]+\/sure-preview-pr-[1-9][0-9]*:[a-f0-9]{40}$/.test(imageRef || '')) { - throw new Error('Expected registry-shaped preview image ref before wrangler containers push'); - } - - const original = fs.readFileSync(configPath, 'utf8'); - const updated = original.replace(/image = "[^"]+"/, `image = ${JSON.stringify(imageRef)}`); - if (updated === original) { - throw new Error('Expected wrangler.toml to contain an image entry to rewrite before push'); - } - fs.writeFileSync(configPath, updated); - NODE + PREVIEW_IMAGE_REF="$temporary_image_ref" node ./deploy/render_preview_config.cjs render "$source_config" "$config_path" + cp "$config_path" "$RUNNER_TEMP/wrangler-push.toml" set +e ./node_modules/.bin/wrangler containers push "$image_tag" 2>&1 | tee "$push_log" | ./deploy/redact_preview_log.sh @@ -285,19 +273,7 @@ jobs: exit "$push_status" fi - image_ref="$(node - "$clean_log" <<'NODE' - const fs = require('node:fs'); - - const logPath = process.argv[2]; - const log = fs.readFileSync(logPath, 'utf8'); - const expectedSuffix = `sure-preview-pr-${process.env.PR_NUMBER}:${process.env.HEAD_SHA}`; - const pattern = /registry\.cloudflare\.com\/[A-Za-z0-9_-]+\/sure-preview-pr-[1-9][0-9]*:[a-f0-9]{40}/g; - const matches = [...log.matchAll(pattern)].map((match) => match[0]); - const imageRef = matches.findLast((candidate) => candidate.endsWith(`/${expectedSuffix}`)); - - if (imageRef) process.stdout.write(imageRef); - NODE - )" + image_ref="$(node ./deploy/render_preview_config.cjs find "$clean_log")" if [ -z "$image_ref" ]; then echo "Could not find Cloudflare registry image reference in wrangler output" >&2 @@ -312,30 +288,12 @@ jobs: run: | set -euo pipefail + source_config="$RUNNER_TEMP/sure-preview-worker/wrangler.source.toml" config_path="$RUNNER_TEMP/sure-preview-worker/wrangler.toml" - # Use Node instead of sed so the replacement preserves TOML string syntax. - node - "$config_path" <<'NODE' - const fs = require('node:fs'); - - const configPath = process.argv[2]; - const imageRef = process.env.IMAGE_REF; - const expectedSuffix = `sure-preview-pr-${process.env.PR_NUMBER}:${process.env.HEAD_SHA}`; - - if (!/^registry\.cloudflare\.com\/[A-Za-z0-9_-]+\/sure-preview-pr-[1-9][0-9]*:[a-f0-9]{40}$/.test(imageRef || '')) { - throw new Error('Expected a Cloudflare registry image reference'); - } - - if (!imageRef.endsWith(`/${expectedSuffix}`)) { - throw new Error('Cloudflare registry image reference does not match this preview artifact'); - } - - const original = fs.readFileSync(configPath, 'utf8'); - const updated = original.replace(/image = "[^"]+"/, `image = ${JSON.stringify(imageRef)}`); - if (updated === original) { - throw new Error('Expected wrangler.toml to contain an image entry to rewrite'); - } - fs.writeFileSync(configPath, updated); - NODE + # Render from the preserved trusted source template so the push-time + # registry ref cannot make the final deploy rewrite stateful. + PREVIEW_IMAGE_REF="$IMAGE_REF" node "$RUNNER_TEMP/sure-preview-worker/deploy/render_preview_config.cjs" render "$source_config" "$config_path" + cp "$config_path" "$RUNNER_TEMP/wrangler-final.toml" # Print a redacted copy for logs without mutating the config used by deploy. redacted_config="$RUNNER_TEMP/wrangler-redacted.toml" @@ -395,27 +353,66 @@ jobs: run: | set -euo pipefail - diagnostics_file="$RUNNER_TEMP/preview-diagnostics.json" + diagnostics_dir="$RUNNER_TEMP/preview-diagnostics" + diagnostics_file="$diagnostics_dir/preview-diagnostics.json" + latest_metrics_file="$diagnostics_dir/latest-metrics.json" + polls_log="$diagnostics_dir/metrics-polls.log" + summary_file="$diagnostics_dir/summary.md" last_error="" + mkdir -p "$diagnostics_dir" for attempt in $(seq 1 40); do if curl -fsS --connect-timeout 5 --max-time 15 "$PREVIEW_URL/_container_status" -o "$diagnostics_file"; then - if jq -e '.previewReady == true or .previewFailed == true' "$diagnostics_file" >/dev/null; then - break + if jq -e . "$diagnostics_file" >/dev/null 2>&1; then + jq -c --argjson attempt "$attempt" --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{attempt: $attempt, at: $at, previewReady: (.previewReady // false), previewFailed: (.previewFailed // false), progress: (.progress // {}), timings: (.timings // {})}' \ + "$diagnostics_file" >> "$polls_log" + jq '{previewReady: (.previewReady // false), previewFailed: (.previewFailed // false), progress: (.progress // {}), timings: (.timings // {})}' "$diagnostics_file" > "$latest_metrics_file" + + if jq -e '.previewReady == true or .previewFailed == true' "$diagnostics_file" >/dev/null; then + break + fi + else + last_error="invalid diagnostics JSON on attempt ${attempt}" + raw_snippet="$(head -c 2048 "$diagnostics_file")" + latest_metrics_snapshot="none" + if [ -f "$latest_metrics_file" ]; then + latest_metrics_snapshot="$(head -c 2048 "$latest_metrics_file")" + fi + jq -nc --argjson attempt "$attempt" --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg error "$last_error" --arg latestMetrics "$latest_metrics_snapshot" --arg rawSnippet "$raw_snippet" \ + '{attempt: $attempt, at: $at, error: $error, latestMetrics: $latestMetrics, rawSnippet: $rawSnippet}' >> "$polls_log" fi else last_error="curl failed on attempt ${attempt}" + jq -nc --argjson attempt "$attempt" --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg error "$last_error" \ + '{attempt: $attempt, at: $at, error: $error}' >> "$polls_log" fi sleep 3 done - if [ ! -s "$diagnostics_file" ]; then + if [ ! -s "$diagnostics_file" ] || ! jq -e . "$diagnostics_file" >/dev/null 2>&1; then jq -n --arg error "${last_error:-preview diagnostics unavailable}" \ --arg url "$PREVIEW_URL" \ '{previewReady: false, previewFailed: false, error: $error, previewUrl: $url}' > "$diagnostics_file" fi + jq '{previewReady: (.previewReady // false), previewFailed: (.previewFailed // false), progress: (.progress // {}), timings: (.timings // {}), error: (.error // null)}' "$diagnostics_file" > "$latest_metrics_file" + { + echo "# Preview diagnostics" + echo + echo "- PR: ${PR_NUMBER}" + echo "- Commit: ${HEAD_SHA}" + echo "- Preview URL: ${PREVIEW_URL}" + echo "- Preview ready: $(jq -r '.previewReady // false' "$diagnostics_file")" + echo "- Preview failed: $(jq -r '.previewFailed // false' "$diagnostics_file")" + echo "- Phase: $(jq -r '.progress.phase // "unknown"' "$diagnostics_file")" + echo "- Stage: $(jq -r '.progress.stage // "unknown"' "$diagnostics_file")" + echo "- Seconds to Rails ready: $(jq -r '.timings.secondsToRailsReady // "unknown"' "$diagnostics_file")" + echo "- Seconds to demo data ready: $(jq -r '.timings.secondsToDemoDataReady // "unknown"' "$diagnostics_file")" + echo "- Seconds to preview ready: $(jq -r '.timings.secondsToPreviewReady // "unknown"' "$diagnostics_file")" + } > "$summary_file" + jq -c . "$diagnostics_file" if jq -e '.previewFailed == true' "$diagnostics_file" >/dev/null; then @@ -441,7 +438,7 @@ jobs: uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: preview-diagnostics-pr-${{ env.PR_NUMBER }}-${{ env.HEAD_SHA }} - path: ${{ runner.temp }}/preview-diagnostics.json + path: ${{ runner.temp }}/preview-diagnostics if-no-files-found: error retention-days: 3 @@ -493,6 +490,9 @@ jobs: }' "$manifest_file" > "$diagnostics_dir/preview-image-manifest.json" fi + sanitize_copy "$RUNNER_TEMP/sure-preview-worker/wrangler.source.toml" "$diagnostics_dir/wrangler-source.toml" + sanitize_copy "$RUNNER_TEMP/wrangler-push.toml" "$diagnostics_dir/wrangler-push.toml" + sanitize_copy "$RUNNER_TEMP/wrangler-final.toml" "$diagnostics_dir/wrangler-final.toml" sanitize_copy "$RUNNER_TEMP/sure-preview-worker/wrangler.toml" "$diagnostics_dir/wrangler.toml" sanitize_copy "$RUNNER_TEMP/wrangler-containers-push.clean.log" "$diagnostics_dir/wrangler-containers-push.log" if [ -f "$RUNNER_TEMP/wrangler-deploy.clean.log" ]; then diff --git a/bin/preview_deploy_security_check.rb b/bin/preview_deploy_security_check.rb index 72339cd6e..d5c2b6c1a 100644 --- a/bin/preview_deploy_security_check.rb +++ b/bin/preview_deploy_security_check.rb @@ -8,6 +8,7 @@ PREVIEW_WORKFLOW_PATH = File.join(ROOT, ".github/workflows/preview-deploy.yml") PR_WORKFLOW_PATH = File.join(ROOT, ".github/workflows/pr.yml") LOCKFILE_PATH = File.join(ROOT, "workers/preview/package-lock.json") RESOLVER_PATH = File.join(ROOT, "workers/preview/deploy/resolve_preview_request.cjs") +CONFIG_RENDERER_PATH = File.join(ROOT, "workers/preview/deploy/render_preview_config.cjs") REDACTION_HELPER_PATH = File.join(ROOT, "workers/preview/deploy/redact_preview_log.sh") PREVIEW_WORKER_PATH = File.join(ROOT, "workers/preview/src/index.ts") PREVIEW_DOCKERFILE_PATH = File.join(ROOT, "Dockerfile.preview") @@ -52,6 +53,7 @@ EXPECTED_COMMENT_PERMISSIONS = { }.freeze EXPECTED_DEPLOY_SECRET_ENV = %w[CLOUDFLARE_ACCOUNT_ID CLOUDFLARE_API_TOKEN CLOUDFLARE_WORKERS_SUBDOMAIN].freeze EXPECTED_PUSH_SECRET_ENV = %w[CLOUDFLARE_ACCOUNT_ID CLOUDFLARE_API_TOKEN].freeze +EXPECTED_DIAGNOSTICS_PATH = "${{ runner.temp }}/preview-diagnostics" EXPECTED_FAILURE_DIAGNOSTICS_PATH = "${{ runner.temp }}/preview-failure-diagnostics" EXPECTED_CLEANUP_METADATA_PATH = "${{ runner.temp }}/preview-cleanup-metadata/wrangler.toml" REQUIRED_PREPARE_LINES = [ @@ -62,9 +64,11 @@ REQUIRED_PREPARE_LINES = [ 'cp -R trusted/workers/preview/src "$preview_dir/src"', 'mkdir -p "$preview_dir/deploy"', 'cp trusted/workers/preview/deploy/redact_preview_log.sh "$preview_dir/deploy/redact_preview_log.sh"', + 'cp trusted/workers/preview/deploy/render_preview_config.cjs "$preview_dir/deploy/render_preview_config.cjs"', 'chmod 0755 "$preview_dir/deploy/redact_preview_log.sh"', 'diagnostics_nonce="$(openssl rand -hex 32)"', 'sed -i "s/\${PREVIEW_DIAGNOSTICS_NONCE}/${diagnostics_nonce}/g" "$preview_dir/src/index.ts"', + 'cp "$preview_dir/wrangler.toml" "$preview_dir/wrangler.source.toml"', "Preview diagnostics nonce placeholder was not replaced", "npm ci --ignore-scripts --no-audit --no-fund" ].freeze @@ -160,6 +164,7 @@ preview_workflow = YAML.safe_load_file(PREVIEW_WORKFLOW_PATH, aliases: true) pr_workflow = YAML.safe_load_file(PR_WORKFLOW_PATH, aliases: true) lockfile = JSON.parse(File.read(LOCKFILE_PATH)) resolver_script = File.read(RESOLVER_PATH) +config_renderer_script = File.read(CONFIG_RENDERER_PATH) redaction_helper_script = File.read(REDACTION_HELPER_PATH) preview_worker_script = File.read(PREVIEW_WORKER_PATH) preview_dockerfile = File.read(PREVIEW_DOCKERFILE_PATH) @@ -282,7 +287,7 @@ comment_on_pr = step!(preview_comment_steps, "Comment on PR") [ "fork deployment record guard", create_deployment.fetch("if"), "env.IS_FORK == 'false'" ], [ "diagnostics upload if", upload_diagnostics.fetch("if"), "always() && steps.deploy.outputs.preview_url != ''" ], [ "diagnostics upload name", upload_diagnostics.dig("with", "name"), "preview-diagnostics-pr-${{ env.PR_NUMBER }}-${{ env.HEAD_SHA }}" ], - [ "diagnostics upload path", upload_diagnostics.dig("with", "path"), "${{ runner.temp }}/preview-diagnostics.json" ], + [ "diagnostics upload path", upload_diagnostics.dig("with", "path"), EXPECTED_DIAGNOSTICS_PATH ], [ "diagnostics upload retention", upload_diagnostics.dig("with", "retention-days"), 3 ], [ "failure diagnostics collect if", collect_failure_diagnostics.fetch("if"), "failure()" ], [ "failure diagnostics upload if", upload_failure_diagnostics.fetch("if"), "failure()" ], @@ -423,6 +428,18 @@ assert(File.executable?(REDACTION_HELPER_PATH), "preview log redaction helper mu "" ].each { |needle| assert(redaction_helper_script.include?(needle), "preview log redaction helper must include #{needle.inspect}") } +[ + "REGISTRY_IMAGE_REF_PATTERN", + "REGISTRY_IMAGE_REF_SCAN_PATTERN", + "function validateRegistryImageRef", + "function renderPreviewConfig", + "function findRegistryImageRef", + "Expected wrangler.toml source to contain exactly one image entry", + "Cloudflare registry image reference does not match this preview artifact", + "Cloudflare registry image reference account does not match this workflow", + "module.exports" +].each { |needle| assert(config_renderer_script.include?(needle), "preview config renderer must include #{needle.inspect}") } + prepare_run = assert_run_includes(prepare, *REQUIRED_PREPARE_LINES) assert(!prepare_run.include?("npm install"), "prepare step must not use npm install") assert(!prepare_run.include?("CLOUDFLARE_API_TOKEN"), "prepare step must not receive Cloudflare secrets") @@ -440,29 +457,38 @@ push_image_run = assert_run_includes( push_image, "./node_modules/.bin/wrangler containers push", "registry.cloudflare.com/${CLOUDFLARE_ACCOUNT_ID}/${image_tag}", - "registry\\.cloudflare\\.com\\/", "image_ref=", + 'source_config="$RUNNER_TEMP/sure-preview-worker/wrangler.source.toml"', 'config_path="$RUNNER_TEMP/sure-preview-worker/wrangler.toml"', 'temporary_image_ref="registry.cloudflare.com/${CLOUDFLARE_ACCOUNT_ID}/${image_tag}"', - 'TEMPORARY_IMAGE_REF="$temporary_image_ref" node - "$config_path"', - "Expected registry-shaped preview image ref before wrangler containers push", - "Expected wrangler.toml to contain an image entry to rewrite before push" + 'PREVIEW_IMAGE_REF="$temporary_image_ref" node ./deploy/render_preview_config.cjs render "$source_config" "$config_path"', + 'cp "$config_path" "$RUNNER_TEMP/wrangler-push.toml"', + 'image_ref="$(node ./deploy/render_preview_config.cjs find "$clean_log")"' ) -push_rewrite_index = push_image_run.index('TEMPORARY_IMAGE_REF="$temporary_image_ref" node - "$config_path"') +push_rewrite_index = push_image_run.index('PREVIEW_IMAGE_REF="$temporary_image_ref" node ./deploy/render_preview_config.cjs render "$source_config" "$config_path"') push_command_index = push_image_run.index("./node_modules/.bin/wrangler containers push") assert( push_rewrite_index < push_command_index, "push step must rewrite wrangler.toml to a registry-shaped image ref before wrangler validates it" ) +assert(push_image_run.index('wrangler.source.toml') < push_rewrite_index, "push step must render from the preserved trusted source config") assert(!push_image_run.include?("LOCAL_IMAGE_TAG"), "push step must not write a local Docker tag into wrangler.toml") assert(!push_image_run.include?("Expected local preview image tag"), "push step must not accept local Docker tags as wrangler config image refs") assert_run_includes(push_image, 'tee "$push_log" | ./deploy/redact_preview_log.sh', "push_status=${PIPESTATUS[0]}") -assert_run_includes(configure_image, "registry\\.cloudflare\\.com", "expectedSuffix", "imageRef.endsWith", "Cloudflare registry image reference does not match this preview artifact", 'const original = fs.readFileSync', 'const updated = original.replace(/image = "[^"]+"/', "updated === original", "Expected wrangler.toml to contain an image entry to rewrite", "JSON.stringify(imageRef)", 'redact_preview_log.sh" < "$config_path"') +configure_image_run = assert_run_includes( + configure_image, + 'source_config="$RUNNER_TEMP/sure-preview-worker/wrangler.source.toml"', + 'PREVIEW_IMAGE_REF="$IMAGE_REF" node "$RUNNER_TEMP/sure-preview-worker/deploy/render_preview_config.cjs" render "$source_config" "$config_path"', + 'cp "$config_path" "$RUNNER_TEMP/wrangler-final.toml"', + "preserved trusted source template", + 'redact_preview_log.sh" < "$config_path"' +) +assert(!configure_image_run.include?('const updated = original.replace(/image = "[^"]+"/'), "final image configuration must use the tested renderer instead of inline regex replacement") assert_run_includes(create_deployment, "github.rest.repos.createDeployment", "ref: headSha", "preview-pr-${prNumber}") assert_run_includes(deploy, 'cd "$RUNNER_TEMP/sure-preview-worker"', "deploy_once()", "./node_modules/.bin/wrangler deploy --config wrangler.toml", '--var "PR_NUMBER:${PR_NUMBER}"', 'tee "$deploy_log" | ./deploy/redact_preview_log.sh', "deploy_status=${PIPESTATUS[0]}", "associated with a different durable object namespace", 'if ! ./node_modules/.bin/wrangler delete --name "sure-preview-${PR_NUMBER}" --force', "Preview Worker delete failed", "retrying once") assert_run_includes(warm_preview, "$PREVIEW_URL/_container_status", "--connect-timeout 5", "--max-time 15") -assert_run_includes(collect_diagnostics, "$PREVIEW_URL/_container_status", "--connect-timeout 5", "--max-time 15", "seq 1 40", "preview-diagnostics.json", "jq -e '.previewReady == true or .previewFailed == true'", "jq -e '.previewFailed == true'", "Preview diagnostics from _container_status reported previewFailed=true", "jq -e '.previewReady == true'", "Preview diagnostics from _container_status did not reach previewReady=true", ".timings.previewReadyAt != null and .timings.secondsToPreviewReady != null", "Preview diagnostics are missing readiness timing fields", "exit 1") -assert_run_includes(collect_failure_diagnostics, "preview-failure-diagnostics", "preview-request.json", "preview-image-manifest.json", "wrangler.toml", "wrangler-containers-push.log", "wrangler-deploy.log", "redaction_helper=", 'sanitize_copy "$RUNNER_TEMP/sure-preview-worker/wrangler.toml"', "wrangler-deploy.clean.log", "resolutionSource") +assert_run_includes(collect_diagnostics, "$PREVIEW_URL/_container_status", "--connect-timeout 5", "--max-time 15", "seq 1 40", "preview-diagnostics", "preview-diagnostics.json", "latest-metrics.json", "metrics-polls.log", "summary.md", '! jq -e . "$diagnostics_file"', 'raw_snippet="$(head -c 2048 "$diagnostics_file")"', 'latest_metrics_snapshot="$(head -c 2048 "$latest_metrics_file")"', "rawSnippet", "latestMetrics", "jq -e '.previewReady == true or .previewFailed == true'", "jq -e '.previewFailed == true'", "Preview diagnostics from _container_status reported previewFailed=true", "jq -e '.previewReady == true'", "Preview diagnostics from _container_status did not reach previewReady=true", ".timings.previewReadyAt != null and .timings.secondsToPreviewReady != null", "Preview diagnostics are missing readiness timing fields", "exit 1") +assert_run_includes(collect_failure_diagnostics, "preview-failure-diagnostics", "preview-request.json", "preview-image-manifest.json", "wrangler-source.toml", "wrangler-push.toml", "wrangler-final.toml", "wrangler.toml", "wrangler-containers-push.log", "wrangler-deploy.log", "redaction_helper=", 'sanitize_copy "$RUNNER_TEMP/sure-preview-worker/wrangler.source.toml"', 'sanitize_copy "$RUNNER_TEMP/wrangler-push.toml"', 'sanitize_copy "$RUNNER_TEMP/wrangler-final.toml"', "wrangler-deploy.clean.log", "resolutionSource") assert_run_includes(prepare_cleanup_metadata, "preview-cleanup-metadata", "redact_preview_log.sh", "$RUNNER_TEMP/sure-preview-worker/wrangler.toml", "$metadata_dir/wrangler.toml") assert_run_includes(update_deployment_status, "github.rest.repos.createDeploymentStatus", "process.env.DEPLOY_RESULT === 'success'", "deployment_id: Number(process.env.DEPLOYMENT_ID)") assert_run_includes(comment_on_pr, "github.rest.issues.listComments", "github.rest.issues.updateComment", "github.rest.issues.createComment", "Preview Deployment Ready") diff --git a/test/javascript/preview_deploy/render_preview_config_test.cjs b/test/javascript/preview_deploy/render_preview_config_test.cjs new file mode 100644 index 000000000..70deab73f --- /dev/null +++ b/test/javascript/preview_deploy/render_preview_config_test.cjs @@ -0,0 +1,100 @@ +const assert = require("node:assert/strict"); +const { describe, it } = require("node:test"); + +const { + findRegistryImageRef, + renderPreviewConfig, + validateRegistryImageRef, +} = require("../../../workers/preview/deploy/render_preview_config.cjs"); + +const options = { + accountId: "account_123", + prNumber: "2160", + headSha: "3f013c4d9193ff111295c89a6f833d59bd69d91e", +}; +const imageRef = + "registry.cloudflare.com/account_123/sure-preview-pr-2160:3f013c4d9193ff111295c89a6f833d59bd69d91e"; + +describe("renderPreviewConfig", () => { + it("renders exactly one trusted TOML image entry to a registry reference", () => { + const source = [ + 'name = "sure-preview-2160"', + "", + "[[containers]]", + 'image = "../../Dockerfile.preview"', + 'class_name = "RailsContainer"', + "", + ].join("\n"); + + const rendered = renderPreviewConfig(source, imageRef, options); + + assert.ok(rendered.includes(`image = "${imageRef}"`)); + assert.doesNotMatch(rendered, /Dockerfile\.preview/); + }); + + it("rejects missing image entries", () => { + assert.throws( + () => renderPreviewConfig('name = "sure-preview-2160"\n', imageRef, options), + /contain an image entry/ + ); + }); + + it("rejects duplicate image entries", () => { + const source = [ + "[[containers]]", + 'image = "../../Dockerfile.preview"', + "", + "[[containers]]", + 'image = "../../OtherDockerfile"', + "", + ].join("\n"); + + assert.throws(() => renderPreviewConfig(source, imageRef, options), /exactly one image entry/); + }); + + it("rejects local Docker tags as deploy image refs", () => { + assert.throws( + () => renderPreviewConfig('image = "../../Dockerfile.preview"\n', "my-local-image:latest", options), + /Cloudflare registry image reference/ + ); + }); +}); + +describe("validateRegistryImageRef", () => { + it("accepts the expected registry ref", () => { + assert.equal(validateRegistryImageRef(imageRef, options), imageRef); + }); + + it("rejects registry refs for another PR", () => { + const wrongPr = + "registry.cloudflare.com/account_123/sure-preview-pr-2161:3f013c4d9193ff111295c89a6f833d59bd69d91e"; + + assert.throws(() => validateRegistryImageRef(wrongPr, options), /does not match this preview artifact/); + }); + + it("rejects registry refs for another account", () => { + const wrongAccount = + "registry.cloudflare.com/account_456/sure-preview-pr-2160:3f013c4d9193ff111295c89a6f833d59bd69d91e"; + + assert.throws(() => validateRegistryImageRef(wrongAccount, options), /account does not match/); + }); +}); + +describe("findRegistryImageRef", () => { + it("extracts the expected registry image ref from wrangler output", () => { + const log = [ + "Pushing image layers", + "Published registry.cloudflare.com/account_123/sure-preview-pr-2160:3f013c4d9193ff111295c89a6f833d59bd69d91e", + "Done", + ].join("\n"); + + assert.equal(findRegistryImageRef(log, options), imageRef); + }); + + it("ignores registry refs that do not match this preview artifact", () => { + const log = + "Published registry.cloudflare.com/account_123/sure-preview-pr-2161:3f013c4d9193ff111295c89a6f833d59bd69d91e"; + + assert.equal(findRegistryImageRef(log, options), ""); + }); +}); diff --git a/workers/preview/deploy/render_preview_config.cjs b/workers/preview/deploy/render_preview_config.cjs new file mode 100644 index 000000000..4afeed72e --- /dev/null +++ b/workers/preview/deploy/render_preview_config.cjs @@ -0,0 +1,114 @@ +const fs = require("node:fs"); + +const IMAGE_FIELD_PATTERN = /^(\s*image\s*=\s*)"([^"]*)"(\s*(?:#.*)?)$/gm; +const REGISTRY_IMAGE_REF_PATTERN = + /^registry\.cloudflare\.com\/([A-Za-z0-9_-]+)\/(sure-preview-pr-([1-9][0-9]*):([a-f0-9]{40}))$/; +const REGISTRY_IMAGE_REF_SCAN_PATTERN = + /registry\.cloudflare\.com\/[A-Za-z0-9_-]+\/sure-preview-pr-[1-9][0-9]*:[a-f0-9]{40}/g; + +function expectedImageTag({ prNumber, headSha }) { + if (!/^[1-9][0-9]*$/.test(String(prNumber || ""))) { + throw new Error("Expected a numeric preview PR number"); + } + + if (!/^[a-f0-9]{40}$/.test(String(headSha || ""))) { + throw new Error("Expected a 40-character preview head SHA"); + } + + return `sure-preview-pr-${prNumber}:${headSha}`; +} + +function validateRegistryImageRef(imageRef, { accountId, prNumber, headSha }) { + const match = REGISTRY_IMAGE_REF_PATTERN.exec(imageRef || ""); + if (!match) { + throw new Error("Expected a Cloudflare registry image reference"); + } + + const expectedTag = expectedImageTag({ prNumber, headSha }); + if (match[2] !== expectedTag) { + throw new Error("Cloudflare registry image reference does not match this preview artifact"); + } + + if (accountId && match[1] !== accountId) { + throw new Error("Cloudflare registry image reference account does not match this workflow"); + } + + return imageRef; +} + +function renderPreviewConfig(source, imageRef, options) { + validateRegistryImageRef(imageRef, options); + + const matches = [...source.matchAll(IMAGE_FIELD_PATTERN)]; + if (matches.length === 0) { + throw new Error("Expected wrangler.toml source to contain an image entry"); + } + + if (matches.length > 1) { + throw new Error("Expected wrangler.toml source to contain exactly one image entry"); + } + + return source.replace(IMAGE_FIELD_PATTERN, `$1${JSON.stringify(imageRef)}$3`); +} + +function findRegistryImageRef(log, options) { + const matches = [...new Set(log.match(REGISTRY_IMAGE_REF_SCAN_PATTERN) || [])]; + const matchedRef = matches.find((candidate) => { + try { + validateRegistryImageRef(candidate, options); + return true; + } catch { + return false; + } + }); + + return matchedRef || ""; +} + +function envOptions() { + return { + accountId: process.env.CLOUDFLARE_ACCOUNT_ID, + prNumber: process.env.PR_NUMBER, + headSha: process.env.HEAD_SHA, + }; +} + +function runCli() { + const command = process.argv[2]; + + if (command === "render") { + const sourcePath = process.argv[3]; + const destinationPath = process.argv[4]; + const imageRef = process.env.PREVIEW_IMAGE_REF; + + if (!sourcePath || !destinationPath) { + throw new Error("Usage: render_preview_config.cjs render "); + } + + const rendered = renderPreviewConfig(fs.readFileSync(sourcePath, "utf8"), imageRef, envOptions()); + fs.writeFileSync(destinationPath, rendered); + return; + } + + if (command === "find") { + const logPath = process.argv[3]; + if (!logPath) { + throw new Error("Usage: render_preview_config.cjs find "); + } + + process.stdout.write(findRegistryImageRef(fs.readFileSync(logPath, "utf8"), envOptions())); + return; + } + + throw new Error(`Unknown command ${command || ""}`); +} + +if (require.main === module) { + runCli(); +} + +module.exports = { + findRegistryImageRef, + renderPreviewConfig, + validateRegistryImageRef, +};