diff --git a/.github/workflows/console-qrs-backend-candidate-release.yml b/.github/workflows/console-qrs-backend-candidate-release.yml new file mode 100644 index 00000000..52e7c367 --- /dev/null +++ b/.github/workflows/console-qrs-backend-candidate-release.yml @@ -0,0 +1,103 @@ +name: Console fixed backend candidate release +on: + workflow_dispatch: + inputs: + action: + description: Read-only preflight or fixed backend release + type: choice + options: [preflight, release] + default: preflight + required: true + control_sha: + description: Reviewed release workflow commit on main + type: string + required: true +permissions: + contents: read +concurrency: + group: strategy-switch-console-deploy + cancel-in-progress: false +jobs: + prepare: + if: github.repository == 'QuantStrategyLab/QuantRuntimeSettings' && github.ref == 'refs/heads/main' && github.run_attempt == 1 && inputs.action == 'release' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-node@v6 + with: + node-version: '22' + - name: Check reviewed control commit and prepare fixed module + env: + CONTROL_SHA: ${{ inputs.control_sha }} + WRANGLER_SEND_METRICS: 'false' + CLOUDFLARE_CF_FETCH_ENABLED: 'false' + run: | + set -euo pipefail + [[ "$CONTROL_SHA" == "$GITHUB_SHA" ]] + [[ "$(git rev-parse HEAD)" == "$GITHUB_SHA" ]] + [[ "$(git ls-remote origin refs/heads/main | cut -f1)" == "$GITHUB_SHA" ]] + source="$(node scripts/console_m09_release.mjs build-source)" + npx --yes wrangler@4.106.0 --version + bash scripts/console_m09_offline_bundle.sh "$RUNNER_TEMP/qrs-backend-candidate" "$source" + node scripts/console_m09_release.mjs verify-inputs "$RUNNER_TEMP/qrs-backend-candidate/worker.js" + - uses: actions/upload-artifact@v4 + with: + name: console-fixed-backend-module + path: ${{ runner.temp }}/qrs-backend-candidate/worker.js + if-no-files-found: error + retention-days: 1 + preflight: + if: github.repository == 'QuantStrategyLab/QuantRuntimeSettings' && github.ref == 'refs/heads/main' && github.run_attempt == 1 && inputs.action == 'preflight' + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: runtime-strategy-switch + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-node@v6 + with: + node-version: '22' + - name: Read fixed Worker state + env: + CONTROL_SHA: ${{ inputs.control_sha }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID || vars.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -euo pipefail + [[ "$CONTROL_SHA" == "$GITHUB_SHA" ]] + [[ "$(git rev-parse HEAD)" == "$GITHUB_SHA" ]] + [[ "$(git ls-remote origin refs/heads/main | cut -f1)" == "$GITHUB_SHA" ]] + node scripts/console_m09_release.mjs preflight + provider: + if: inputs.action == 'release' + needs: prepare + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: runtime-strategy-switch + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-node@v6 + with: + node-version: '22' + - uses: actions/download-artifact@v4 + with: + name: console-fixed-backend-module + path: ${{ runner.temp }}/qrs-backend-candidate + - name: Verify main and release fixed module + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID || vars.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -euo pipefail + [[ "$(git rev-parse HEAD)" == "$GITHUB_SHA" ]] + [[ "$(git ls-remote origin refs/heads/main | cut -f1)" == "$GITHUB_SHA" ]] + node scripts/console_m09_release.mjs release "$RUNNER_TEMP/qrs-backend-candidate/worker.js" diff --git a/scripts/console_m09_offline_bundle.sh b/scripts/console_m09_offline_bundle.sh new file mode 100755 index 00000000..45829e01 --- /dev/null +++ b/scripts/console_m09_offline_bundle.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Build the fixed backend tree with the unchanged, previously verified recovery assets. +set -euo pipefail +umask 077 +readonly tree=42b404d5755b658deff73100b495ecf26fa3a6b1 +readonly baseline=e6bd32af8471eaa8e5968134b6eb60a2c6da66db +readonly recovery=85504dd6ee201fd358f6ea3489742ea5c47afcf8 +readonly recovery_asset=acb728faf6247535bdc173b0f18c82adeea3485e +readonly asset=web/strategy-switch-console/v2_asset_map.js +output="${1:?output directory required}" +source="${2:-$tree}" +[[ "$(git rev-parse "${source}^{tree}")" == "$tree" ]] +[[ ! -e "$output" ]] +[[ "$(git rev-parse "$tree:$asset")" == "$(git rev-parse "$baseline:$asset")" ]] +[[ "$(git rev-parse "$recovery:$asset")" == "$recovery_asset" ]] +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$output" +output="$(cd "$output" && pwd)" +git archive "$source" | tar -x -C "$tmp" +git cat-file blob "$recovery_asset" > "$tmp/$asset" +cp "$tmp/web/strategy-switch-console/wrangler.toml.example" "$tmp/web/strategy-switch-console/wrangler.toml" +( + cd "$tmp/web/strategy-switch-console" + unset CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID CLOUDFLARE_API_KEY CLOUDFLARE_EMAIL + export WRANGLER_SEND_METRICS=false CLOUDFLARE_CF_FETCH_ENABLED=false npm_config_update_notifier=false + npx --offline wrangler@4.106.0 deploy --dry-run --config wrangler.toml --outdir "$output" +) +[[ -s "$output/worker.js" && -s "$output/worker.js.map" ]] +shasum -a 256 "$output/worker.js" diff --git a/scripts/console_m09_release.mjs b/scripts/console_m09_release.mjs new file mode 100644 index 00000000..e72e8110 --- /dev/null +++ b/scripts/console_m09_release.mjs @@ -0,0 +1,173 @@ +// M09/P02 release remains closed until the reviewed source, bundle, active +// deployment and verified rollback version have all been pinned in this file. +import {execFileSync} from 'node:child_process'; +import {createHash} from 'node:crypto'; +import {readFile} from 'node:fs/promises'; +import {pathToFileURL} from 'node:url'; +import {createFixedApi} from './console_946_backend_release.mjs'; +import {execute} from './console_full_bundle_release.mjs'; +import {RECOVERY_SNAPSHOT} from './console_frontend_candidate_preflight.mjs'; + +export const POLICY = Object.freeze({source:'cabe5a1534e0c992713b084030af313eada758ca', + tree:'42b404d5755b658deff73100b495ecf26fa3a6b1', + frontendBlob:RECOVERY_SNAPSHOT.assetBlob, + before:RECOVERY_SNAPSHOT.bundleSha256, + candidate:'ea76cfb5648486a8358f7bd339807f1aa8c0dc1a7eb70df1d31518bad0d957d3', + version:null, deployment:null, rollback:null}); +const hex=(x,n)=>typeof x==='string'&&new RegExp(`^[a-f0-9]{${n}}$`).test(x); +const uuid=x=>typeof x==='string'&&/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(x); +const hash=x=>createHash('sha256').update(x).digest('hex'); +function sourcePinned(p) { + return !!p && hex(p.source,40) && hex(p.tree,40) && hex(p.frontendBlob,40) && + hex(p.before,64) && hex(p.candidate,64); +} +export function pinned(p) { + return sourcePinned(p) && uuid(p.version) && uuid(p.deployment) && + uuid(p.rollback) && p.rollback===p.version; +} +// No provider method is touched until all provenance and rollback pins match. +export async function release({api, policy, evidence, candidate, release=false}) { + if (!pinned(policy) || !evidence || evidence.source!==policy.source || evidence.tree!==policy.tree || + evidence.frontendBlob!==policy.frontendBlob || !Buffer.isBuffer(candidate) || hash(candidate)!==policy.candidate) + return {ok:false,reason:'hold_or_provenance_mismatch',upload_attempted:false,deploy_attempted:false}; + return execute({api,policy:{before:policy.before,candidate:policy.candidate}, + expected:{version:policy.version,deployment:policy.deployment},candidate,release}); +} +export function createPreflightApi(fixedApi=createFixedApi()) { + const {deployments,versions,scriptVersion,betaVersion}=fixedApi; + return Object.freeze({deployments,versions,scriptVersion,betaVersion}); +} +function activeIdentity(result) { + const list=result?.deployments; + const top=list?.[0]; + const [slot]=Array.isArray(top?.versions)?top.versions:[]; + if(!Array.isArray(list)||list.length>10||!uuid(top?.id)||top.strategy!=='percentage'|| + top.versions?.length!==1||!uuid(slot?.version_id)||slot.percentage!==100) throw Error('invalid_active'); + return {deployment:top.id,version:slot.version_id}; +} +function versions(result) { + const items=result?.items; + if(!Array.isArray(items)||!items.length||items.length>10||items.some(x=>!uuid(x?.id))|| + new Set(items.map(x=>x.id)).size!==items.length) throw Error('invalid_versions'); + return items.map(x=>x.id); +} +function readModule(result,id) { + const module=result?.modules?.[0]; + if(result?.id!==id||!Array.isArray(result.modules)||result.modules.length!==1|| + module?.name!=='worker.js'||module.content_type!=='application/javascript+module'|| + typeof module.content_base64!=='string'||!module.content_base64.length|| + module.content_base64.length>Math.ceil(10*1024*1024/3)*4) throw Error('invalid_module'); + const bytes=Buffer.from(module.content_base64,'base64'); + if(!bytes.length||bytes.length>10*1024*1024||bytes.toString('base64')!==module.content_base64) + throw Error('invalid_module'); + return bytes; +} +function readConfiguration(result,id) { + const resources=result?.resources; + const runtime=resources?.script_runtime,script=resources?.script; + if(result?.id!==id||!runtime||!script||!Array.isArray(resources.bindings)||resources.bindings.length>128|| + resources.bindings.some(b=>typeof b?.type!=='string')||!/^\d{4}-\d{2}-\d{2}$/.test(runtime.compatibility_date??'')|| + !['standard','bundled','unbound'].includes(runtime.usage_model)|| + (script.placement_mode!=null&&!['smart','off'].includes(script.placement_mode))|| + (runtime.compatibility_flags!==undefined&&!Array.isArray(runtime.compatibility_flags))|| + (script.named_handlers!==undefined&&!Array.isArray(script.named_handlers))) throw Error('invalid_configuration'); + return resources; +} +// Inspect the same inputs the write path would inherit. Return only redacted evidence. +async function inspectBackendRelease(api, referenceHash) { + let stage='traffic'; + const result={read_only:true,upload_attempted:false,deploy_attempted:false}; + try { + const initial=await api.deployments(); + const traffic=initial?.deployments?.[0]?.versions; + if (Array.isArray(traffic) && traffic.length<=10 && traffic.every(v=>uuid(v?.version_id)&&Number.isFinite(v?.percentage))) + result.traffic=traffic.map((v,index)=>({slot:index+1,percentage:v.percentage})); + const prior=activeIdentity(initial); + result.single_active_version=true; + stage='version_list'; + const listed=versions(await api.versions()); + result.active_listed=listed.includes(prior.version); + result.active_is_latest=listed[0]===prior.version; + stage='module'; + const bytes=readModule(await api.betaVersion(prior.version),prior.version); + result.module_matches_reference=hash(bytes)===referenceHash; + stage='configuration'; + const live=readConfiguration(await api.scriptVersion(prior.version),prior.version); + const runtime=live.script_runtime; + const script=live.script; + result.configuration={ + bindings:Object.fromEntries(['plain_text','json','secret_text','kv_namespace','durable_object_namespace'] + .map(type=>[type,live.bindings.filter(b=>b.type===type).length])), + binding_count:live.bindings.length, + compatibility_date:runtime.compatibility_date, compatibility_flag_count:runtime.compatibility_flags?.length??0, + usage_model:runtime.usage_model, limits_present:runtime.limits!=null, migration_tag_present:typeof runtime.migration_tag==='string'&&!!runtime.migration_tag, + named_handler_count:script.named_handlers?.length??0, placement_mode:script.placement_mode??null, + candidate_configuration_source:'active_version_inheritance', + candidate_provider_configuration_observed:false, + }; + stage='recheck'; + if (JSON.stringify(activeIdentity(await api.deployments()))!==JSON.stringify(prior) || + JSON.stringify(versions(await api.versions()))!==JSON.stringify(listed)) throw Error('active_drift'); + return {result:{...result,ok:true,active_identity_unchanged:true},version:prior.version}; + } catch { + return {result:{...result,ok:false,stage}}; + } +} + +export function readRollbackTargets(git) { + const workflow=git('show','HEAD:.github/workflows/deploy-strategy-switch-console.yml'); + const fixed=workflow.split(' - name: Restore fixed pre-release Worker version'); + if(fixed.length!==2)throw Error('rollback_policy'); + const target=pattern=>{ + const matches=[...fixed[1].matchAll(pattern)]; + if(matches.length!==1||!uuid(matches[0][1]))throw Error('rollback_policy'); + return matches[0][1]; + }; + return { + fixed:[target(/const target = process\.env\.ROLLBACK_VERSION \|\| '([^']+)';/g), + target(/const fe11 = target === '([^']+)';/g)], + fixedSuccessor:target(/const dailyRestore = target === '([^']+)';/g), + }; +} +export async function preflight(api,rollbackTargets={fixed:[],fixedSuccessor:null}) { + const {result,version}=await inspectBackendRelease(api,POLICY.before); + if(!result.ok)return result; + // The daily restore target requires a specific, unrelated successor deployment. + const route=rollbackTargets.fixed.includes(version) ? 'fixed_target' + : version===rollbackTargets.fixedSuccessor ? 'fixed_successor_only' : 'no_matching_target'; + return {...result,source:POLICY.source,candidate_sha256:POLICY.candidate,reference_sha256:POLICY.before, + rollback:{route,prior_version_readable:true,write_rehearsed:false, + eligible_after_release:route==='fixed_target'&&result.active_listed&&result.module_matches_reference&&result.configuration.migration_tag_present}, + hold:true}; +} +async function main() { + const [action,path,...extra]=process.argv.slice(2); + if (extra.length || !['build-source','verify-inputs','preflight','release'].includes(action) || + (['preflight','build-source'].includes(action) ? path!==undefined : !path)) throw Error('arguments'); + if (action==='preflight' ? !sourcePinned(POLICY) : !pinned(POLICY)) throw Error('hold'); + const git=(...args)=>execFileSync('git',args,{encoding:'utf8'}).trim(); + git('merge-base','--is-ancestor',POLICY.source,'HEAD'); + const evidence={source:git('rev-parse',`${POLICY.source}^{commit}`),tree:git('rev-parse',`${POLICY.source}^{tree}`), + frontendBlob:git('rev-parse',`${RECOVERY_SNAPSHOT.commit}:web/strategy-switch-console/v2_asset_map.js`)}; + if (POLICY.before!==RECOVERY_SNAPSHOT.bundleSha256 || POLICY.frontendBlob!==RECOVERY_SNAPSHOT.assetBlob || + evidence.source!==POLICY.source || evidence.tree!==POLICY.tree || evidence.frontendBlob!==POLICY.frontendBlob) + throw Error('provenance'); + if (action==='preflight') { + const result=await preflight(createPreflightApi(),readRollbackTargets(git)); + console.log(JSON.stringify(result)); + if(!result.ok)process.exitCode=1; + return; + } + if (action==='build-source') { + console.log(POLICY.source); + return; + } + const candidate=await readFile(path); + if (hash(candidate)!==POLICY.candidate) throw Error('provenance'); + if (action==='verify-inputs') return; + const result=await release({api:createFixedApi(),policy:POLICY,evidence,candidate,release:true}); + console.log(JSON.stringify(result)); + if(!result.ok)process.exitCode=1; +} +if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href) + try{await main()}catch{console.error(JSON.stringify({ok:false,reason:'hold_or_input_invalid',upload_attempted:false,deploy_attempted:false}));process.exitCode=1;} diff --git a/tests/console_m09_release.test.mjs b/tests/console_m09_release.test.mjs new file mode 100644 index 00000000..035182d6 --- /dev/null +++ b/tests/console_m09_release.test.mjs @@ -0,0 +1,97 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {execFileSync,spawnSync} from 'node:child_process'; +import {POLICY,createPreflightApi,pinned,preflight,readRollbackTargets,release} from '../scripts/console_m09_release.mjs'; +const hash=x=>createHash('sha256').update(x).digest('hex'); +const old=Buffer.from('old'),candidate=Buffer.from('candidate'); +const version='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',deployment='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const policy={source:'a'.repeat(40),tree:'b'.repeat(40),frontendBlob:'c'.repeat(40),before:hash(old),candidate:hash(candidate),version,deployment,rollback:version}; +const evidence={source:policy.source,tree:policy.tree,frontendBlob:policy.frontendBlob}; +function fixture(){ + const calls=[];let uploaded=false,activated=false,body; + const next='cccccccc-cccc-4ccc-8ccc-cccccccccccc',dep='dddddddd-dddd-4ddd-8ddd-dddddddddddd',latest='eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; + const bindings=[{name:'STRATEGY_SWITCH_CONFIG',type:'kv_namespace',namespace_id:'1'.repeat(32)}, + {name:'STRATEGY_SWITCH_RUNTIME_INSTANCES',type:'durable_object_namespace',namespace_id:'2'.repeat(32),class_name:'RuntimeInstances'}, {name:'SECRET',type:'secret_text'}]; + const api={ + async deployments(){calls.push('deployments');return {deployments:[{id:activated?dep:deployment,strategy:'percentage',versions:[{version_id:activated?next:version,percentage:100}]}]};}, + async versions(){calls.push('versions');return {items:(uploaded?[next,latest]:[latest,version]).map(id=>({id}))};}, + async betaVersion(id){calls.push('module');return {id,modules:[{name:'worker.js',content_type:'application/javascript+module',content_base64:(id===next?candidate:old).toString('base64')}]};}, + async scriptVersion(id){calls.push('config');return {id,resources:{bindings,script_runtime:{compatibility_date:'2026-06-08',usage_model:'standard',migration_tag:'tag'},script:{handlers:['fetch']}}};}, + async upload(value){calls.push('upload');body=value;uploaded=true;return {id:next};}, + async activate(){calls.push('activate');activated=true;return {id:dep};} + }; + return {api,calls,get body(){return body},bindings}; +} +test('unbound real entry fails before provider or file access',()=>{ + assert.equal(pinned(POLICY),false); + const p=spawnSync(process.execPath,['scripts/console_m09_release.mjs','release','nonexistent'],{encoding:'utf8'}); + assert.equal(p.status,1);assert.match(p.stderr,/hold_or_input_invalid/); +}); +test('pins, rollback, source, tree, frontend and artifact must match before provider access',async()=>{ + for(const [p,e,c] of [[POLICY,evidence,candidate],[{...policy,rollback:'ffffffff-ffff-4fff-8fff-ffffffffffff'},evidence,candidate], + [policy,{...evidence,source:'d'.repeat(40)},candidate],[policy,{...evidence,tree:'d'.repeat(40)},candidate], + [policy,{...evidence,frontendBlob:'d'.repeat(40)},candidate],[policy,evidence,old]]) { + const f=fixture();const r=await release({api:f.api,policy:p,evidence:e,candidate:c,release:true}); + assert.equal(r.ok,false);assert.deepEqual(f.calls,[]); + } +}); +test('synthetic release inherits active config and verifies exact module around activation',async()=>{ + const f=fixture();const r=await release({api:f.api,policy,evidence,candidate,release:true}); + assert.equal(r.ok,true);assert.equal(r.active_verified,true);assert.equal(r.provider_cas,false); + assert.deepEqual(f.body.bindings,f.bindings.map(b=>({type:'inherit',name:b.name,version_id:version}))); + assert.equal(f.body.modules[0].content_base64,candidate.toString('base64')); + assert.equal(f.calls.filter(x=>x==='upload').length,1);assert.equal(f.calls.filter(x=>x==='activate').length,1); +}); + +test('preflight reads active state with write pins unset and returns redacted evidence',async()=>{ + const f=fixture(); + const result=await preflight(f.api); + assert.equal(result.ok,true); + assert.deepEqual(result.traffic,[{slot:1,percentage:100}]); + assert.equal(result.active_identity_unchanged,true); + assert.equal(result.module_matches_reference,false); + assert.equal(result.configuration.bindings.secret_text,1); + assert.equal(result.configuration.candidate_provider_configuration_observed,false); + assert.equal(result.rollback.eligible_after_release,false); + assert.equal(result.hold,true); + assert.doesNotMatch(JSON.stringify(result),/namespace_id|STRATEGY_SWITCH_CONFIG|SECRET|aaaaaaaa|bbbbbbbb|module_sha256/); + assert.deepEqual(f.calls,['deployments','versions','module','config','deployments','versions']); +}); + +test('preflight stops on split traffic or changed deployment',async()=>{ + const split=fixture(); + split.api.deployments=async()=>({deployments:[{id:deployment,strategy:'percentage', + versions:[{version_id:version,percentage:99},{version_id:deployment,percentage:1}]}]}); + const splitResult=await preflight(split.api); + assert.equal(splitResult.stage,'traffic'); + assert.equal(splitResult.traffic.length,2); + assert.deepEqual(split.calls,[]); + const drift=fixture(); + const read=drift.api.deployments; + drift.api.deployments=async()=>{ + const value=await read(); + if(drift.calls.length>1)value.deployments[0].id=version; + return value; + }; + assert.equal((await preflight(drift.api)).stage,'recheck'); + assert.ok(!drift.calls.includes('upload')&&!drift.calls.includes('activate')); +}); + +test('preflight adapter exposes only the fixed read methods',async()=>{ + const f=fixture(); + const api=createPreflightApi(f.api); + assert.deepEqual(Object.keys(api).sort(),['betaVersion','deployments','scriptVersion','versions']); + assert.equal(api.upload,undefined); + assert.equal(api.activate,undefined); + assert.equal((await preflight(api)).ok,true); + assert.deepEqual(f.calls,['deployments','versions','module','config','deployments','versions']); +}); + +test('preflight derives rollback classes from the existing workflow without emitting targets',async()=>{ + const targets=readRollbackTargets((...args)=>execFileSync('git',args,{encoding:'utf8'})); + assert.equal(new Set([...targets.fixed,targets.fixedSuccessor]).size,3); + const result=await preflight(fixture().api,targets); + assert.ok([...targets.fixed,targets.fixedSuccessor].every(id=>!JSON.stringify(result).includes(id))); + assert.throws(()=>readRollbackTargets(()=>''),/rollback_policy/); +});