openvibers/openvibe.media Commits
Media S3: per-zone usage — requests, egress and stored bytes counted, every closed hour sent as media.usage.recorded (contracts 0.175.0) (#111)
Changes
@@@ -45,7 +45,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pgexport and deletion), OpenVibe.Live (`live.lineage.resolve`), OpenVibe.Events (the outbox relay andthe account and revocation subscriptions)- Backblaze B2 and Cloudflare R2 (S3 API) when configured; `ffmpeg`/`ffprobe` on the host--- `openvibe-contracts` v0.143.0, `openvibe-sdk` v0.44.0 (tokens, events outbox, usage readings, per-actor limits),++- `openvibe-contracts` v0.175.0, `openvibe-sdk` v0.44.0 (tokens, events outbox, usage readings, per-actor limits),`openvibe-shared` v4.1.3, pinned by release tarball## Capabilities@@@ -129,7 +129,7 @@ server/storage.orphans.scan (monthly storage orphan report, service-wide, report only),storage.move.cleanup (the alert for placement moves whose delete keeps failing, service-wide)client-ip.js trust proxy = loopback; req.ip is the only client address--(openvibe-shared v4.1.3, openvibe-contracts v0.173.0, openvibe-sdk v0.44.0: pinned release tarballs, installed by npm)++(openvibe-shared v4.1.3, openvibe-contracts v0.175.0, openvibe-sdk v0.44.0: pinned release tarballs, installed by npm)scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)scripts/reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator toolsscripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposals@@@ -749,7 +749,7 @@ Rollback: ovhost puts the previous sha back by itself when `/api/ready` does notrestart; afterwards `sudo ovhost rollback media --to <sha>`.<!-- versions:start -->--- openvibe-contracts: v0.143.0++- openvibe-contracts: v0.175.0- openvibe-sdk: v0.44.0- openvibe-shared: v4.1.3<!-- versions:end -->@@@ -13,6 +13,7 @@ a named object zone of one project environment. Zones are made and deleted only| Access keys | `server/s3/keys.js` | `ovk_` keys for one project environment: read, write or both, and optionally limited to some zones. Secrets are encrypted with `MEDIA_S3_KEY_SECRET` and shown once. || Zones | `server/s3/zones.js` | Media's copy of Zone's records. It resolves bucket names, gives each zone's namespace its quota, and runs zone deletion. || S3 routes | `server/s3/routes.js` | The operations below, dispatched by `Host` (`MEDIA_S3_HOSTS`). |++| Usage | `server/s3/usage.js` | Counts each zone's requests, egress and stored bytes, and sends every closed hour as `media.usage.recorded` (see "Usage" below). || Control doors | `server/s3/admin-routes.js` | `PUT/GET /internal/zones/:id` (Zone only, `media.zone.provision`) and `/api/v1/projects/:project/access-keys` (`media.access_key.manage`, on behalf of a project admin). |## Operations@@@ -39,10 +40,35 @@ a named object zone of one project environment. Zones are made and deleted only- **HEAD through a proxy.** Cloudflare forwards a HEAD as a GET for paths it may cache (by extension: `.jpg`, `.bin`, `.zip` …), but SigV4 signs the method. A header-signed request with no body that arrives as GET and verifies only as HEAD is served as the GET, and the proxy returns just the headers. A presigned URL never gets this, because one may be shared for a HEAD alone; nor does any other method. Every S3 response is `Cache-Control: private, no-store`, so the edge never caches one (`cf-cache-status: BYPASS`). The fix at the edge itself is a DNS-only `s3.openvibe.media`.- **No browser execution.** Every response carries `Content-Security-Policy: sandbox; default-src 'none'` and `nosniff`, whatever type the object declares.++## Usage++++Media is the only counter (ADR-031 section 8). Every request that reaches a zone counts against it when its answer is++sent. Every closed UTC hour goes out as one `media.usage.recorded` event per series (`common.usage-recorded@1`):++++| Series | Capability | Unit |++|---|---|---|++| Stored bytes | `media.object.upload` | `byte_hours`: the zone's live bytes sampled each minute, a sixtieth each time |++| Writes (PUT, multipart, POST) | `media.object.upload` | `requests` |++| Deletes (DELETE, DeleteObjects, abort) | `media.object.delete` | `requests` |++| Lists (ListObjects, ListParts, ListMultipartUploads, HeadBucket) | `media.object.list` | `requests` |++| Reads (GET, HEAD) | `media.object.read` | `requests`, and `egress_bytes` for the GETs answered |++++Each rollup names its zone twice: `resource` is the zone's OVRN and `dimension` its id in lowercase. A refused request++counts in `errors`, by its S3 code (`media.s3.no_such_key`, …), with the last ten failures as samples. A request that++names no zone of its key (`NoSuchBucket`, a bad signature) counts nowhere.++++- **Storage:** counts are kept in memory and added to `media_s3_usage` every 30 s, and at shutdown.++- **Sending:** every 5 minutes, the closed hours go to the outbox, in the transaction that marks them sent. Counts that land after an hour was sent raise its `revision`, and it is sent again with its new total.++- **Validation:** a rollup that does not match the contract stays unsent and is logged.++- **Retention:** sent rows go after 40 days.++- **Sandbox zones** are rolled up too: the rollup says its `env`.++- **Who reads them:** Network adds the rollups to the project's usage and Zone sums them in its usage view. They are counts, never money.++## Tests- `test/s3-zones.test.js` runs end to end with the AWS SDK for JavaScript v3: every operation, presigned URLs, isolation, quotas, holds, two doors and zone deletion.- `test/s3-multipart.test.js` uses the SDK's `Upload` helper (parallel parts) and the raw commands: S3's rules, ETags, abort, quota and expiry.++- `test/s3-usage.test.js` counts every series through the SDK, then checks the rollups against the contract, their revisions and the pruning.- `test/s3-sigv4.test.js` checks the worked examples of the S3 API reference, including the chunk signature chain.## Using itnnew file mode 100644@@@ -0,0 +1,22 @@++-- phase: expand++-- Per-zone usage on the S3 surface (ADR-031 section 8, server/s3/usage.js): the counts of one zone's series in one UTC++-- hour, added to every 30 s from memory. A closed hour is sent as media.usage.recorded (common.usage-recorded@1) and++-- marked with the revision it was sent at; counts that land after it raise the revision, and the hour is sent again.++-- Counts only: no key, object key, address or subject is kept.++CREATE TABLE media_s3_usage (++ zone_id text COLLATE "C" NOT NULL,++ project_id text COLLATE "C" NOT NULL,++ env text COLLATE "C" NOT NULL,++ window_start bigint NOT NULL, -- ms since the epoch, on the hour (UTC)++ capability text COLLATE "C" NOT NULL, -- media.object.upload | read | list | delete++ unit text COLLATE "C" NOT NULL, -- requests | egress_bytes | byte_hours++ quantity bigint NOT NULL DEFAULT 0,++ errors bigint NOT NULL DEFAULT 0,++ error_codes text NOT NULL DEFAULT '{}', -- { "media.s3.<code>": n }, the 20 largest++ samples text NOT NULL DEFAULT '[]', -- the last 10 failures: { at, code, status }++ revision integer NOT NULL DEFAULT 1,++ sent_revision integer, -- the revision last sent; NULL: not sent yet++ updated_at bigint NOT NULL,++ PRIMARY KEY (zone_id, window_start, capability, unit)++);++CREATE INDEX media_s3_usage_unsent ON media_s3_usage (window_start) WHERE sent_revision IS NULL OR sent_revision < revision;@@@ -16,7 +16,7 @@"express": "^4.19.2","iovalkey": "^0.4.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.173.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.175.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3","pg": "^8.23.0",@@@ -1847,9 +1847,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.173.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.173.0",-- "integrity": "sha512-6zHbU9zghIYDd3PPGtgMTsIgxrWwsoBHO/rvuLW1rghr6kRK6VwCmSMZRcjQbcPODPH1XrUAFuLg/T1K4nOCMQ==",++ "version": "0.175.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.175.0",++ "integrity": "sha512-AJNkb/g7mMQDi1oGPiKpVVEmk+oGvzJYLsgxCzd7bPkxN7aos3y/VKh7+tAlfnAOKTajpOTAz5cvLXcGJQHiiQ==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -20,7 +20,7 @@"express": "^4.19.2","iovalkey": "^0.4.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.173.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.175.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3","pg": "^8.23.0",@@@ -166,6 +166,34 @@ async function enqueue(eventType, appId, subject, data, priority) {return env;}++/**++ * Queue one closed hour of a zone's usage (media.usage.recorded, common.usage-recorded@1; server/s3/usage.js) inside the++ * transaction that marks it sent. Unlike outcomes, sandbox zones count too: the rollup says its env, and only the++ * project's usage views (Network, Zone) read it. Subject: the project.++ */++async function recordUsage(payload) {++ if (!outbox) return null;++ const env = await outbox.enqueue(inTx(), {++ event_type: 'media.usage.recorded',++ actor: { type: 'service', id: 'media' },++ subject: { type: 'project', id: payload.project_id },++ visibility: 'internal',++ priority: 'low',++ payload,++ });++ stats.queued++;++ return env;++}++++/** Does a rollup match media.usage.recorded@1 and name a resource of its own project? { valid, errors } */++function checkUsage(payload) {++ const contracts = require('openvibe-contracts');++ const v = contracts.validate('media.usage.recorded@1', payload);++ if (!v.valid) return { valid: false, errors: v.errors.map((e) => `${e.path || '/'} ${e.message}`).join('; ') };++ const c = contracts.usage && contracts.usage.checkUsageRecorded ? contracts.usage.checkUsageRecorded(payload) : { valid: true, errors: [] };++ return c.valid ? { valid: true, errors: '' } : { valid: false, errors: c.errors.map((e) => `${e.path || '/'} ${e.message}`).join('; ') };++}++/*** Queue a Search document or tombstone (media.index_document.*, ./public/search-documents.js) as is:* no tenant field, low priority. Same rule as record(): inside the transaction that records the push.@@@ -285,4 +313,4 @@ function _reset() {stats.queued = 0; stats.lastError = null;}--module.exports = { init, initWriter, record, recordPlacement, recordIndexDocument, recordModeration, recordJob, recordObjectChanges, drainObjectChanges, discardObjectChanges, objectChangeEvent, kick, status, TYPES, PLACEMENT_TYPES, JOB_TRANSITIONS, _reset };++module.exports = { init, initWriter, record, recordPlacement, recordIndexDocument, recordModeration, recordJob, recordUsage, checkUsage, recordObjectChanges, drainObjectChanges, discardObjectChanges, objectChangeEvent, kick, status, TYPES, PLACEMENT_TYPES, JOB_TRANSITIONS, _reset };@@@ -296,6 +296,15 @@ const ready = (async () => {every(60 * 1000, async () => {try { const r = await require('./s3/zones').sweepDeleting(); if (r.tombstoned || r.purged) console.log(`[Zones] ${r.zones} deleting zone(s): ${r.tombstoned} tombstoned, ${r.purged} purged`); } catch (err) { console.warn('[Zones] deletion sweep:', err.message); }});++ // Per-zone usage on the S3 surface (ADR-031 section 8): counts flushed every 30 s, stored bytes sampled every++ // minute, and every closed hour sent as media.usage.recorded (server/s3/usage.js).++ const s3usage = require('./s3/usage');++ every(30 * 1000, async () => { try { await s3usage.flush(); } catch (err) { console.warn('[S3] usage flush:', err.message); } });++ every(60 * 1000, async () => { try { await s3usage.sample(); } catch (err) { console.warn('[S3] usage sample:', err.message); } });++ every(5 * 60 * 1000, async () => {++ try { const n = await s3usage.emit(); if (n) console.log(`[S3] ${n} usage rollup(s) sent`); } catch (err) { console.warn('[S3] usage rollups:', err.message); }++ try { await s3usage.prune(); } catch (err) { console.warn('[S3] usage prune:', err.message); }++ });every(60 * 60 * 1000, () => thumbService.cleanupOldThumbnails()); // stale live thumbs// Object model: purge native objects whose soft-delete retention has passed (held ones are kept).every(60 * 60 * 1000, async () => {@@@ -389,6 +398,7 @@ const ready = (async () => {() => require('./events')._reset(),() => { const v = require('./actor-limits').valkey(); if (v) v.close().catch(() => {}); },() => { for (const t of timers) clearInterval(t); },++ () => require('./s3/usage').flush().catch((err) => console.warn('[S3] usage flush at stop:', err.message)),],close: [() => db.close()],deadlineMs: 70000,@@@ -43,6 +43,7 @@ const model = require('../objects/model');const namespaces = require('../objects/namespaces');const sigv4 = require('./sigv4');const keys = require('./keys');++const usage = require('./usage');const zones = require('./zones');const mp = require('./multipart');@@@ -83,6 +84,7 @@ function xml(res, status, body) {function s3error(req, res, code, message, extra = '') {const status = STATUS[code] || 400;++ res.locals.s3code = code; // the zone's usage counts refusals by code (server/s3/usage.js)baseHeaders(res);if (req.method === 'HEAD') return res.status(status).end();xml(res, status, `<Error><Code>${esc(code)}</Code><Message>${esc(message)}</Message><Resource>${esc(req.originalUrl.split('?')[0])}</Resource>`@@@ -129,6 +131,7 @@ async function zoneFor(req, res, bucket) {const zone = await zones.byName(key.project_id, key.env, bucket);if (!zone || zone.state !== 'active') { s3error(req, res, 'NoSuchBucket', 'The specified bucket does not exist'); return null; }if (key.zones.length && !key.zones.includes(zone.id)) { s3error(req, res, 'AccessDenied', 'This access key is not scoped to this bucket'); return null; }++ req.s3.zone = zone; // from here the request counts in the zone's usagereturn zone;}@@@ -668,6 +671,8 @@ router.use(async (req, res) => {const slash = p.indexOf('/', 1);const bucket = decodeURIComponent(slash < 0 ? p.slice(1) : p.slice(1, slash));const rest = slash < 0 ? '' : p.slice(slash + 1);++ req.s3.hasKey = !!rest;++ usage.track(req, res);const q = req.query;if (!bucket) {if (req.method === 'GET') return await listBuckets(req, res);nnew file mode 100644@@@ -0,0 +1,193 @@++'use strict';++/**++ * Per-zone usage on Media's S3 surface (ADR-031 section 8; openvibe-contracts media.usage.recorded@1, which is++ * common.usage-recorded@1). Media is the only counter: per zone and closed UTC hour it stores one rollup per series as++ * its own event, and Network (the project's usage) and Zone (its usage view) add them up.++ *++ * track(req, res) a request that reached a zone (req.s3.zone): counted when its answer finishes++ * sample() each active zone's live bytes for this minute, added as byte_hours (bytes / 60 a minute)++ * flush() the counts kept in memory are added to media_s3_usage (every 30 s, and at shutdown)++ * emit() every closed hour not sent yet, or corrected since (revision), goes to the outbox as++ * media.usage.recorded, in the transaction that marks it sent++ *++ * The series (capability, unit): stored bytes (media.object.upload, byte_hours); writes (media.object.upload and++ * media.object.delete, requests); lists (media.object.list, requests); reads (media.object.read, requests and++ * egress_bytes). A refused or failed request counts in `errors`, by its S3 code as media.s3.<snake_case>. Each rollup++ * names its zone twice: resource is the zone's OVRN and dimension its id in lowercase. Nothing here names who sent a++ * request, or a key, an object key or an address: rollups are counts, never money (Billing never reads them).++ */++const db = require('../db/database');++const events = require('../events');++++const HOUR_MS = 3600 * 1000;++const MAX_CODES = 20;++const MAX_SAMPLES = 10;++const hourOf = (ms) => Math.floor(ms / HOUR_MS) * HOUR_MS;++const parse = (v, d) => { if (v && typeof v === 'object') return v; try { return JSON.parse(v); } catch { return d; } };++const snake = (code) => String(code).replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[^A-Za-z0-9]+/g, '_').toLowerCase();++++let pending = new Map(); // zone|window|capability|unit → { zone, window, capability, unit, quantity, errors, codes, samples }++++function bucket(zone, capability, unit, now) {++ const window = hourOf(now);++ const k = `${zone.id}|${window}|${capability}|${unit}`;++ let b = pending.get(k);++ if (!b) {++ b = { zone: { id: zone.id, project_id: zone.project_id, env: zone.env }, window, capability, unit, quantity: 0, errors: 0, codes: {}, samples: [] };++ pending.set(k, b);++ }++ return b;++}++++/** Add to one series of a zone now: quantity units, and an error when code is set. */++function count(zone, capability, unit, quantity, { code = null, status = null, now = Date.now() } = {}) {++ if (!zone || !zone.id) return;++ const b = bucket(zone, capability, unit, now);++ b.quantity += Math.max(0, Math.round(quantity || 0));++ if (code) {++ b.errors++;++ const c = `media.s3.${snake(code)}`;++ b.codes[c] = (b.codes[c] || 0) + 1;++ b.samples.unshift({ at: new Date(now).toISOString(), code: c, ...(status ? { status } : {}) });++ b.samples.length = Math.min(b.samples.length, MAX_SAMPLES);++ }++}++++/** The capability a request to a zone counts against, from its method and query (hasKey: an object, not the bucket). */++function capabilityOf(req, hasKey) {++ const m = req.method;++ const q = req.query || {};++ if (!hasKey) {++ if (m === 'POST' && q.delete !== undefined) return 'media.object.delete';++ if ((m === 'GET' && q.location === undefined) || m === 'HEAD') return 'media.object.list';++ return null;++ }++ if (m === 'PUT' || m === 'POST') return 'media.object.upload';++ if (m === 'DELETE') return 'media.object.delete';++ if (m === 'GET' && q.uploadId !== undefined) return 'media.object.list';++ if (m === 'GET' || m === 'HEAD') return 'media.object.read';++ return null;++}++++/** Count a request to a zone when its answer is sent (req.s3.zone and req.s3.hasKey are set by the dispatcher). */++function track(req, res) {++ res.once('finish', () => {++ const s3 = req.s3;++ if (!s3 || !s3.zone) return;++ const capability = capabilityOf(req, s3.hasKey);++ if (!capability) return;++ const failed = res.statusCode >= 400;++ const code = failed ? (res.locals.s3code || `http_${res.statusCode}`) : null;++ count(s3.zone, capability, 'requests', 1, { code, status: failed ? res.statusCode : null });++ if (capability === 'media.object.read' && req.method === 'GET' && !failed) {++ count(s3.zone, capability, 'egress_bytes', Number(res.getHeader('content-length')) || 0);++ }++ });++}++++/** Each active zone's live bytes, as one minute of byte_hours (bytes / 60). */++async function sample({ now = Date.now() } = {}) {++ const zones = require('./zones');++ const rows = await db.all("SELECT * FROM media_zones WHERE state = 'active'");++ for (const z of rows) {++ const st = await zones.status(z, { now });++ if (st.stored_bytes > 0) count(z, 'media.object.upload', 'byte_hours', st.stored_bytes / 60, { now });++ }++ return rows.length;++}++++/** Add what is kept in memory to media_s3_usage. A window already sent gets a new revision (it is re-sent). */++async function flush() {++ if (!pending.size) return 0;++ const batch = [...pending.values()];++ pending = new Map();++ try {++ await db.getDb().tx(async () => {++ for (const b of batch) {++ const cur = await db.get(`SELECT * FROM media_s3_usage WHERE zone_id = ? AND window_start = ? AND capability = ? AND unit = ?`,++ [b.zone.id, b.window, b.capability, b.unit]);++ if (!cur) {++ await db.run(`INSERT INTO media_s3_usage (zone_id, project_id, env, window_start, capability, unit, quantity, errors, error_codes, samples, revision, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`,++ [b.zone.id, b.zone.project_id, b.zone.env, b.window, b.capability, b.unit, b.quantity, b.errors,++ JSON.stringify(topCodes(b.codes)), JSON.stringify(b.samples), Date.now()]);++ continue;++ }++ const codes = parse(cur.error_codes, {});++ for (const [c, n] of Object.entries(b.codes)) codes[c] = (codes[c] || 0) + n;++ const samples = [...b.samples, ...parse(cur.samples, [])].slice(0, MAX_SAMPLES);++ const sent = cur.sent_revision != null && Number(cur.sent_revision) >= Number(cur.revision);++ await db.run(`UPDATE media_s3_usage SET quantity = quantity + ?, errors = errors + ?, error_codes = ?, samples = ?,++ revision = revision + ?, updated_at = ? WHERE zone_id = ? AND window_start = ? AND capability = ? AND unit = ?`,++ [b.quantity, b.errors, JSON.stringify(topCodes(codes)), JSON.stringify(samples), sent ? 1 : 0, Date.now(),++ b.zone.id, b.window, b.capability, b.unit]);++ }++ });++ } catch (err) {++ // Put the counts back: the next flush tries again.++ for (const b of batch) {++ const t = bucket(b.zone, b.capability, b.unit, b.window);++ t.quantity += b.quantity;++ t.errors += b.errors;++ for (const [c, n] of Object.entries(b.codes)) t.codes[c] = (t.codes[c] || 0) + n;++ t.samples = [...b.samples, ...t.samples].slice(0, MAX_SAMPLES);++ }++ throw err;++ }++ return batch.length;++}++++function topCodes(codes) {++ return Object.fromEntries(Object.entries(codes).sort((a, b) => b[1] - a[1]).slice(0, MAX_CODES));++}++++/** The rollup a stored row becomes (common.usage-recorded@1). */++function rollupOf(row) {++ const codes = parse(row.error_codes, {});++ const samples = parse(row.samples, []);++ const start = Number(row.window_start);++ const out = {++ project_id: row.project_id, env: row.env, capability: row.capability,++ resource: `ovrn:zone:${row.project_id}:object-zone/${row.zone_id}`, dimension: String(row.zone_id).toLowerCase(),++ unit: row.unit, window: 'hour', window_start: new Date(start).toISOString(), window_end: new Date(start + HOUR_MS).toISOString(),++ quantity: Number(row.quantity), errors: Number(row.errors),++ };++ if (Object.keys(codes).length) out.error_codes = codes;++ if (samples.length) out.samples = samples;++ if (Number(row.revision) > 1) out.revision = Number(row.revision);++ return out;++}++++/** Store every closed hour not sent yet (or corrected since) as media.usage.recorded. */++async function emit({ now = Date.now(), limit = 500 } = {}) {++ const rows = await db.all(`SELECT * FROM media_s3_usage WHERE window_start < ? AND (sent_revision IS NULL OR sent_revision < revision)++ ORDER BY window_start, zone_id, capability, unit LIMIT ?`, [hourOf(now), limit]);++ let sent = 0;++ for (const row of rows) {++ const rollup = rollupOf(row);++ const v = events.checkUsage(rollup);++ if (!v.valid) {++ console.error(`[S3] usage rollup ${row.zone_id} ${rollup.window_start} ${row.capability}/${row.unit} does not match media.usage.recorded@1 and stays unsent: ${v.errors}`);++ continue;++ }++ await db.getDb().tx(async () => {++ const env = await events.recordUsage(rollup);++ await db.run(`UPDATE media_s3_usage SET sent_revision = ? WHERE zone_id = ? AND window_start = ? AND capability = ? AND unit = ?`,++ [Number(row.revision), row.zone_id, row.window_start, row.capability, row.unit]);++ if (env) sent++;++ });++ }++ return sent;++}++++/** Rows older than keepDays (the events carry them on; Zone and Network keep their own sums). */++async function prune({ now = Date.now(), keepDays = 40 } = {}) {++ const r = await db.run('DELETE FROM media_s3_usage WHERE window_start < ? AND sent_revision IS NOT NULL AND sent_revision >= revision',++ [now - keepDays * 24 * HOUR_MS]);++ return r && r.changes ? r.changes : 0;++}++++function _reset() { pending = new Map(); }++function _pending() { return pending; }++++module.exports = { track, count, capabilityOf, sample, flush, emit, prune, rollupOf, snake, _reset, _pending };nnew file mode 100644@@@ -0,0 +1,167 @@++'use strict';++// ADR-031 section 8: Media counts each zone's use of the S3 surface and sends every closed hour as media.usage.recorded++// (common.usage-recorded@1): writes, lists, reads and egress as they are answered, stored bytes as byte_hours sampled++// each minute, refusals by code. Late counts raise a sent hour's revision and it is sent again.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const crypto = require('crypto');++++(async () => {++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-s3u-'));++ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };++ process.env.VOD_PATH = dir('vods');++ process.env.FILES_PATH = dir('files');++ process.env.OBJECTS_PATH = dir('objects');++ process.env.THUMBNAILS_PATH = dir('thumbnails');++ process.env.OV_NETWORK_URL = 'https://openvibe.network';++ process.env.MEDIA_PUBLIC_URL = 'https://media.test';++ process.env.MEDIA_SIGNING_SECRET = 'test-signing-secret';++ process.env.MEDIA_S3_KEY_SECRET = 'k'.repeat(40); // a test value: 32+ characters++ process.env.MEDIA_S3_HOSTS = '127.0.0.1';++++ const db = require('../server/db/database');++ const auth = require('../server/auth');++ const events = require('../server/events');++ const usage = require('../server/s3/usage');++ const { serviceAuth, zones: zoneContracts, ids, validate } = require('openvibe-contracts');++ const express = require('express');++ const S3 = require('@aws-sdk/client-s3');++++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++ auth._setNetworkPublicKeyForTests(keys.publicKey);++ const now = Math.floor(Date.now() / 1000);++ const svc = (sub, cap) => serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub, actor_type: 'service', aud: ['openvibe.media'], cap, iat: now, exp: now + 600, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey);++ const ZONE_TOKEN = svc('svc:zone', ['media.zone.provision']);++ const SERVICES_TOKEN = svc('svc:services', ['media.access_key.manage']);++ const ADMIN = `usr_${ids.ulid()}`;++ const PRJ = `prj_${ids.ulid()}`;++++ const s3router = require('../server/s3/routes');++ const admin = require('../server/s3/admin-routes');++ const app = express();++ app.use((req, res, next) => (s3router.isS3Host(req) ? s3router(req, res, next) : next()));++ app.use('/internal/zones', admin.internalRouter());++ app.use('/api/v1/projects/:project/access-keys', admin.keysRouter({ projects: { isAdmin: async (p, s) => s === ADMIN && p === PRJ } }));++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const port = server.address().port;++ const internal = async (method, p, { token, body } = {}) => {++ const res = await fetch(`http://localhost:${port}${p}`, { method, headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });++ return { status: res.status, body: await res.json().catch(() => null) };++ };++ const zoneRecord = (env, name) => {++ const z = { id: `zon_${ids.ulid()}`, project_id: PRJ, env, name, default: name === 'default', access: name === 'default' ? 'per-object' : 'private', state: 'active',++ quota_bytes: null, revision: 1, created_at: new Date().toISOString() };++ return Object.assign(z, zoneContracts.identity(z));++ };++ const rows = async (zoneId) => db.all('SELECT * FROM media_s3_usage WHERE zone_id = ? ORDER BY capability, unit', [zoneId]);++ const row = (list, capability, unit) => list.find((r) => r.capability === capability && r.unit === unit);++++ // What the outbox would store: the rollups, as recordUsage receives them inside its transaction.++ const sent = [];++ events.recordUsage = async (payload) => { sent.push(payload); return { event_id: `evt_${ids.ulid()}` }; };++++ try {++ const assets = zoneRecord('production', 'assets');++ const scratch = zoneRecord('sandbox', 'scratch');++ for (const z of [zoneRecord('production', 'default'), assets, zoneRecord('sandbox', 'default'), scratch]) {++ assert.strictEqual((await internal('PUT', `/internal/zones/${z.id}`, { token: ZONE_TOKEN, body: z })).status, 200);++ }++ const issue = async (env) => (await internal('POST', `/api/v1/projects/${PRJ}/access-keys`, { token: SERVICES_TOKEN, body: { on_behalf_of: ADMIN, env, name: env } })).body;++ const k = await issue('production');++ const ks = await issue('sandbox');++ const client = (key) => new S3.S3Client({ endpoint: `http://127.0.0.1:${port}`, region: 'us-east-1', forcePathStyle: true, credentials: { accessKeyId: key.key.id, secretAccessKey: key.secret } });++ const s3 = client(k);++++ // ── Requests count as they are answered ──++ const body = crypto.randomBytes(3000);++ await s3.send(new S3.PutObjectCommand({ Bucket: 'assets', Key: 'a.bin', Body: body }));++ await s3.send(new S3.PutObjectCommand({ Bucket: 'assets', Key: 'b.txt', Body: 'hello' }));++ const got = await s3.send(new S3.GetObjectCommand({ Bucket: 'assets', Key: 'a.bin' }));++ assert.strictEqual(Buffer.from(await got.Body.transformToByteArray()).length, 3000);++ await s3.send(new S3.HeadObjectCommand({ Bucket: 'assets', Key: 'b.txt' }));++ await assert.rejects(s3.send(new S3.GetObjectCommand({ Bucket: 'assets', Key: 'missing' })), /NoSuchKey/);++ await s3.send(new S3.ListObjectsV2Command({ Bucket: 'assets' }));++ await s3.send(new S3.DeleteObjectCommand({ Bucket: 'assets', Key: 'b.txt' }));++ await assert.rejects(s3.send(new S3.GetObjectCommand({ Bucket: 'nope', Key: 'x' })), /NoSuchBucket/);++ await client(ks).send(new S3.PutObjectCommand({ Bucket: 'scratch', Key: 's.txt', Body: 'sandbox' }));++ await new Promise((r) => setImmediate(r));++ assert.ok(usage._pending().size > 0, 'counted in memory first');++ await usage.flush();++ assert.strictEqual(usage._pending().size, 0, 'and flushed');++ let list = await rows(assets.id);++ assert.strictEqual(Number(row(list, 'media.object.upload', 'requests').quantity), 2, 'two writes');++ const reads = row(list, 'media.object.read', 'requests');++ assert.deepStrictEqual([Number(reads.quantity), Number(reads.errors)], [3, 1], 'three reads (GET, HEAD, a missing key), one refused');++ assert.deepStrictEqual(JSON.parse(reads.error_codes), { 'media.s3.no_such_key': 1 }, 'refusals by code');++ assert.strictEqual(JSON.parse(reads.samples)[0].status, 404);++ assert.strictEqual(Number(row(list, 'media.object.read', 'egress_bytes').quantity), 3000, 'egress: the bytes of the GET that succeeded');++ assert.strictEqual(Number(row(list, 'media.object.list', 'requests').quantity), 1);++ assert.strictEqual(Number(row(list, 'media.object.delete', 'requests').quantity), 1);++ assert.ok(!(await db.all("SELECT * FROM media_s3_usage WHERE capability LIKE '%' AND zone_id NOT IN (?, ?)", [assets.id, scratch.id])).length,++ 'a request that names no zone of the key (NoSuchBucket) counts nowhere');++ assert.strictEqual(Number(row(await rows(scratch.id), 'media.object.upload', 'requests').quantity), 1, 'the sandbox zone counts on its own');++ console.log('✅ writes, reads, lists, deletes and egress counted per zone as they are answered; refusals by code');++++ // ── Stored bytes: one minute of byte_hours per sample ──++ await usage.sample();++ await usage.flush();++ list = await rows(assets.id);++ assert.strictEqual(Number(row(list, 'media.object.upload', 'byte_hours').quantity), Math.round(3000 / 60), 'live bytes / 60 a minute');++ console.log('✅ stored bytes sampled each minute as byte_hours');++++ // ── Closed hours go out once, as media.usage.recorded ──++ assert.strictEqual(await usage.emit(), 0, 'the current hour is not closed');++ const later = Date.now() + 3600 * 1000;++ const n = await usage.emit({ now: later });++ assert.ok(n >= 8, `every series of both zones (${n})`);++ for (const p of sent) {++ const v = validate('media.usage.recorded@1', p);++ assert.ok(v.valid, JSON.stringify(v.errors));++ assert.ok(events.checkUsage(p).valid, 'and its resource is a zone of its project');++ }++ const readRollup = sent.find((p) => p.dimension === assets.id.toLowerCase() && p.capability === 'media.object.read' && p.unit === 'requests');++ assert.strictEqual(readRollup.resource, `ovrn:zone:${PRJ}:object-zone/${assets.id}`, 'the zone named twice: its OVRN');++ assert.deepStrictEqual([readRollup.quantity, readRollup.errors, readRollup.window, readRollup.env], [3, 1, 'hour', 'production']);++ assert.ok(sent.some((p) => p.env === 'sandbox' && p.dimension === scratch.id.toLowerCase()), 'sandbox zones are rolled up too');++ assert.ok(!sent.some((p) => /usr_|ovk_|a\.bin|127\.0\.0\.1/.test(JSON.stringify(p))), 'no subject, key, object key or address in a rollup');++ const before = sent.length;++ assert.strictEqual(await usage.emit({ now: later }), 0, 'a sent hour is not sent again');++ assert.strictEqual(sent.length, before);++ console.log('✅ each closed hour sent once as media.usage.recorded (valid, OVRN and dimension, sandbox included, no names)');++++ // ── A late count corrects the hour: revision 2 ──++ const window = Number(readRollup.window_start ? Date.parse(readRollup.window_start) : 0);++ usage.count({ id: assets.id, project_id: PRJ, env: 'production' }, 'media.object.read', 'requests', 1, { now: window + 1000 });++ await usage.flush();++ assert.strictEqual(await usage.emit({ now: later }), 1, 'the corrected hour goes out again');++ const fixed = sent[sent.length - 1];++ assert.deepStrictEqual([fixed.quantity, fixed.revision, fixed.capability], [4, 2, 'media.object.read'], 'with its new total and revision');++ console.log('✅ late counts raise the revision and the hour is re-sent with its total');++++ // ── An invalid rollup stays unsent ──++ await db.run(`INSERT INTO media_s3_usage (zone_id, project_id, env, window_start, capability, unit, quantity, errors, revision, updated_at)++ VALUES (?, ?, 'production', ?, 'not a capability', 'requests', 1, 0, 1, ?)`, [assets.id, PRJ, window, Date.now()]);++ const quiet = console.error;++ const logged = [];++ console.error = (...a) => logged.push(a.join(' '));++ try { await usage.emit({ now: later }); } finally { console.error = quiet; }++ assert.ok(logged.some((l) => /stays unsent/.test(l)), 'it is logged');++ assert.ok((await db.get("SELECT sent_revision FROM media_s3_usage WHERE capability = 'not a capability'")).sent_revision == null, 'and not marked sent');++ await db.run("DELETE FROM media_s3_usage WHERE capability = 'not a capability'");++ console.log('✅ a rollup that does not match the contract stays unsent');++++ // ── Prune: sent rows past the retention go ──++ assert.strictEqual(await usage.prune({ now: later + 41 * 24 * 3600 * 1000 }) > 0, true);++ assert.strictEqual((await rows(assets.id)).length, 0);++ console.log('✅ sent rows are pruned after 40 days');++ } finally {++ server.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++ console.log('s3 usage: all checks passed');++ process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });
@@ -45,7 +45,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pg
@@ -45,7 +45,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pg
export and deletion), OpenVibe.Live (`live.lineage.resolve`), OpenVibe.Events (the outbox relay and
export and deletion), OpenVibe.Live (`live.lineage.resolve`), OpenVibe.Events (the outbox relay and
the account and revocation subscriptions)
the account and revocation subscriptions)
- Backblaze B2 and Cloudflare R2 (S3 API) when configured; `ffmpeg`/`ffprobe` on the host
- Backblaze B2 and Cloudflare R2 (S3 API) when configured; `ffmpeg`/`ffprobe` on the host
-- `openvibe-contracts` v0.143.0, `openvibe-sdk` v0.44.0 (tokens, events outbox, usage readings, per-actor limits),
+- `openvibe-contracts` v0.175.0, `openvibe-sdk` v0.44.0 (tokens, events outbox, usage readings, per-actor limits),
`openvibe-shared` v4.1.3, pinned by release tarball
`openvibe-shared` v4.1.3, pinned by release tarball
## Capabilities
## Capabilities
@@ -129,7 +129,7 @@ server/
@@ -129,7 +129,7 @@ server/
storage.orphans.scan (monthly storage orphan report, service-wide, report only),
storage.orphans.scan (monthly storage orphan report, service-wide, report only),
storage.move.cleanup (the alert for placement moves whose delete keeps failing, service-wide)
storage.move.cleanup (the alert for placement moves whose delete keeps failing, service-wide)
client-ip.js trust proxy = loopback; req.ip is the only client address
client-ip.js trust proxy = loopback; req.ip is the only client address
-(openvibe-shared v4.1.3, openvibe-contracts v0.173.0, openvibe-sdk v0.44.0: pinned release tarballs, installed by npm)
+(openvibe-shared v4.1.3, openvibe-contracts v0.175.0, openvibe-sdk v0.44.0: pinned release tarballs, installed by npm)
scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)
scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)
scripts/reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator tools
scripts/reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator tools
scripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposals
scripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposals
@@ -749,7 +749,7 @@ Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not
@@ -749,7 +749,7 @@ Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not
restart; afterwards `sudo ovhost rollback media --to <sha>`.
restart; afterwards `sudo ovhost rollback media --to <sha>`.
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.143.0
+- openvibe-contracts: v0.175.0
- openvibe-sdk: v0.44.0
- openvibe-sdk: v0.44.0
- openvibe-shared: v4.1.3
- openvibe-shared: v4.1.3
<!-- versions:end -->
<!-- versions:end -->
@@ -13,6 +13,7 @@ a named object zone of one project environment. Zones are made and deleted only
@@ -13,6 +13,7 @@ a named object zone of one project environment. Zones are made and deleted only
| Access keys | `server/s3/keys.js` | `ovk_` keys for one project environment: read, write or both, and optionally limited to some zones. Secrets are encrypted with `MEDIA_S3_KEY_SECRET` and shown once. |
| Access keys | `server/s3/keys.js` | `ovk_` keys for one project environment: read, write or both, and optionally limited to some zones. Secrets are encrypted with `MEDIA_S3_KEY_SECRET` and shown once. |
| Zones | `server/s3/zones.js` | Media's copy of Zone's records. It resolves bucket names, gives each zone's namespace its quota, and runs zone deletion. |
| Zones | `server/s3/zones.js` | Media's copy of Zone's records. It resolves bucket names, gives each zone's namespace its quota, and runs zone deletion. |
| S3 routes | `server/s3/routes.js` | The operations below, dispatched by `Host` (`MEDIA_S3_HOSTS`). |
| S3 routes | `server/s3/routes.js` | The operations below, dispatched by `Host` (`MEDIA_S3_HOSTS`). |
+| Usage | `server/s3/usage.js` | Counts each zone's requests, egress and stored bytes, and sends every closed hour as `media.usage.recorded` (see "Usage" below). |
| Control doors | `server/s3/admin-routes.js` | `PUT/GET /internal/zones/:id` (Zone only, `media.zone.provision`) and `/api/v1/projects/:project/access-keys` (`media.access_key.manage`, on behalf of a project admin). |
| Control doors | `server/s3/admin-routes.js` | `PUT/GET /internal/zones/:id` (Zone only, `media.zone.provision`) and `/api/v1/projects/:project/access-keys` (`media.access_key.manage`, on behalf of a project admin). |
## Operations
## Operations
@@ -39,10 +40,35 @@ a named object zone of one project environment. Zones are made and deleted only
@@ -39,10 +40,35 @@ a named object zone of one project environment. Zones are made and deleted only
- **HEAD through a proxy.** Cloudflare forwards a HEAD as a GET for paths it may cache (by extension: `.jpg`, `.bin`, `.zip` …), but SigV4 signs the method. A header-signed request with no body that arrives as GET and verifies only as HEAD is served as the GET, and the proxy returns just the headers. A presigned URL never gets this, because one may be shared for a HEAD alone; nor does any other method. Every S3 response is `Cache-Control: private, no-store`, so the edge never caches one (`cf-cache-status: BYPASS`). The fix at the edge itself is a DNS-only `s3.openvibe.media`.
- **HEAD through a proxy.** Cloudflare forwards a HEAD as a GET for paths it may cache (by extension: `.jpg`, `.bin`, `.zip` …), but SigV4 signs the method. A header-signed request with no body that arrives as GET and verifies only as HEAD is served as the GET, and the proxy returns just the headers. A presigned URL never gets this, because one may be shared for a HEAD alone; nor does any other method. Every S3 response is `Cache-Control: private, no-store`, so the edge never caches one (`cf-cache-status: BYPASS`). The fix at the edge itself is a DNS-only `s3.openvibe.media`.
- **No browser execution.** Every response carries `Content-Security-Policy: sandbox; default-src 'none'` and `nosniff`, whatever type the object declares.
- **No browser execution.** Every response carries `Content-Security-Policy: sandbox; default-src 'none'` and `nosniff`, whatever type the object declares.
+## Usage
+
+Media is the only counter (ADR-031 section 8). Every request that reaches a zone counts against it when its answer is
+sent. Every closed UTC hour goes out as one `media.usage.recorded` event per series (`common.usage-recorded@1`):
+
+| Series | Capability | Unit |
+|---|---|---|
+| Stored bytes | `media.object.upload` | `byte_hours`: the zone's live bytes sampled each minute, a sixtieth each time |
+| Writes (PUT, multipart, POST) | `media.object.upload` | `requests` |
+| Deletes (DELETE, DeleteObjects, abort) | `media.object.delete` | `requests` |
+| Lists (ListObjects, ListParts, ListMultipartUploads, HeadBucket) | `media.object.list` | `requests` |
+| Reads (GET, HEAD) | `media.object.read` | `requests`, and `egress_bytes` for the GETs answered |
+
+Each rollup names its zone twice: `resource` is the zone's OVRN and `dimension` its id in lowercase. A refused request
+counts in `errors`, by its S3 code (`media.s3.no_such_key`, …), with the last ten failures as samples. A request that
+names no zone of its key (`NoSuchBucket`, a bad signature) counts nowhere.
+
+- **Storage:** counts are kept in memory and added to `media_s3_usage` every 30 s, and at shutdown.
+- **Sending:** every 5 minutes, the closed hours go to the outbox, in the transaction that marks them sent. Counts that land after an hour was sent raise its `revision`, and it is sent again with its new total.
+- **Validation:** a rollup that does not match the contract stays unsent and is logged.
+- **Retention:** sent rows go after 40 days.
+- **Sandbox zones** are rolled up too: the rollup says its `env`.
+- **Who reads them:** Network adds the rollups to the project's usage and Zone sums them in its usage view. They are counts, never money.
+
## Tests
## Tests
- `test/s3-zones.test.js` runs end to end with the AWS SDK for JavaScript v3: every operation, presigned URLs, isolation, quotas, holds, two doors and zone deletion.
- `test/s3-zones.test.js` runs end to end with the AWS SDK for JavaScript v3: every operation, presigned URLs, isolation, quotas, holds, two doors and zone deletion.
- `test/s3-multipart.test.js` uses the SDK's `Upload` helper (parallel parts) and the raw commands: S3's rules, ETags, abort, quota and expiry.
- `test/s3-multipart.test.js` uses the SDK's `Upload` helper (parallel parts) and the raw commands: S3's rules, ETags, abort, quota and expiry.
+- `test/s3-usage.test.js` counts every series through the SDK, then checks the rollups against the contract, their revisions and the pruning.
- `test/s3-sigv4.test.js` checks the worked examples of the S3 API reference, including the chunk signature chain.
- `test/s3-sigv4.test.js` checks the worked examples of the S3 API reference, including the chunk signature chain.
## Using it
## Using it
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+-- phase: expand
+-- Per-zone usage on the S3 surface (ADR-031 section 8, server/s3/usage.js): the counts of one zone's series in one UTC
+-- hour, added to every 30 s from memory. A closed hour is sent as media.usage.recorded (common.usage-recorded@1) and
+-- marked with the revision it was sent at; counts that land after it raise the revision, and the hour is sent again.
+-- Counts only: no key, object key, address or subject is kept.
+CREATE TABLE media_s3_usage (
+ zone_id text COLLATE "C" NOT NULL,
+ project_id text COLLATE "C" NOT NULL,
+ env text COLLATE "C" NOT NULL,
+ window_start bigint NOT NULL, -- ms since the epoch, on the hour (UTC)
+ capability text COLLATE "C" NOT NULL, -- media.object.upload | read | list | delete
+ unit text COLLATE "C" NOT NULL, -- requests | egress_bytes | byte_hours
+ quantity bigint NOT NULL DEFAULT 0,
+ errors bigint NOT NULL DEFAULT 0,
+ error_codes text NOT NULL DEFAULT '{}', -- { "media.s3.<code>": n }, the 20 largest
+ samples text NOT NULL DEFAULT '[]', -- the last 10 failures: { at, code, status }
+ revision integer NOT NULL DEFAULT 1,
+ sent_revision integer, -- the revision last sent; NULL: not sent yet
+ updated_at bigint NOT NULL,
+ PRIMARY KEY (zone_id, window_start, capability, unit)
+);
+CREATE INDEX media_s3_usage_unsent ON media_s3_usage (window_start) WHERE sent_revision IS NULL OR sent_revision < revision;
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express": "^4.19.2",
"express": "^4.19.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.173.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.175.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1847,9 +1847,9 @@
@@ -1847,9 +1847,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.173.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.173.0",
- "integrity": "sha512-6zHbU9zghIYDd3PPGtgMTsIgxrWwsoBHO/rvuLW1rghr6kRK6VwCmSMZRcjQbcPODPH1XrUAFuLg/T1K4nOCMQ==",
+ "version": "0.175.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.175.0",
+ "integrity": "sha512-AJNkb/g7mMQDi1oGPiKpVVEmk+oGvzJYLsgxCzd7bPkxN7aos3y/VKh7+tAlfnAOKTajpOTAz5cvLXcGJQHiiQ==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"express": "^4.19.2",
"express": "^4.19.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.173.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.175.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -166,6 +166,34 @@ async function enqueue(eventType, appId, subject, data, priority) {
@@ -166,6 +166,34 @@ async function enqueue(eventType, appId, subject, data, priority) {
return env;
return env;
}
}
+/**
+ * Queue one closed hour of a zone's usage (media.usage.recorded, common.usage-recorded@1; server/s3/usage.js) inside the
+ * transaction that marks it sent. Unlike outcomes, sandbox zones count too: the rollup says its env, and only the
+ * project's usage views (Network, Zone) read it. Subject: the project.
+ */
+async function recordUsage(payload) {
+ if (!outbox) return null;
+ const env = await outbox.enqueue(inTx(), {
+ event_type: 'media.usage.recorded',
+ actor: { type: 'service', id: 'media' },
+ subject: { type: 'project', id: payload.project_id },
+ visibility: 'internal',
+ priority: 'low',
+ payload,
+ });
+ stats.queued++;
+ return env;
+}
+
+/** Does a rollup match media.usage.recorded@1 and name a resource of its own project? { valid, errors } */
+function checkUsage(payload) {
+ const contracts = require('openvibe-contracts');
+ const v = contracts.validate('media.usage.recorded@1', payload);
+ if (!v.valid) return { valid: false, errors: v.errors.map((e) => `${e.path || '/'} ${e.message}`).join('; ') };
+ const c = contracts.usage && contracts.usage.checkUsageRecorded ? contracts.usage.checkUsageRecorded(payload) : { valid: true, errors: [] };
+ return c.valid ? { valid: true, errors: '' } : { valid: false, errors: c.errors.map((e) => `${e.path || '/'} ${e.message}`).join('; ') };
+}
+
/**
/**
* Queue a Search document or tombstone (media.index_document.*, ./public/search-documents.js) as is:
* Queue a Search document or tombstone (media.index_document.*, ./public/search-documents.js) as is:
* no tenant field, low priority. Same rule as record(): inside the transaction that records the push.
* no tenant field, low priority. Same rule as record(): inside the transaction that records the push.
@@ -285,4 +313,4 @@ function _reset() {
@@ -285,4 +313,4 @@ function _reset() {
stats.queued = 0; stats.lastError = null;
stats.queued = 0; stats.lastError = null;
}
}
-module.exports = { init, initWriter, record, recordPlacement, recordIndexDocument, recordModeration, recordJob, recordObjectChanges, drainObjectChanges, discardObjectChanges, objectChangeEvent, kick, status, TYPES, PLACEMENT_TYPES, JOB_TRANSITIONS, _reset };
+module.exports = { init, initWriter, record, recordPlacement, recordIndexDocument, recordModeration, recordJob, recordUsage, checkUsage, recordObjectChanges, drainObjectChanges, discardObjectChanges, objectChangeEvent, kick, status, TYPES, PLACEMENT_TYPES, JOB_TRANSITIONS, _reset };
@@ -296,6 +296,15 @@ const ready = (async () => {
@@ -296,6 +296,15 @@ const ready = (async () => {
every(60 * 1000, async () => {
every(60 * 1000, async () => {
try { const r = await require('./s3/zones').sweepDeleting(); if (r.tombstoned || r.purged) console.log(`[Zones] ${r.zones} deleting zone(s): ${r.tombstoned} tombstoned, ${r.purged} purged`); } catch (err) { console.warn('[Zones] deletion sweep:', err.message); }
try { const r = await require('./s3/zones').sweepDeleting(); if (r.tombstoned || r.purged) console.log(`[Zones] ${r.zones} deleting zone(s): ${r.tombstoned} tombstoned, ${r.purged} purged`); } catch (err) { console.warn('[Zones] deletion sweep:', err.message); }
});
});
+ // Per-zone usage on the S3 surface (ADR-031 section 8): counts flushed every 30 s, stored bytes sampled every
+ // minute, and every closed hour sent as media.usage.recorded (server/s3/usage.js).
+ const s3usage = require('./s3/usage');
+ every(30 * 1000, async () => { try { await s3usage.flush(); } catch (err) { console.warn('[S3] usage flush:', err.message); } });
+ every(60 * 1000, async () => { try { await s3usage.sample(); } catch (err) { console.warn('[S3] usage sample:', err.message); } });
+ every(5 * 60 * 1000, async () => {
+ try { const n = await s3usage.emit(); if (n) console.log(`[S3] ${n} usage rollup(s) sent`); } catch (err) { console.warn('[S3] usage rollups:', err.message); }
+ try { await s3usage.prune(); } catch (err) { console.warn('[S3] usage prune:', err.message); }
+ });
every(60 * 60 * 1000, () => thumbService.cleanupOldThumbnails()); // stale live thumbs
every(60 * 60 * 1000, () => thumbService.cleanupOldThumbnails()); // stale live thumbs
// Object model: purge native objects whose soft-delete retention has passed (held ones are kept).
// Object model: purge native objects whose soft-delete retention has passed (held ones are kept).
every(60 * 60 * 1000, async () => {
every(60 * 60 * 1000, async () => {
@@ -389,6 +398,7 @@ const ready = (async () => {
@@ -389,6 +398,7 @@ const ready = (async () => {
() => require('./events')._reset(),
() => require('./events')._reset(),
() => { const v = require('./actor-limits').valkey(); if (v) v.close().catch(() => {}); },
() => { const v = require('./actor-limits').valkey(); if (v) v.close().catch(() => {}); },
() => { for (const t of timers) clearInterval(t); },
() => { for (const t of timers) clearInterval(t); },
+ () => require('./s3/usage').flush().catch((err) => console.warn('[S3] usage flush at stop:', err.message)),
],
],
close: [() => db.close()],
close: [() => db.close()],
deadlineMs: 70000,
deadlineMs: 70000,
@@ -43,6 +43,7 @@ const model = require('../objects/model');
@@ -43,6 +43,7 @@ const model = require('../objects/model');
const namespaces = require('../objects/namespaces');
const namespaces = require('../objects/namespaces');
const sigv4 = require('./sigv4');
const sigv4 = require('./sigv4');
const keys = require('./keys');
const keys = require('./keys');
+const usage = require('./usage');
const zones = require('./zones');
const zones = require('./zones');
const mp = require('./multipart');
const mp = require('./multipart');
@@ -83,6 +84,7 @@ function xml(res, status, body) {
@@ -83,6 +84,7 @@ function xml(res, status, body) {
function s3error(req, res, code, message, extra = '') {
function s3error(req, res, code, message, extra = '') {
const status = STATUS[code] || 400;
const status = STATUS[code] || 400;
+ res.locals.s3code = code; // the zone's usage counts refusals by code (server/s3/usage.js)
baseHeaders(res);
baseHeaders(res);
if (req.method === 'HEAD') return res.status(status).end();
if (req.method === 'HEAD') return res.status(status).end();
xml(res, status, `<Error><Code>${esc(code)}</Code><Message>${esc(message)}</Message><Resource>${esc(req.originalUrl.split('?')[0])}</Resource>`
xml(res, status, `<Error><Code>${esc(code)}</Code><Message>${esc(message)}</Message><Resource>${esc(req.originalUrl.split('?')[0])}</Resource>`
@@ -129,6 +131,7 @@ async function zoneFor(req, res, bucket) {
@@ -129,6 +131,7 @@ async function zoneFor(req, res, bucket) {
const zone = await zones.byName(key.project_id, key.env, bucket);
const zone = await zones.byName(key.project_id, key.env, bucket);
if (!zone || zone.state !== 'active') { s3error(req, res, 'NoSuchBucket', 'The specified bucket does not exist'); return null; }
if (!zone || zone.state !== 'active') { s3error(req, res, 'NoSuchBucket', 'The specified bucket does not exist'); return null; }
if (key.zones.length && !key.zones.includes(zone.id)) { s3error(req, res, 'AccessDenied', 'This access key is not scoped to this bucket'); return null; }
if (key.zones.length && !key.zones.includes(zone.id)) { s3error(req, res, 'AccessDenied', 'This access key is not scoped to this bucket'); return null; }
+ req.s3.zone = zone; // from here the request counts in the zone's usage
return zone;
return zone;
}
}
@@ -668,6 +671,8 @@ router.use(async (req, res) => {
@@ -668,6 +671,8 @@ router.use(async (req, res) => {
const slash = p.indexOf('/', 1);
const slash = p.indexOf('/', 1);
const bucket = decodeURIComponent(slash < 0 ? p.slice(1) : p.slice(1, slash));
const bucket = decodeURIComponent(slash < 0 ? p.slice(1) : p.slice(1, slash));
const rest = slash < 0 ? '' : p.slice(slash + 1);
const rest = slash < 0 ? '' : p.slice(slash + 1);
+ req.s3.hasKey = !!rest;
+ usage.track(req, res);
const q = req.query;
const q = req.query;
if (!bucket) {
if (!bucket) {
if (req.method === 'GET') return await listBuckets(req, res);
if (req.method === 'GET') return await listBuckets(req, res);
new file mode 100644
new file mode 100644
@@ -0,0 +1,193 @@
@@ -0,0 +1,193 @@
+'use strict';
+/**
+ * Per-zone usage on Media's S3 surface (ADR-031 section 8; openvibe-contracts media.usage.recorded@1, which is
+ * common.usage-recorded@1). Media is the only counter: per zone and closed UTC hour it stores one rollup per series as
+ * its own event, and Network (the project's usage) and Zone (its usage view) add them up.
+ *
+ * track(req, res) a request that reached a zone (req.s3.zone): counted when its answer finishes
+ * sample() each active zone's live bytes for this minute, added as byte_hours (bytes / 60 a minute)
+ * flush() the counts kept in memory are added to media_s3_usage (every 30 s, and at shutdown)
+ * emit() every closed hour not sent yet, or corrected since (revision), goes to the outbox as
+ * media.usage.recorded, in the transaction that marks it sent
+ *
+ * The series (capability, unit): stored bytes (media.object.upload, byte_hours); writes (media.object.upload and
+ * media.object.delete, requests); lists (media.object.list, requests); reads (media.object.read, requests and
+ * egress_bytes). A refused or failed request counts in `errors`, by its S3 code as media.s3.<snake_case>. Each rollup
+ * names its zone twice: resource is the zone's OVRN and dimension its id in lowercase. Nothing here names who sent a
+ * request, or a key, an object key or an address: rollups are counts, never money (Billing never reads them).
+ */
+const db = require('../db/database');
+const events = require('../events');
+
+const HOUR_MS = 3600 * 1000;
+const MAX_CODES = 20;
+const MAX_SAMPLES = 10;
+const hourOf = (ms) => Math.floor(ms / HOUR_MS) * HOUR_MS;
+const parse = (v, d) => { if (v && typeof v === 'object') return v; try { return JSON.parse(v); } catch { return d; } };
+const snake = (code) => String(code).replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[^A-Za-z0-9]+/g, '_').toLowerCase();
+
+let pending = new Map(); // zone|window|capability|unit → { zone, window, capability, unit, quantity, errors, codes, samples }
+
+function bucket(zone, capability, unit, now) {
+ const window = hourOf(now);
+ const k = `${zone.id}|${window}|${capability}|${unit}`;
+ let b = pending.get(k);
+ if (!b) {
+ b = { zone: { id: zone.id, project_id: zone.project_id, env: zone.env }, window, capability, unit, quantity: 0, errors: 0, codes: {}, samples: [] };
+ pending.set(k, b);
+ }
+ return b;
+}
+
+/** Add to one series of a zone now: quantity units, and an error when code is set. */
+function count(zone, capability, unit, quantity, { code = null, status = null, now = Date.now() } = {}) {
+ if (!zone || !zone.id) return;
+ const b = bucket(zone, capability, unit, now);
+ b.quantity += Math.max(0, Math.round(quantity || 0));
+ if (code) {
+ b.errors++;
+ const c = `media.s3.${snake(code)}`;
+ b.codes[c] = (b.codes[c] || 0) + 1;
+ b.samples.unshift({ at: new Date(now).toISOString(), code: c, ...(status ? { status } : {}) });
+ b.samples.length = Math.min(b.samples.length, MAX_SAMPLES);
+ }
+}
+
+/** The capability a request to a zone counts against, from its method and query (hasKey: an object, not the bucket). */
+function capabilityOf(req, hasKey) {
+ const m = req.method;
+ const q = req.query || {};
+ if (!hasKey) {
+ if (m === 'POST' && q.delete !== undefined) return 'media.object.delete';
+ if ((m === 'GET' && q.location === undefined) || m === 'HEAD') return 'media.object.list';
+ return null;
+ }
+ if (m === 'PUT' || m === 'POST') return 'media.object.upload';
+ if (m === 'DELETE') return 'media.object.delete';
+ if (m === 'GET' && q.uploadId !== undefined) return 'media.object.list';
+ if (m === 'GET' || m === 'HEAD') return 'media.object.read';
+ return null;
+}
+
+/** Count a request to a zone when its answer is sent (req.s3.zone and req.s3.hasKey are set by the dispatcher). */
+function track(req, res) {
+ res.once('finish', () => {
+ const s3 = req.s3;
+ if (!s3 || !s3.zone) return;
+ const capability = capabilityOf(req, s3.hasKey);
+ if (!capability) return;
+ const failed = res.statusCode >= 400;
+ const code = failed ? (res.locals.s3code || `http_${res.statusCode}`) : null;
+ count(s3.zone, capability, 'requests', 1, { code, status: failed ? res.statusCode : null });
+ if (capability === 'media.object.read' && req.method === 'GET' && !failed) {
+ count(s3.zone, capability, 'egress_bytes', Number(res.getHeader('content-length')) || 0);
+ }
+ });
+}
+
+/** Each active zone's live bytes, as one minute of byte_hours (bytes / 60). */
+async function sample({ now = Date.now() } = {}) {
+ const zones = require('./zones');
+ const rows = await db.all("SELECT * FROM media_zones WHERE state = 'active'");
+ for (const z of rows) {
+ const st = await zones.status(z, { now });
+ if (st.stored_bytes > 0) count(z, 'media.object.upload', 'byte_hours', st.stored_bytes / 60, { now });
+ }
+ return rows.length;
+}
+
+/** Add what is kept in memory to media_s3_usage. A window already sent gets a new revision (it is re-sent). */
+async function flush() {
+ if (!pending.size) return 0;
+ const batch = [...pending.values()];
+ pending = new Map();
+ try {
+ await db.getDb().tx(async () => {
+ for (const b of batch) {
+ const cur = await db.get(`SELECT * FROM media_s3_usage WHERE zone_id = ? AND window_start = ? AND capability = ? AND unit = ?`,
+ [b.zone.id, b.window, b.capability, b.unit]);
+ if (!cur) {
+ await db.run(`INSERT INTO media_s3_usage (zone_id, project_id, env, window_start, capability, unit, quantity, errors, error_codes, samples, revision, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`,
+ [b.zone.id, b.zone.project_id, b.zone.env, b.window, b.capability, b.unit, b.quantity, b.errors,
+ JSON.stringify(topCodes(b.codes)), JSON.stringify(b.samples), Date.now()]);
+ continue;
+ }
+ const codes = parse(cur.error_codes, {});
+ for (const [c, n] of Object.entries(b.codes)) codes[c] = (codes[c] || 0) + n;
+ const samples = [...b.samples, ...parse(cur.samples, [])].slice(0, MAX_SAMPLES);
+ const sent = cur.sent_revision != null && Number(cur.sent_revision) >= Number(cur.revision);
+ await db.run(`UPDATE media_s3_usage SET quantity = quantity + ?, errors = errors + ?, error_codes = ?, samples = ?,
+ revision = revision + ?, updated_at = ? WHERE zone_id = ? AND window_start = ? AND capability = ? AND unit = ?`,
+ [b.quantity, b.errors, JSON.stringify(topCodes(codes)), JSON.stringify(samples), sent ? 1 : 0, Date.now(),
+ b.zone.id, b.window, b.capability, b.unit]);
+ }
+ });
+ } catch (err) {
+ // Put the counts back: the next flush tries again.
+ for (const b of batch) {
+ const t = bucket(b.zone, b.capability, b.unit, b.window);
+ t.quantity += b.quantity;
+ t.errors += b.errors;
+ for (const [c, n] of Object.entries(b.codes)) t.codes[c] = (t.codes[c] || 0) + n;
+ t.samples = [...b.samples, ...t.samples].slice(0, MAX_SAMPLES);
+ }
+ throw err;
+ }
+ return batch.length;
+}
+
+function topCodes(codes) {
+ return Object.fromEntries(Object.entries(codes).sort((a, b) => b[1] - a[1]).slice(0, MAX_CODES));
+}
+
+/** The rollup a stored row becomes (common.usage-recorded@1). */
+function rollupOf(row) {
+ const codes = parse(row.error_codes, {});
+ const samples = parse(row.samples, []);
+ const start = Number(row.window_start);
+ const out = {
+ project_id: row.project_id, env: row.env, capability: row.capability,
+ resource: `ovrn:zone:${row.project_id}:object-zone/${row.zone_id}`, dimension: String(row.zone_id).toLowerCase(),
+ unit: row.unit, window: 'hour', window_start: new Date(start).toISOString(), window_end: new Date(start + HOUR_MS).toISOString(),
+ quantity: Number(row.quantity), errors: Number(row.errors),
+ };
+ if (Object.keys(codes).length) out.error_codes = codes;
+ if (samples.length) out.samples = samples;
+ if (Number(row.revision) > 1) out.revision = Number(row.revision);
+ return out;
+}
+
+/** Store every closed hour not sent yet (or corrected since) as media.usage.recorded. */
+async function emit({ now = Date.now(), limit = 500 } = {}) {
+ const rows = await db.all(`SELECT * FROM media_s3_usage WHERE window_start < ? AND (sent_revision IS NULL OR sent_revision < revision)
+ ORDER BY window_start, zone_id, capability, unit LIMIT ?`, [hourOf(now), limit]);
+ let sent = 0;
+ for (const row of rows) {
+ const rollup = rollupOf(row);
+ const v = events.checkUsage(rollup);
+ if (!v.valid) {
+ console.error(`[S3] usage rollup ${row.zone_id} ${rollup.window_start} ${row.capability}/${row.unit} does not match media.usage.recorded@1 and stays unsent: ${v.errors}`);
+ continue;
+ }
+ await db.getDb().tx(async () => {
+ const env = await events.recordUsage(rollup);
+ await db.run(`UPDATE media_s3_usage SET sent_revision = ? WHERE zone_id = ? AND window_start = ? AND capability = ? AND unit = ?`,
+ [Number(row.revision), row.zone_id, row.window_start, row.capability, row.unit]);
+ if (env) sent++;
+ });
+ }
+ return sent;
+}
+
+/** Rows older than keepDays (the events carry them on; Zone and Network keep their own sums). */
+async function prune({ now = Date.now(), keepDays = 40 } = {}) {
+ const r = await db.run('DELETE FROM media_s3_usage WHERE window_start < ? AND sent_revision IS NOT NULL AND sent_revision >= revision',
+ [now - keepDays * 24 * HOUR_MS]);
+ return r && r.changes ? r.changes : 0;
+}
+
+function _reset() { pending = new Map(); }
+function _pending() { return pending; }
+
+module.exports = { track, count, capabilityOf, sample, flush, emit, prune, rollupOf, snake, _reset, _pending };
new file mode 100644
new file mode 100644
@@ -0,0 +1,167 @@
@@ -0,0 +1,167 @@
+'use strict';
+// ADR-031 section 8: Media counts each zone's use of the S3 surface and sends every closed hour as media.usage.recorded
+// (common.usage-recorded@1): writes, lists, reads and egress as they are answered, stored bytes as byte_hours sampled
+// each minute, refusals by code. Late counts raise a sent hour's revision and it is sent again.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const crypto = require('crypto');
+
+(async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-s3u-'));
+ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
+ process.env.VOD_PATH = dir('vods');
+ process.env.FILES_PATH = dir('files');
+ process.env.OBJECTS_PATH = dir('objects');
+ process.env.THUMBNAILS_PATH = dir('thumbnails');
+ process.env.OV_NETWORK_URL = 'https://openvibe.network';
+ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
+ process.env.MEDIA_SIGNING_SECRET = 'test-signing-secret';
+ process.env.MEDIA_S3_KEY_SECRET = 'k'.repeat(40); // a test value: 32+ characters
+ process.env.MEDIA_S3_HOSTS = '127.0.0.1';
+
+ const db = require('../server/db/database');
+ const auth = require('../server/auth');
+ const events = require('../server/events');
+ const usage = require('../server/s3/usage');
+ const { serviceAuth, zones: zoneContracts, ids, validate } = require('openvibe-contracts');
+ const express = require('express');
+ const S3 = require('@aws-sdk/client-s3');
+
+ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+ auth._setNetworkPublicKeyForTests(keys.publicKey);
+ const now = Math.floor(Date.now() / 1000);
+ const svc = (sub, cap) => serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub, actor_type: 'service', aud: ['openvibe.media'], cap, iat: now, exp: now + 600, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey);
+ const ZONE_TOKEN = svc('svc:zone', ['media.zone.provision']);
+ const SERVICES_TOKEN = svc('svc:services', ['media.access_key.manage']);
+ const ADMIN = `usr_${ids.ulid()}`;
+ const PRJ = `prj_${ids.ulid()}`;
+
+ const s3router = require('../server/s3/routes');
+ const admin = require('../server/s3/admin-routes');
+ const app = express();
+ app.use((req, res, next) => (s3router.isS3Host(req) ? s3router(req, res, next) : next()));
+ app.use('/internal/zones', admin.internalRouter());
+ app.use('/api/v1/projects/:project/access-keys', admin.keysRouter({ projects: { isAdmin: async (p, s) => s === ADMIN && p === PRJ } }));
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const port = server.address().port;
+ const internal = async (method, p, { token, body } = {}) => {
+ const res = await fetch(`http://localhost:${port}${p}`, { method, headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
+ return { status: res.status, body: await res.json().catch(() => null) };
+ };
+ const zoneRecord = (env, name) => {
+ const z = { id: `zon_${ids.ulid()}`, project_id: PRJ, env, name, default: name === 'default', access: name === 'default' ? 'per-object' : 'private', state: 'active',
+ quota_bytes: null, revision: 1, created_at: new Date().toISOString() };
+ return Object.assign(z, zoneContracts.identity(z));
+ };
+ const rows = async (zoneId) => db.all('SELECT * FROM media_s3_usage WHERE zone_id = ? ORDER BY capability, unit', [zoneId]);
+ const row = (list, capability, unit) => list.find((r) => r.capability === capability && r.unit === unit);
+
+ // What the outbox would store: the rollups, as recordUsage receives them inside its transaction.
+ const sent = [];
+ events.recordUsage = async (payload) => { sent.push(payload); return { event_id: `evt_${ids.ulid()}` }; };
+
+ try {
+ const assets = zoneRecord('production', 'assets');
+ const scratch = zoneRecord('sandbox', 'scratch');
+ for (const z of [zoneRecord('production', 'default'), assets, zoneRecord('sandbox', 'default'), scratch]) {
+ assert.strictEqual((await internal('PUT', `/internal/zones/${z.id}`, { token: ZONE_TOKEN, body: z })).status, 200);
+ }
+ const issue = async (env) => (await internal('POST', `/api/v1/projects/${PRJ}/access-keys`, { token: SERVICES_TOKEN, body: { on_behalf_of: ADMIN, env, name: env } })).body;
+ const k = await issue('production');
+ const ks = await issue('sandbox');
+ const client = (key) => new S3.S3Client({ endpoint: `http://127.0.0.1:${port}`, region: 'us-east-1', forcePathStyle: true, credentials: { accessKeyId: key.key.id, secretAccessKey: key.secret } });
+ const s3 = client(k);
+
+ // ── Requests count as they are answered ──
+ const body = crypto.randomBytes(3000);
+ await s3.send(new S3.PutObjectCommand({ Bucket: 'assets', Key: 'a.bin', Body: body }));
+ await s3.send(new S3.PutObjectCommand({ Bucket: 'assets', Key: 'b.txt', Body: 'hello' }));
+ const got = await s3.send(new S3.GetObjectCommand({ Bucket: 'assets', Key: 'a.bin' }));
+ assert.strictEqual(Buffer.from(await got.Body.transformToByteArray()).length, 3000);
+ await s3.send(new S3.HeadObjectCommand({ Bucket: 'assets', Key: 'b.txt' }));
+ await assert.rejects(s3.send(new S3.GetObjectCommand({ Bucket: 'assets', Key: 'missing' })), /NoSuchKey/);
+ await s3.send(new S3.ListObjectsV2Command({ Bucket: 'assets' }));
+ await s3.send(new S3.DeleteObjectCommand({ Bucket: 'assets', Key: 'b.txt' }));
+ await assert.rejects(s3.send(new S3.GetObjectCommand({ Bucket: 'nope', Key: 'x' })), /NoSuchBucket/);
+ await client(ks).send(new S3.PutObjectCommand({ Bucket: 'scratch', Key: 's.txt', Body: 'sandbox' }));
+ await new Promise((r) => setImmediate(r));
+ assert.ok(usage._pending().size > 0, 'counted in memory first');
+ await usage.flush();
+ assert.strictEqual(usage._pending().size, 0, 'and flushed');
+ let list = await rows(assets.id);
+ assert.strictEqual(Number(row(list, 'media.object.upload', 'requests').quantity), 2, 'two writes');
+ const reads = row(list, 'media.object.read', 'requests');
+ assert.deepStrictEqual([Number(reads.quantity), Number(reads.errors)], [3, 1], 'three reads (GET, HEAD, a missing key), one refused');
+ assert.deepStrictEqual(JSON.parse(reads.error_codes), { 'media.s3.no_such_key': 1 }, 'refusals by code');
+ assert.strictEqual(JSON.parse(reads.samples)[0].status, 404);
+ assert.strictEqual(Number(row(list, 'media.object.read', 'egress_bytes').quantity), 3000, 'egress: the bytes of the GET that succeeded');
+ assert.strictEqual(Number(row(list, 'media.object.list', 'requests').quantity), 1);
+ assert.strictEqual(Number(row(list, 'media.object.delete', 'requests').quantity), 1);
+ assert.ok(!(await db.all("SELECT * FROM media_s3_usage WHERE capability LIKE '%' AND zone_id NOT IN (?, ?)", [assets.id, scratch.id])).length,
+ 'a request that names no zone of the key (NoSuchBucket) counts nowhere');
+ assert.strictEqual(Number(row(await rows(scratch.id), 'media.object.upload', 'requests').quantity), 1, 'the sandbox zone counts on its own');
+ console.log('✅ writes, reads, lists, deletes and egress counted per zone as they are answered; refusals by code');
+
+ // ── Stored bytes: one minute of byte_hours per sample ──
+ await usage.sample();
+ await usage.flush();
+ list = await rows(assets.id);
+ assert.strictEqual(Number(row(list, 'media.object.upload', 'byte_hours').quantity), Math.round(3000 / 60), 'live bytes / 60 a minute');
+ console.log('✅ stored bytes sampled each minute as byte_hours');
+
+ // ── Closed hours go out once, as media.usage.recorded ──
+ assert.strictEqual(await usage.emit(), 0, 'the current hour is not closed');
+ const later = Date.now() + 3600 * 1000;
+ const n = await usage.emit({ now: later });
+ assert.ok(n >= 8, `every series of both zones (${n})`);
+ for (const p of sent) {
+ const v = validate('media.usage.recorded@1', p);
+ assert.ok(v.valid, JSON.stringify(v.errors));
+ assert.ok(events.checkUsage(p).valid, 'and its resource is a zone of its project');
+ }
+ const readRollup = sent.find((p) => p.dimension === assets.id.toLowerCase() && p.capability === 'media.object.read' && p.unit === 'requests');
+ assert.strictEqual(readRollup.resource, `ovrn:zone:${PRJ}:object-zone/${assets.id}`, 'the zone named twice: its OVRN');
+ assert.deepStrictEqual([readRollup.quantity, readRollup.errors, readRollup.window, readRollup.env], [3, 1, 'hour', 'production']);
+ assert.ok(sent.some((p) => p.env === 'sandbox' && p.dimension === scratch.id.toLowerCase()), 'sandbox zones are rolled up too');
+ assert.ok(!sent.some((p) => /usr_|ovk_|a\.bin|127\.0\.0\.1/.test(JSON.stringify(p))), 'no subject, key, object key or address in a rollup');
+ const before = sent.length;
+ assert.strictEqual(await usage.emit({ now: later }), 0, 'a sent hour is not sent again');
+ assert.strictEqual(sent.length, before);
+ console.log('✅ each closed hour sent once as media.usage.recorded (valid, OVRN and dimension, sandbox included, no names)');
+
+ // ── A late count corrects the hour: revision 2 ──
+ const window = Number(readRollup.window_start ? Date.parse(readRollup.window_start) : 0);
+ usage.count({ id: assets.id, project_id: PRJ, env: 'production' }, 'media.object.read', 'requests', 1, { now: window + 1000 });
+ await usage.flush();
+ assert.strictEqual(await usage.emit({ now: later }), 1, 'the corrected hour goes out again');
+ const fixed = sent[sent.length - 1];
+ assert.deepStrictEqual([fixed.quantity, fixed.revision, fixed.capability], [4, 2, 'media.object.read'], 'with its new total and revision');
+ console.log('✅ late counts raise the revision and the hour is re-sent with its total');
+
+ // ── An invalid rollup stays unsent ──
+ await db.run(`INSERT INTO media_s3_usage (zone_id, project_id, env, window_start, capability, unit, quantity, errors, revision, updated_at)
+ VALUES (?, ?, 'production', ?, 'not a capability', 'requests', 1, 0, 1, ?)`, [assets.id, PRJ, window, Date.now()]);
+ const quiet = console.error;
+ const logged = [];
+ console.error = (...a) => logged.push(a.join(' '));
+ try { await usage.emit({ now: later }); } finally { console.error = quiet; }
+ assert.ok(logged.some((l) => /stays unsent/.test(l)), 'it is logged');
+ assert.ok((await db.get("SELECT sent_revision FROM media_s3_usage WHERE capability = 'not a capability'")).sent_revision == null, 'and not marked sent');
+ await db.run("DELETE FROM media_s3_usage WHERE capability = 'not a capability'");
+ console.log('✅ a rollup that does not match the contract stays unsent');
+
+ // ── Prune: sent rows past the retention go ──
+ assert.strictEqual(await usage.prune({ now: later + 41 * 24 * 3600 * 1000 }) > 0, true);
+ assert.strictEqual((await rows(assets.id)).length, 0);
+ console.log('✅ sent rows are pruned after 40 days');
+ } finally {
+ server.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ console.log('s3 usage: all checks passed');
+ process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });