Add versioned deployment receipt ingestion
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
PORT=3082
|
||||
TRACE_PASSWORD=replace-me
|
||||
# Project-scoped ingestion credentials: project:token pairs.
|
||||
TRACE_RECEIPT_TOKENS=goonk:replace-with-a-long-random-token
|
||||
# Comma-separated id:absolute-path pairs. Mount paths read-only in Docker.
|
||||
TRACE_REPOS=goonk:/repos/goonk,flit:/repos/flit
|
||||
|
||||
@@ -8,3 +8,7 @@ npm start
|
||||
```
|
||||
|
||||
Node 24+, no runtime dependencies. Uses built-in `node:sqlite`. Set `TRACE_PASSWORD`; configure read-only repositories through `TRACE_REPOS`. Nothing publishes automatically: export creates a draft under `data/exports/` for manual review.
|
||||
|
||||
## Deployment receipts v1
|
||||
|
||||
`POST /api/v1/events` accepts versioned build/deployment receipts using a project-scoped bearer token configured as `TRACE_RECEIPT_TOKENS=goonk:token,retur:other-token`. Event IDs are idempotent. `GET /api/v1/public/projects/:project/latest` returns an explicit allowlisted projection; raw payloads remain private.
|
||||
|
||||
15
src/receipts.mjs
Normal file
15
src/receipts.mjs
Normal file
@@ -0,0 +1,15 @@
|
||||
import crypto from 'node:crypto';
|
||||
const RESULTS=new Set(['success','failed','cancelled','unknown']), TYPES=new Set(['build','deployment','release','rollback']);
|
||||
const send=(res,status,value)=>res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':status===200?'public, max-age=30':'no-store'}).end(JSON.stringify(value));
|
||||
export function validateReceipt(v){const e=[];if(!v||typeof v!=='object'||Array.isArray(v))return['body must be an object'];if(v.schemaVersion!==1)e.push('schemaVersion must be 1');if(!TYPES.has(v.eventType))e.push('unsupported eventType');for(const f of ['eventId','project','occurredAt'])if(typeof v[f]!=='string'||!v[f].trim())e.push(`${f} is required`);if(v.occurredAt&&Number.isNaN(Date.parse(v.occurredAt)))e.push('occurredAt must be ISO-8601');if(!RESULTS.has(v.result))e.push('unsupported result');if(!v.source||typeof v.source.commit!=='string'||!/^[a-f0-9]{7,64}$/i.test(v.source.commit))e.push('source.commit must be a commit SHA');if(v.artifacts&&!Array.isArray(v.artifacts))e.push('artifacts must be an array');return e;}
|
||||
export function createReceiptStore(db,config=''){
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS deployment_events(id INTEGER PRIMARY KEY AUTOINCREMENT,event_id TEXT NOT NULL UNIQUE,schema_version INTEGER NOT NULL,event_type TEXT NOT NULL,project TEXT NOT NULL,environment TEXT NOT NULL DEFAULT '',source_commit TEXT NOT NULL,source_branch TEXT NOT NULL DEFAULT '',result TEXT NOT NULL,health_result TEXT NOT NULL DEFAULT '',occurred_at TEXT NOT NULL,received_at TEXT NOT NULL,previous_event_id TEXT NOT NULL DEFAULT '',payload TEXT NOT NULL);CREATE INDEX IF NOT EXISTS deployment_events_project_time ON deployment_events(project,occurred_at DESC);`);
|
||||
const tokens=new Map(config.split(',').filter(Boolean).map(p=>{const i=p.indexOf(':');return[p.slice(i+1),p.slice(0,i)]}));
|
||||
const authenticated=(req,project)=>{const token=req.headers.authorization?.replace(/^Bearer\s+/i,'');if(!token)return false;for(const[candidate,allowed]of tokens)if(candidate.length===token.length&&crypto.timingSafeEqual(Buffer.from(candidate),Buffer.from(token))&&allowed===project)return true;return false};
|
||||
const publicEvent=row=>{if(!row)return null;const p=JSON.parse(row.payload);return{schemaVersion:row.schema_version,eventId:row.event_id,eventType:row.event_type,project:row.project,environment:row.environment||undefined,commit:row.source_commit,branch:row.source_branch||undefined,result:row.result,health:row.health_result||undefined,occurredAt:row.occurred_at,artifacts:(p.artifacts||[]).map(x=>({component:x.component,digest:x.digest?.slice(0,19)}))}};
|
||||
async function handle(req,res,path){
|
||||
if(path==='/api/v1/events'&&req.method==='POST'){let raw='';for await(const c of req){raw+=c;if(raw.length>131072)return send(res,413,{error:'receipt too large'})}let v;try{v=JSON.parse(raw)}catch{return send(res,400,{error:'invalid JSON'})}const errors=validateReceipt(v);if(errors.length)return send(res,422,{error:'invalid receipt',details:errors});if(!authenticated(req,v.project))return send(res,401,{error:'invalid receipt credential'});if(db.prepare('SELECT event_id FROM deployment_events WHERE event_id=?').get(v.eventId))return send(res,200,{ok:true,eventId:v.eventId,duplicate:true});db.prepare(`INSERT INTO deployment_events(event_id,schema_version,event_type,project,environment,source_commit,source_branch,result,health_result,occurred_at,received_at,previous_event_id,payload) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(v.eventId,1,v.eventType,v.project,v.environment||'',v.source.commit,v.source.branch||'',v.result,v.verification?.health||'',v.occurredAt,new Date().toISOString(),v.previousEventId||'',raw);return send(res,201,{ok:true,eventId:v.eventId});}
|
||||
const m=path.match(/^\/api\/v1\/public\/projects\/([a-z0-9-]+)\/latest$/);if(m&&req.method==='GET'){const row=db.prepare("SELECT * FROM deployment_events WHERE project=? AND event_type='deployment' ORDER BY occurred_at DESC LIMIT 1").get(m[1]);return send(res,200,{project:m[1],deployment:publicEvent(row)})}
|
||||
if(path==='/api/v1/public/projects'&&req.method==='GET'){const rows=db.prepare(`SELECT e.* FROM deployment_events e JOIN (SELECT project,MAX(occurred_at) occurred_at FROM deployment_events WHERE event_type='deployment' GROUP BY project) l ON l.project=e.project AND l.occurred_at=e.occurred_at ORDER BY e.project`).all();return send(res,200,{projects:rows.map(publicEvent)})}return false;
|
||||
}return{handle,publicEvent};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { promisify } from 'node:util';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { createReceiptStore } from './receipts.mjs';
|
||||
|
||||
const exec = promisify(execFile), port = Number(process.env.PORT || 3082), password = process.env.TRACE_PASSWORD || '';
|
||||
mkdirSync('data', { recursive: true }); mkdirSync('data/exports', { recursive: true });
|
||||
@@ -15,6 +16,7 @@ CREATE TABLE IF NOT EXISTS timeline(id INTEGER PRIMARY KEY AUTOINCREMENT,inciden
|
||||
CREATE TABLE IF NOT EXISTS hypotheses(id INTEGER PRIMARY KEY AUTOINCREMENT,incident_id TEXT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,statement TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'untested',evidence TEXT DEFAULT '');
|
||||
CREATE TABLE IF NOT EXISTS candidates(id INTEGER PRIMARY KEY AUTOINCREMENT,repo TEXT NOT NULL,sha TEXT NOT NULL,committed_at TEXT NOT NULL,subject TEXT NOT NULL,paths TEXT DEFAULT '',reason TEXT NOT NULL,state TEXT NOT NULL DEFAULT 'new',incident_id TEXT,UNIQUE(repo,sha));
|
||||
CREATE TABLE IF NOT EXISTS receipts(id INTEGER PRIMARY KEY AUTOINCREMENT,project TEXT NOT NULL,commit_sha TEXT NOT NULL,deployed_at TEXT NOT NULL,environment TEXT DEFAULT '',health TEXT DEFAULT '',payload TEXT NOT NULL,UNIQUE(project,commit_sha,deployed_at));`);
|
||||
const receiptStore = createReceiptStore(db, process.env.TRACE_RECEIPT_TOKENS || '');
|
||||
const repos = new Map((process.env.TRACE_REPOS || '').split(',').filter(Boolean).map(x => { const i=x.indexOf(':'); return [x.slice(0,i),x.slice(i+1)]; }));
|
||||
const esc=s=>String(s??'').replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"');
|
||||
const form=async req=>{let b='';for await(const c of req)b+=c;return Object.fromEntries(new URLSearchParams(b));};
|
||||
@@ -34,4 +36,4 @@ if(u.pathname==='/candidates'&&req.method==='POST'){await scan();redirect(res,'/
|
||||
const link=u.pathname.match(/^\/candidates\/(\d+)\/link$/);if(link&&req.method==='POST'){const f=await form(req);db.prepare("UPDATE candidates SET incident_id=?,state='linked' WHERE id=?").run(f.incident_id,link[1]);redirect(res,'/candidates');return;}
|
||||
if(u.pathname==='/api/receipts'&&req.method==='POST'){let raw='';for await(const c of req)raw+=c;const x=JSON.parse(raw);db.prepare('INSERT OR IGNORE INTO receipts(project,commit_sha,deployed_at,environment,health,payload) VALUES(?,?,?,?,?,?)').run(x.project,x.commit,x.deployedAt,x.environment||'',x.health||'',raw);res.writeHead(201,{'content-type':'application/json'}).end('{"ok":true}');return;}
|
||||
res.writeHead(404).end(layout('not found','<h1>Not found</h1>'));}
|
||||
http.createServer((req,res)=>handler(req,res).catch(e=>{console.error(e);res.writeHead(500).end('Internal error');})).listen(port,()=>console.log(`trace listening on ${port}`));
|
||||
http.createServer(async(req,res)=>{try{const path=new URL(req.url,'http://x').pathname;const handled=await receiptStore.handle(req,res,path);if(handled===false)await handler(req,res);}catch(e){console.error(e);if(!res.headersSent)res.writeHead(500);res.end('Internal error');}}).listen(port,()=>console.log(`trace listening on ${port}`));
|
||||
|
||||
4
test/receipts.test.mjs
Normal file
4
test/receipts.test.mjs
Normal file
@@ -0,0 +1,4 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {DatabaseSync}from'node:sqlite';import{createReceiptStore,validateReceipt}from'../src/receipts.mjs';
|
||||
const valid={schemaVersion:1,eventType:'deployment',eventId:'goonk-prod-1',project:'goonk',occurredAt:'2026-07-20T20:00:00Z',result:'success',source:{commit:'577a62e'},verification:{health:'healthy'}};
|
||||
test('validates v1',()=>{assert.deepEqual(validateReceipt(valid),[]);assert.ok(validateReceipt({}).length)});
|
||||
test('is idempotent and public projection is allowlisted',async()=>{const db=new DatabaseSync(':memory:'),store=createReceiptStore(db,'goonk:secret');const req=()=>({method:'POST',headers:{authorization:'Bearer secret'},async *[Symbol.asyncIterator](){yield JSON.stringify(valid)}});const res=()=>{const x={status:0,body:''};x.writeHead=s=>{x.status=s;return x};x.end=b=>{x.body=b;return x};return x};let r=res();await store.handle(req(),r,'/api/v1/events');assert.equal(r.status,201);r=res();await store.handle(req(),r,'/api/v1/events');assert.equal(JSON.parse(r.body).duplicate,true);r=res();await store.handle({method:'GET',headers:{}},r,'/api/v1/public/projects/goonk/latest');const out=JSON.parse(r.body);assert.equal(out.deployment.commit,'577a62e');assert.equal(out.deployment.payload,undefined)});
|
||||
Reference in New Issue
Block a user