diff --git a/docs/extensions/cloudflare-hosting.md b/docs/extensions/cloudflare-hosting.md index 070251fcf..f166f5bc1 100644 --- a/docs/extensions/cloudflare-hosting.md +++ b/docs/extensions/cloudflare-hosting.md @@ -22,6 +22,7 @@ npx tsc npm ci --prefix marketplace/cloudflare --no-audit --no-fund npm run build --prefix marketplace/cloudflare npm test --prefix marketplace/cloudflare +(cd marketplace/cloudflare && node ops-test.mjs) CLOUDFLARE_REGISTRY=1 PLAYWRIGHT_MODULE=/absolute/path/to/playwright \ node scripts/validation/extensions-compatibility.cjs CLOUDFLARE_REGISTRY=1 PLAYWRIGHT_MODULE=/absolute/path/to/playwright \ @@ -39,7 +40,7 @@ node ops.mjs backup /absolute/lab/registry /absolute/backups/unique-snapshot node ops.mjs restore /absolute/new-lab/registry /absolute/backups/unique-snapshot ``` -Stop the rehearsal server before using the operator CLI on that same local state. The binding-level backup reads identity/owners/releases/nonces in one D1 batch snapshot, then copies immutable R2 bytes. A completion manifest is written last. Restore verifies checksums, manifest/package/ownership correspondence, refuses an occupied target, copies objects first and publishes metadata in one D1 batch. Partial restores remain detached and must be retried into a new destination. Backups contain public packages and metadata, not publisher private keys. Protect the independent checksum against tampering; checksums alone are not authentication. Local process-kill proof does not establish provider disk/power-loss or off-host retention guarantees. +Stop the rehearsal server before using the operator CLI on that same local state. Backup refuses missing or uninitialized source state and never silently migrates an empty source into a successful snapshot. The binding-level backup reads identity/owners/releases/nonces in one D1 batch snapshot, then copies immutable R2 bytes. A completion manifest is written last. Restore verifies checksums, manifest/package/ownership correspondence, refuses an occupied target, copies objects first and publishes metadata in one D1 batch. Partial restores remain detached and must be retried into a new destination. Backups contain public packages and metadata, not publisher private keys. Protect the independent checksum against tampering; checksums alone are not authentication. Local process-kill proof does not establish provider disk/power-loss or off-host retention guarantees. ## Prepare deployment after access is verified diff --git a/marketplace/cloudflare/local.mjs b/marketplace/cloudflare/local.mjs index d2cfe8f54..052c32ab9 100644 --- a/marketplace/cloudflare/local.mjs +++ b/marketplace/cloudflare/local.mjs @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const root=path.dirname(fileURLToPath(import.meta.url)); -export async function startLocal({state,testing=false,boundary,port=0,publishing=true}={}) { +export async function startLocal({state,testing=false,boundary,port=0,publishing=true,initialize=true}={}) { const mf=new Miniflare({...convertV4MiniflareOptions({host:'127.0.0.1',port,workers:[{name:'registry',modules:true,scriptPath:path.join(root,'.build',testing?'test-worker.js':'worker.js'),compatibilityDate:'2026-09-21', d1Databases:{DB:'registry'},r2Buckets:{PACKAGES:'packages'}, bindings:{PUBLISHING_ENABLED:String(publishing)},serviceBindings:{ @@ -12,6 +12,7 @@ export async function startLocal({state,testing=false,boundary,port=0,publishing await mf.ready; const db=await mf.getD1Database('DB'); const exists=await db.prepare("SELECT name FROM sqlite_master WHERE name='registry_meta'").first(); + if(!exists&&!initialize){await mf.dispose();throw Error('Source registry is not initialized; verify the source state path before backup');} if(!exists)for(const line of fs.readFileSync(path.join(root,'migrations/0001_registry.sql'),'utf8').split('\n').filter(Boolean))await db.exec(line); return {mf,db,bucket:await mf.getR2Bucket('PACKAGES'),port:Number((await mf.ready).port),close:()=>mf.dispose()}; } diff --git a/marketplace/cloudflare/ops-test.mjs b/marketplace/cloudflare/ops-test.mjs new file mode 100644 index 000000000..5a4d10063 --- /dev/null +++ b/marketplace/cloudflare/ops-test.mjs @@ -0,0 +1,30 @@ +// Real subprocess rehearsal of the documented offline operator commands. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import assert from 'node:assert/strict'; +import {spawn,execFileSync} from 'node:child_process'; +import {generateKeyPairSync,sign,randomUUID} from 'node:crypto'; +import {startLocal} from './local.mjs'; +const root=path.resolve('../..'),out=path.resolve(process.env.CLOUDFLARE_OPS_OUTPUT||path.join(root,'.qa/cloudflare/operators')); +fs.mkdirSync(out,{recursive:true}); +const lab=fs.mkdtempSync(path.join(os.tmpdir(),'cg-worker-operators-')),checks=[],commands=[]; +async function command(args,expected=0){ + const result=await new Promise((resolve,reject)=>{const child=spawn(process.execPath,['ops.mjs',...args],{timeout:30000});let log='';child.stdout.on('data',b=>log+=b);child.stderr.on('data',b=>log+=b);child.on('error',reject);child.on('close',(exit,signal)=>resolve({exit,signal,log}));}); + const log='command-'+(commands.length+1)+'.log';fs.writeFileSync(path.join(out,log),result.log);commands.push({command:[process.execPath,'ops.mjs',...args],exit:result.exit,signal:result.signal,log});assert.equal(result.exit,expected,result.log);return result; +} +function passed(name){checks.push(name);fs.writeFileSync(path.join(out,'progress.json'),JSON.stringify({checks,commands},null,2));console.log('PASS',name);} +let source,restored; +try{ + const empty=path.join(lab,'empty');fs.mkdirSync(empty); + const absent=await command(['backup',path.join(lab,'missing'),path.join(lab,'absent-backup')],1);assert.match(absent.log,/does not exist/);assert.ok(!fs.existsSync(path.join(lab,'absent-backup')));passed('missing source fails before backup'); + const uninitialized=await command(['backup',empty,path.join(lab,'empty-backup')],1);assert.match(uninitialized.log,/not initialized/);assert.ok(!fs.existsSync(path.join(lab,'empty-backup')));passed('empty source fails without creating a registry snapshot'); + const state=path.join(lab,'source'),backup=path.join(lab,'snapshot'),target=path.join(lab,'restored');source=await startLocal({state}); + const artifact=Buffer.from(JSON.stringify({format:'codegraph-extension-1',package:{name:'operator-demo',version:'1.0.0',main:'index.cjs',codegraph:{id:'operator-demo',apiVersion:1,capabilities:['frameworks']}},files:{'index.cjs':'module.exports=()=>({frameworks:[]});'}})); + const keys=generateKeyPairSync('ec',{namedCurve:'prime256v1'}),payload=JSON.stringify({name:'Operator example',description:'Backup rehearsal',publisher:'Test',readme:'Fixture',source:'https://example.com/source',artifact:artifact.toString('base64'),timestamp:Date.now(),nonce:randomUUID()}); + const response=await fetch('http://127.0.0.1:'+source.port+'/api/publish',{method:'POST',body:JSON.stringify({payload,publicKey:keys.publicKey.export({format:'jwk'}),signature:sign('sha256',Buffer.from(payload),{key:keys.privateKey,dsaEncoding:'ieee-p1363'}).toString('base64')})});assert.equal(response.status,201);const listing=await response.json();await source.close();source=null; + await command(['backup',state,backup]);await command(['backup',state,backup],1);passed('documented backup command captures a release and refuses overwrite'); + await command(['restore',target,backup]);restored=await startLocal({state:target,initialize:false,publishing:false});const catalog=await(await fetch('http://127.0.0.1:'+restored.port+'/api/extensions')).json();assert.deepEqual(catalog,[listing]);const bytes=Buffer.from(await(await fetch('http://127.0.0.1:'+restored.port+'/api/download/operator-demo/1.0.0')).arrayBuffer());assert.deepEqual(bytes,artifact);await restored.close();restored=null;passed('documented restore command preserves publisher release and exact bytes'); + const occupied=await command(['restore',target,backup],1);assert.match(occupied.log,/new state directory/);passed('restore refuses an existing target before mutation'); + fs.writeFileSync(path.join(out,'result.json'),JSON.stringify({revision:execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim(),dirty:!!execFileSync('git',['status','--porcelain'],{encoding:'utf8'}).trim(),checks,commands,success:true},null,2)); +}finally{await source?.close();await restored?.close();fs.rmSync(lab,{recursive:true,force:true});} diff --git a/marketplace/cloudflare/ops.mjs b/marketplace/cloudflare/ops.mjs index 83545bf85..102099025 100644 --- a/marketplace/cloudflare/ops.mjs +++ b/marketplace/cloudflare/ops.mjs @@ -8,7 +8,7 @@ if(!['serve','backup','restore'].includes(action)||!stateArg||(['backup','restor if(!path.isAbsolute(stateArg)||(backupArg&&!path.isAbsolute(backupArg)))throw Error('Use explicit absolute state and backup paths'); if(action==='backup'&&!fs.existsSync(stateArg))throw Error('Source state directory does not exist'); if(action==='restore'&&fs.existsSync(stateArg))throw Error('Restore requires a new state directory'); -const server=await startLocal({state:stateArg,publishing:action==='serve'}); +const server=await startLocal({state:stateArg,publishing:action==='serve',initialize:action!=='backup'}); if(action==='serve'){ console.log(JSON.stringify({local:true,origin:'http://127.0.0.1:'+server.port,state:stateArg})); for(const signal of ['SIGTERM','SIGINT'])process.once(signal,async()=>{await server.close();process.exit(0);});