mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 22:02:49 +08:00
fix: refuse uninitialized Cloudflare backup sources
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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()};
|
||||
}
|
||||
|
||||
@@ -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});}
|
||||
@@ -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);});
|
||||
|
||||
Reference in New Issue
Block a user