Implement announce/watch: a deploy signal that reverses CI's reach
All checks were successful
Docker / build-and-push (push) Successful in 1m58s
All checks were successful
Docker / build-and-push (push) Successful in 1m58s
CI announces a new image tag to keep (any grant is enough, read-only included); each deploy target polls for it locally instead of CI holding standing SSH access to production. keep stays a pure relay — the tag lives outside the encrypted vault payload in its own table, and keep never executes anything itself. Adds vault_announcements, POST/GET /vaults/:key/announce, and the `keep announce`/`keep watch` CLI commands, per PROPOSAL-announce-and-agent.md (now marked implemented). Verified live: a read-only identity announcing successfully and being logged, watch picking up the tag on its first poll, and watch exiting nonzero for an unknown/ungranted vault. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -114,3 +114,35 @@ export async function vaultLog(vaultKey: string): Promise<void> {
|
||||
console.log(`${new Date(e.accessed_at * 1000).toISOString()} ${e.action.padEnd(6)} ${e.recipient_id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function vaultAnnounce(vaultKey: string, tag: string): Promise<void> {
|
||||
await ready();
|
||||
const { identity, recipientId } = await requireIdentityAndRecipientId();
|
||||
await expectOk(await signedFetch(identity, recipientId, 'POST', `/api/vaults/${encodeURIComponent(vaultKey)}/announce`, { tag }));
|
||||
console.log(`announced '${vaultKey}' @ ${tag}`);
|
||||
}
|
||||
|
||||
async function fetchAnnouncement(vaultKey: string): Promise<{ tag: string; announcedAt: number; announcedBy: string } | null> {
|
||||
const { identity, recipientId } = await requireIdentityAndRecipientId();
|
||||
const res = await signedFetch(identity, recipientId, 'GET', `/api/vaults/${encodeURIComponent(vaultKey)}/announce`);
|
||||
if (res.status === 404) return null;
|
||||
return await expectOk(res);
|
||||
}
|
||||
|
||||
// Deliberately dumb — plain polling, not a websocket or long-poll. A
|
||||
// 60s detection lag for a deploy signal is fine; don't build push-based
|
||||
// notification for this.
|
||||
export async function vaultWatch(vaultKey: string, intervalSeconds: number): Promise<void> {
|
||||
await ready();
|
||||
console.log(`watching '${vaultKey}' every ${intervalSeconds}s (ctrl-c to stop)...`);
|
||||
|
||||
let lastTag: string | null = null;
|
||||
for (;;) {
|
||||
const announcement = await fetchAnnouncement(vaultKey);
|
||||
if (announcement && announcement.tag !== lastTag) {
|
||||
lastTag = announcement.tag;
|
||||
console.log(`${new Date(announcement.announcedAt * 1000).toISOString()} ${vaultKey} ${announcement.tag} (by ${announcement.announcedBy})`);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, intervalSeconds * 1000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import { identityInit, identityShow, identitySetId } from './commands/identity.js';
|
||||
import { recipientAdd, recipientList, recipientRemove, adminOverview } from './commands/recipient.js';
|
||||
import { vaultPush, vaultPull, vaultGrant, vaultRevoke, vaultLog } from './commands/vault.js';
|
||||
import { vaultPush, vaultPull, vaultGrant, vaultRevoke, vaultLog, vaultAnnounce, vaultWatch } from './commands/vault.js';
|
||||
|
||||
function flag(args: string[], name: string, fallback?: string): string | undefined {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
@@ -35,6 +35,8 @@ async function main(): Promise<void> {
|
||||
if (cmd === 'grant') return await vaultGrant(sub, rest[0], boolFlag(rest, 'read-only'));
|
||||
if (cmd === 'revoke') return await vaultRevoke(sub, rest[0]);
|
||||
if (cmd === 'log') return await vaultLog(sub);
|
||||
if (cmd === 'announce') return await vaultAnnounce(sub, flag(rest, 'tag')!);
|
||||
if (cmd === 'watch') return await vaultWatch(sub, Number(flag(rest, 'interval', '60')));
|
||||
|
||||
printUsage();
|
||||
process.exitCode = cmd ? 1 : 0;
|
||||
@@ -56,6 +58,8 @@ function printUsage(): void {
|
||||
keep grant <vault> <recipient-id> [--read-only]
|
||||
keep revoke <vault> <recipient-id> (admin — needs KEEP_ADMIN_PASSWORD)
|
||||
keep log <vault>
|
||||
keep announce <vault> --tag <tag> (requires any grant, read-only is enough)
|
||||
keep watch <vault> [--interval 60] (polls for a new announced tag; ctrl-c to stop)
|
||||
|
||||
keep recipient add --label <label> --pubkey <hex> (admin)
|
||||
keep recipient list (admin)
|
||||
|
||||
@@ -61,6 +61,17 @@ db.exec(`
|
||||
action TEXT NOT NULL,
|
||||
accessed_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Latest known deploy tag for a vault — "current state", not a
|
||||
-- history, same one-row-per-vault shape as the vaults table's own
|
||||
-- prev_ciphertext retention. An image tag isn't a secret, so this
|
||||
-- lives outside the encrypted payload entirely.
|
||||
CREATE TABLE IF NOT EXISTS vault_announcements (
|
||||
vault_key TEXT PRIMARY KEY,
|
||||
tag TEXT NOT NULL,
|
||||
announced_at INTEGER NOT NULL,
|
||||
announced_by TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
export interface VaultRow {
|
||||
@@ -88,6 +99,13 @@ export interface GrantRow {
|
||||
granted_at: number;
|
||||
}
|
||||
|
||||
export interface AnnouncementRow {
|
||||
vault_key: string;
|
||||
tag: string;
|
||||
announced_at: number;
|
||||
announced_by: string;
|
||||
}
|
||||
|
||||
export function getVault(vaultKey: string): VaultRow | undefined {
|
||||
return db.prepare(`SELECT * FROM vaults WHERE vault_key = ?`).get(vaultKey) as VaultRow | undefined;
|
||||
}
|
||||
@@ -225,7 +243,7 @@ export function deleteGrant(vaultKey: string, recipientId: string): void {
|
||||
db.prepare(`DELETE FROM vault_grants WHERE vault_key = ? AND recipient_id = ?`).run(vaultKey, recipientId);
|
||||
}
|
||||
|
||||
export function logAccess(vaultKey: string, recipientId: string, action: 'pull' | 'push' | 'grant' | 'revoke'): void {
|
||||
export function logAccess(vaultKey: string, recipientId: string, action: 'pull' | 'push' | 'grant' | 'revoke' | 'announce'): void {
|
||||
db.prepare(
|
||||
`INSERT INTO access_log (vault_key, recipient_id, action, accessed_at) VALUES (?, ?, ?, ?)`,
|
||||
).run(vaultKey, recipientId, action, Math.floor(Date.now() / 1000));
|
||||
@@ -236,3 +254,17 @@ export function getAccessLog(vaultKey: string, limit = 50): { recipient_id: stri
|
||||
`SELECT recipient_id, action, accessed_at FROM access_log WHERE vault_key = ? ORDER BY accessed_at DESC LIMIT ?`,
|
||||
).all(vaultKey, limit) as { recipient_id: string; action: string; accessed_at: number }[];
|
||||
}
|
||||
|
||||
// One row per vault — "latest known tag", not a history. Upsert.
|
||||
export function setAnnouncement(vaultKey: string, tag: string, announcedBy: string): void {
|
||||
db.prepare(
|
||||
`INSERT INTO vault_announcements (vault_key, tag, announced_at, announced_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(vault_key) DO UPDATE SET
|
||||
tag = excluded.tag, announced_at = excluded.announced_at, announced_by = excluded.announced_by`,
|
||||
).run(vaultKey, tag, Math.floor(Date.now() / 1000), announcedBy);
|
||||
}
|
||||
|
||||
export function getAnnouncement(vaultKey: string): AnnouncementRow | undefined {
|
||||
return db.prepare(`SELECT * FROM vault_announcements WHERE vault_key = ?`).get(vaultKey) as AnnouncementRow | undefined;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
logAccess,
|
||||
getAccessLog,
|
||||
listRecipients,
|
||||
setAnnouncement,
|
||||
getAnnouncement,
|
||||
} from './db.js';
|
||||
import { requireRecipientAuth, type AuthedRequest } from './auth.js';
|
||||
|
||||
@@ -179,3 +181,42 @@ router.get('/vaults/:key/log', (req: AuthedRequest, res) => {
|
||||
}
|
||||
res.json(getAccessLog(vaultKey));
|
||||
});
|
||||
|
||||
// A deploy tag isn't a secret — it's not stored in the encrypted payload,
|
||||
// just a small side table. Only requires ANY grant (read-only is enough)
|
||||
// since announcing isn't a mutation of vault secrets — a CI identity
|
||||
// should never need write access on a vault just to say "there's a new
|
||||
// image". Resist reaching for hasWriteGrant here by analogy with push.
|
||||
router.post('/vaults/:key/announce', (req: AuthedRequest, res) => {
|
||||
const vaultKey = req.params.key;
|
||||
const recipientId = req.recipientId!;
|
||||
|
||||
if (!hasGrant(vaultKey, recipientId)) {
|
||||
res.status(403).json({ error: 'no access to this vault' });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body as { tag?: string };
|
||||
if (!body.tag) {
|
||||
res.status(400).json({ error: 'tag is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
setAnnouncement(vaultKey, body.tag, recipientId);
|
||||
logAccess(vaultKey, recipientId, 'announce');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/vaults/:key/announce', (req: AuthedRequest, res) => {
|
||||
const vaultKey = req.params.key;
|
||||
if (!hasGrant(vaultKey, req.recipientId!)) {
|
||||
res.status(403).json({ error: 'no access to this vault' });
|
||||
return;
|
||||
}
|
||||
const announcement = getAnnouncement(vaultKey);
|
||||
if (!announcement) {
|
||||
res.status(404).json({ error: 'nothing announced yet' });
|
||||
return;
|
||||
}
|
||||
res.json({ tag: announcement.tag, announcedAt: announcement.announced_at, announcedBy: announcement.announced_by });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user