Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openparachute/cloud",
"version": "0.0.8-rc.126",
"version": "0.0.8-rc.127",
"private": true,
"description": "Open Parachute PBC's Vault Cloud \u2014 one Durable Object per vault on Cloudflare, OAuth issuer + self-serve console (accounts + vault ownership).",
"license": "AGPL-3.0",
Expand Down
73 changes: 59 additions & 14 deletions scripts/smoke-staging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1182,9 +1182,29 @@ async function main() {
// the console MUST surface it: the card's "Using X of Y" line + the
// plan line's across-vaults total. Re-triggering proves the same-day
// upsert (rows refresh, never duplicate — the summary stays recorded>0).
{
//
// cloud#224: the trigger is SCOPED to this run's own vault (?vault=), so
// the rollup is O(1), not O(fleet). It used to enumerate EVERY staging
// vault; as the fleet grew with smoke debris past ~260 vaults it crossed
// the runtime's fetch timeout — and because §14 had NEITHER an explicit
// AbortSignal NOR a liveCatch, the throw escaped to main().catch() as a
// bare, section-less DOMException (empty stack), turning the deploy gate
// red with no clue which section died. Now the trigger carries an
// explicit, generous timeout and is classified via liveCatch (a genuine
// stall is ADVISORY, not an anonymous crash), and — applying cloud#221 —
// the live trigger and the console-render assertions live in SEPARATE
// blocks, so a slow rollup can't silently blind the surface checks: an
// unverified rollup skips them with a named advisory instead of failing
// them for a non-bug reason. The nightly cron still rolls up the whole
// fleet, tested unit-side (workers/identity/test/usage.test.ts).
const USAGE_ROLLUP_TIMEOUT_MS = 120_000;
let usageRecorded = false;
try {
const run = async (): Promise<{ day: string; vaults: number; recorded: number; failed: number; capped: boolean } | null> => {
const r = await fetch(`${IDENTITY}/__test/usage-run`, { method: "POST" });
const r = await fetch(`${IDENTITY}/__test/usage-run?vault=${encodeURIComponent(arrivalVault)}`, {
method: "POST",
signal: AbortSignal.timeout(USAGE_ROLLUP_TIMEOUT_MS),
});
return r.status === 200
? ((await r.json()) as { day: string; vaults: number; recorded: number; failed: number; capped: boolean })
: null;
Expand All @@ -1197,25 +1217,50 @@ async function main() {
"usage: the rollup recorded rows (this run's fresh vault included)",
`day=${first.day} vaults=${first.vaults} recorded=${first.recorded} failed=${first.failed}`,
);
// The row is upserted before the summary returns, so the console below
// can render it even if the re-run trigger later stalls.
usageRecorded = first.recorded >= 1;
const again = await run();
assert(
!!again && again.recorded >= 1 && again.day === first.day,
"usage: a same-day re-run refreshes rows (upsert, no duplicates)",
again ? `recorded=${again.recorded}` : "non-200",
);
}
const conHtml = await (await fetch(`${IDENTITY}/console`, { headers: { cookie: arrivalCookie } })).text();
assert(
// The arrival user is on the no-card trial (mirrors Plus): the card cap
// renders "of 8.5 GiB" (500 MB notes + 8 GiB attachments, summed).
conHtml.includes('data-testid="vault-usage"') && /Using \d+(\.\d+)? MB of 8\.5 GiB/.test(conHtml),
"usage: the vault card shows 'Using X of Y' from the rollup row",
arrivalVault,
);
assert(
conHtml.includes('data-testid="usage-total"'),
"usage: the plan line carries the across-vaults total",
);
} catch (err) {
liveCatch("usage: live rollup trigger", err);
}

// Console render — a SEPARATE concern (does the console SURFACE the usage?),
// deliberately NOT sharing the trigger's try (cloud#221). If the rollup above
// stalled it recorded its OWN named advisory and left usageRecorded false, so
// we SKIP these dependent checks with an advisory rather than fail them
// fatally for a non-bug reason. When the rollup DID record, the surface MUST
// render it — these asserts stay fatal.
if (usageRecorded) {
try {
const conHtml = await (
await fetch(`${IDENTITY}/console`, {
headers: { cookie: arrivalCookie },
signal: AbortSignal.timeout(USAGE_ROLLUP_TIMEOUT_MS),
})
).text();
assert(
// The arrival user is on the no-card trial (mirrors Plus): the card cap
// renders "of 8.5 GiB" (500 MB notes + 8 GiB attachments, summed).
conHtml.includes('data-testid="vault-usage"') && /Using \d+(\.\d+)? MB of 8\.5 GiB/.test(conHtml),
"usage: the vault card shows 'Using X of Y' from the rollup row",
arrivalVault,
);
assert(
conHtml.includes('data-testid="usage-total"'),
"usage: the plan line carries the across-vaults total",
);
} catch (err) {
liveCatch("usage: console usage render", err);
}
} else {
advisory("usage: console-render assertions SKIPPED — the rollup trigger above was unverified (see its advisory)");
}

// 15. Operator admin console (Wave 4c). The dev user IS the operator
Expand Down
8 changes: 7 additions & 1 deletion workers/identity/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,13 @@ app.post("/__test/drip-run", async (c) => {
app.post("/__test/usage-run", async (c) => {
const deps = depsFor(c.env);
if (!deps.exposeDevLinks) return c.notFound();
return c.json(await runUsageRollup(c.env, deps));
// Optional single-vault scope: smoke-staging §14 passes ?vault=<its own fresh
// vault> so its rollup assertion stays O(1) instead of enumerating the whole
// staging fleet (which outgrew the smoke's client timeout — cloud#224, the
// same cliff as the snapshot sweep in cloud#166/#218). No param → the full
// fleet rollup, unchanged (the nightly USAGE_CRON path).
const onlyVault = c.req.query("vault") || undefined;
return c.json(await runUsageRollup(c.env, deps, onlyVault ? { onlyVault } : {}));
});

// Staging/dev-only snapshot-sweep trigger — same gate + rationale (404 in
Expand Down
20 changes: 16 additions & 4 deletions workers/identity/src/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,20 +53,32 @@ export interface UsageRunSummary {
* right transport per environment; `deps.now` is the injectable clock.
* `opts.runCap` exists ONLY so tests can exercise the cap path without
* seeding 500 vaults; the cron always runs the default.
*
* `opts.onlyVault` scopes the run to a SINGLE vault instead of the whole fleet.
* The USAGE_CRON never sets it; the staging-only /__test/usage-run trigger
* passes it so the live smoke's rollup assertion (smoke-staging.ts §14) stays
* O(1) rather than O(fleet) — a fleet-wide rollup outgrew the smoke's client
* timeout as staging debris accumulated (cloud#224, the same cliff the snapshot
* sweep hit in cloud#166/#218).
*/
export async function runUsageRollup(
env: Env,
deps: OAuthDeps,
opts: { runCap?: number } = {},
opts: { runCap?: number; onlyVault?: string } = {},
): Promise<UsageRunSummary> {
const runCap = opts.runCap ?? USAGE_RUN_CAP;
const now = deps.now?.() ?? new Date();
const day = now.toISOString().slice(0, 10);

// +1 over the cap so "stopped at the cap" is distinguishable from "drained".
const res = await env.DB.prepare("SELECT name, owner_user_id FROM vaults ORDER BY name LIMIT ?")
.bind(runCap + 1)
.all<{ name: string; owner_user_id: string }>();
// `onlyVault` narrows to a single row (the run is inherently uncapped then).
const res = opts.onlyVault
? await env.DB.prepare("SELECT name, owner_user_id FROM vaults WHERE name = ? LIMIT 1")
.bind(opts.onlyVault)
.all<{ name: string; owner_user_id: string }>()
: await env.DB.prepare("SELECT name, owner_user_id FROM vaults ORDER BY name LIMIT ?")
.bind(runCap + 1)
.all<{ name: string; owner_user_id: string }>();
const rows = res.results ?? [];
const capped = rows.length > runCap;
const vaults = capped ? rows.slice(0, runCap) : rows;
Expand Down
38 changes: 38 additions & 0 deletions workers/identity/test/usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,27 @@ describe("runUsageRollup", () => {
expect((await usageRows()).map((r) => r.vault_name)).toEqual(["cap-a", "cap-b"]);
expect(USAGE_RUN_CAP).toBe(500); // the real bound the cron runs with
});

test("onlyVault scopes the run to a SINGLE vault — the rest of the fleet is untouched (O(1), not O(fleet))", async () => {
const { id: owner } = await seedUser("usage-scope@example.com");
await seedVault("uscope-mine", owner);
await seedVault("uscope-other", owner);
// ONLY the scoped vault's config read is intercepted; uscope-other must not
// be read at all — an unexpected call trips disableNetConnect, and
// assertNoPendingInterceptors (afterEach) proves the scoped interceptor was
// consumed. This is what keeps smoke §14's rollup O(1) as the fleet grows
// (cloud#224, mirroring the snapshot-sweep scope in cloud#166/#218).
interceptConfigGet("uscope-mine", splitBody(3333, 111));
const summary = await quietly(() => runUsageRollup(env, rollupDeps(), { onlyVault: "uscope-mine" }));
expect(summary).toEqual({ day: TODAY, vaults: 1, recorded: 1, failed: 0, capped: false });
expect((await usageRows()).map((r) => r.vault_name)).toEqual(["uscope-mine"]);
});

test("onlyVault for an unknown name → an empty, clean run (no vault reads)", async () => {
const summary = await quietly(() => runUsageRollup(env, rollupDeps(), { onlyVault: "does-not-exist" }));
expect(summary).toEqual({ day: TODAY, vaults: 0, recorded: 0, failed: 0, capped: false });
expect(await usageRows()).toEqual([]);
});
});

// --- latestUsageForVaults --------------------------------------------------------
Expand Down Expand Up @@ -348,4 +369,21 @@ describe("staging usage trigger", () => {
const prod = await worker.fetch(req(), { ...env, ENVIRONMENT: "production" });
expect(prod.status).toBe(404);
});

test("?vault=<name> scopes the trigger to that one vault (smoke §14's O(1) rollup setup)", async () => {
const { id: owner } = await seedUser("trigger-scope-usage@example.com");
await seedVault("utrig-a", owner);
await seedVault("utrig-b", owner);
// Only utrig-a is intercepted; utrig-b must never be read — no interceptor
// + assertNoPendingInterceptors proves the trigger scoped to the one vault.
interceptConfigGet("utrig-a", splitBody(555, 5));
const res = await quietly(async () =>
worker.fetch(new Request(`${ISSUER}/__test/usage-run?vault=utrig-a`, { method: "POST" }), env),
);
expect(res.status).toBe(200);
const summary = (await res.json()) as { vaults: number; recorded: number };
expect(summary.vaults).toBe(1); // only the scoped vault, not the fleet
expect(summary.recorded).toBe(1);
expect((await usageRows()).map((r) => r.vault_name)).toEqual(["utrig-a"]);
});
});