@runeditrun/rer 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/cli/README.md +21 -0
- package/cli/container.mjs +11 -10
- package/cli/contract.mjs +2 -2
- package/cli/demo-host.mjs +47 -0
- package/cli/demo.mjs +47 -0
- package/cli/dependencies.mjs +26 -4
- package/cli/deploy-command.mjs +94 -0
- package/cli/deploy-inputs.mjs +22 -0
- package/cli/deploy-observation.mjs +28 -0
- package/cli/deploy-owner.mjs +52 -37
- package/cli/deploy-readiness.mjs +16 -0
- package/cli/deploy.mjs +20 -10
- package/cli/detached-server.mjs +1 -1
- package/cli/export.mjs +1 -1
- package/cli/hosted-output.mjs +2 -1
- package/cli/lifecycle.mjs +20 -7
- package/cli/local.mjs +7 -0
- package/cli/main.mjs +1 -1
- package/cli/managed-server.mjs +27 -18
- package/cli/parse.mjs +7 -2
- package/cli/pnpm-probe.mjs +4 -1
- package/cli/portability.mjs +4 -1
- package/cli/scheduled.mjs +2 -2
- package/cli/setup-input.mjs +1 -1
- package/global/CHANGELOG.md +46 -0
- package/global/README.md +9 -2
- package/global/launch/capacity.mjs +1 -1
- package/global/launch/facts.mjs +12 -6
- package/global/launch/install-pinned.mjs +26 -2
- package/global/launch/prepare.mjs +20 -8
- package/global/launch/state.mjs +54 -1
- package/global/main.mjs +18 -20
- package/global/update/command.mjs +2 -1
- package/modules/container/build.mjs +4 -2
- package/modules/container/host.mjs +1 -1
- package/modules/container/ingress.mjs +4 -1
- package/modules/container/migrations.mjs +2 -1
- package/modules/container/worker-engine.mjs +3 -1
- package/modules/demo-host/README.md +30 -0
- package/modules/demo-host/capacity.mjs +19 -0
- package/modules/demo-host/claim.mjs +21 -0
- package/modules/demo-host/container.mjs +74 -0
- package/modules/demo-host/pages.mjs +56 -0
- package/modules/demo-host/plan.mjs +52 -0
- package/modules/demo-host/worker.mjs +72 -0
- package/modules/deploy-kit/README.md +27 -0
- package/modules/deploy-kit/env-file.mjs +2 -1
- package/modules/deploy-kit/health-request.mjs +7 -2
- package/modules/deploy-kit/migration-ledger.mjs +38 -1
- package/modules/deploy-kit/migrations.mjs +38 -2
- package/modules/deploy-kit/owner-cli.mjs +30 -9
- package/modules/deploy-kit/owner-remove.mjs +5 -2
- package/modules/deploy-kit/owner-secret.mjs +67 -22
- package/modules/deploy-kit/owner.mjs +71 -24
- package/modules/deploy-kit/prepare.mjs +3 -1
- package/modules/deploy-kit/provider.mjs +3 -0
- package/modules/erasure/README.md +2 -3
- package/modules/operator-auth/accounts/settings-page.mjs +8 -3
- package/modules/operator-auth/csp.mjs +26 -0
- package/modules/operator-auth/dev/badge.mjs +1 -22
- package/modules/operator-auth/page.mjs +2 -2
- package/modules/operator-auth/routes.mjs +1 -1
- package/modules/portability/README.md +9 -0
- package/modules/portability/bridge.mjs +4 -1
- package/modules/portability/catalogue.mjs +9 -2
- package/modules/portability/index.d.mts +4 -0
- package/modules/portability/index.mjs +1 -0
- package/modules/portability/lines.mjs +3 -1
- package/modules/release/produce.mjs +1 -1
- package/modules/release/source.mjs +5 -2
- package/onboarding/adapter-labels.mjs +1 -0
- package/onboarding/adapter.mjs +9 -4
- package/onboarding/dependency-environment.mjs +33 -0
- package/onboarding/local/owner.mjs +1 -1
- package/onboarding/operator-installer.mjs +10 -4
- package/onboarding/redaction.mjs +22 -3
- package/package.json +6 -1
- package/scripts/publish-check/README.md +22 -1
- package/scripts/publish-check/c2pa.py +121 -0
- package/scripts/publish-check/content_identities.py +3 -1
- package/scripts/publish-check/controller_metadata.py +31 -0
- package/scripts/publish-check/history_scan.py +13 -3
- package/scripts/publish-check/media.py +283 -0
- package/scripts/publish-check/png_text.py +34 -0
- package/scripts/publish-check/public_hygiene.py +39 -4
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { assert, resourceName } from '../deploy-kit/validation.mjs';
|
|
3
|
+
import { ownerWorkerName } from '../deploy-kit/owner.mjs';
|
|
4
|
+
import { siteDeclaration } from '../deploy-kit/site.mjs';
|
|
5
|
+
|
|
6
|
+
/* A seed's hosted demo at demo.<domain>: the Worker `rer deploy --demo` builds
|
|
7
|
+
* from deploy-demo.json, as `rer deploy --site` builds the run site from
|
|
8
|
+
* deploy-site.json. It is this folder's front Worker plus a Container class
|
|
9
|
+
* running the seed's own image (`rer build --platform container`, workerd
|
|
10
|
+
* engine), deployed by `wrangler deploy` with the image beside it. */
|
|
11
|
+
|
|
12
|
+
export const DEMO_FILES = ['worker.mjs', 'container.mjs', 'capacity.mjs', 'claim.mjs', 'pages.mjs'];
|
|
13
|
+
const FRONT_SET = /^(?:RER_.*|SESSION_SECRET|SETUP_TOKEN|OWNER_EMAIL)$/;
|
|
14
|
+
const SECRET = /SECRET|TOKEN|PASSWORD|_KEY$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* deploy-demo.json: `{ "version": 1, "domain": "run-lamp.com", "name": "Lamp" }`, plus optionally
|
|
18
|
+
* `maxInstances` (concurrent demos, default 10) and `settings`, the seed's plain demo settings
|
|
19
|
+
* (such as MAIL_PROVIDER: "development"). Settings never carry secrets or what the front sets.
|
|
20
|
+
*/
|
|
21
|
+
export function demoDeclaration(value, originVar) {
|
|
22
|
+
assert(value && typeof value === 'object' && !Array.isArray(value) && value.version === 1 && Object.keys(value).every(key => ['version', 'domain', 'name', 'maxInstances', 'settings'].includes(key)), 'invalid-demo-declaration');
|
|
23
|
+
siteDeclaration({ version: 1, domain: value.domain });
|
|
24
|
+
assert(typeof value.name === 'string' && value.name.trim() === value.name && value.name.length > 0 && value.name.length <= 60 && !/[\p{Cc}\p{Cf}]/u.test(value.name), 'invalid-demo-name');
|
|
25
|
+
const maxInstances = value.maxInstances ?? 10;
|
|
26
|
+
assert(Number.isSafeInteger(maxInstances) && maxInstances >= 1 && maxInstances <= 100, 'invalid-demo-max-instances');
|
|
27
|
+
const settings = value.settings ?? {};
|
|
28
|
+
assert(settings && typeof settings === 'object' && !Array.isArray(settings) && Object.entries(settings).every(([name, setting]) =>
|
|
29
|
+
/^[A-Z][A-Z0-9_]{0,63}$/.test(name) && !FRONT_SET.test(name) && !SECRET.test(name) && name !== originVar && typeof setting === 'string' && setting.length <= 1024), 'invalid-demo-settings');
|
|
30
|
+
return { domain: value.domain, name: value.name, maxInstances, settings };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The demo Worker's name and its Wrangler configuration; no provider request. */
|
|
34
|
+
export function demoPlan({ project, environment, compatibilityDate, originVar, declaration, turnstileSiteKey }) {
|
|
35
|
+
assert(/^\d{4}-\d{2}-\d{2}$/.test(compatibilityDate ?? '') && /^[A-Z][A-Z0-9_]{0,63}$/.test(originVar ?? ''), 'invalid-plan');
|
|
36
|
+
const { domain, name, maxInstances, settings } = demoDeclaration(declaration, originVar);
|
|
37
|
+
const worker = `${ownerWorkerName(project, environment)}--demo`, hostname = `demo.${domain}`;
|
|
38
|
+
assert(resourceName(worker), 'owner-name-too-long');
|
|
39
|
+
// Rate limit namespaces are account-wide integers: one per demo Worker.
|
|
40
|
+
const namespace = String(1000 + parseInt(createHash('sha256').update(worker).digest('hex').slice(0, 8), 16) % 1_000_000_000);
|
|
41
|
+
const wrangler = {
|
|
42
|
+
name: worker, main: 'worker.mjs', compatibility_date: compatibilityDate, workers_dev: false, preview_urls: false,
|
|
43
|
+
routes: [{ pattern: hostname, custom_domain: true }],
|
|
44
|
+
vars: { DEMO_ORIGIN: `https://${hostname}`, DEMO_SITE: `https://${domain}`, DEMO_NAME: name, DEMO_ORIGIN_VAR: originVar, DEMO_MAX_INSTANCES: String(maxInstances), DEMO_SETTINGS: JSON.stringify(settings),
|
|
45
|
+
...(turnstileSiteKey ? { TURNSTILE_SITE_KEY: turnstileSiteKey } : {}) },
|
|
46
|
+
durable_objects: { bindings: [{ name: 'DEMO', class_name: 'DemoContainer' }, { name: 'CAPACITY', class_name: 'DemoCapacity' }] },
|
|
47
|
+
migrations: [{ tag: 'v1', new_sqlite_classes: ['DemoContainer', 'DemoCapacity'] }],
|
|
48
|
+
containers: [{ class_name: 'DemoContainer', image: './image/Dockerfile', max_instances: maxInstances, instance_type: 'basic' }],
|
|
49
|
+
ratelimits: [{ name: 'STARTS', namespace_id: namespace, simple: { limit: 3, period: 60 } }],
|
|
50
|
+
};
|
|
51
|
+
return { worker, hostname, origin: `https://${hostname}`, maxInstances, settings: Object.keys(settings), wrangler };
|
|
52
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// A seed's hosted demo at demo.<domain>, generated by `rer deploy --demo`.
|
|
2
|
+
// GET / shows the start page (a Turnstile managed widget that submits itself).
|
|
3
|
+
// POST /__demo/start checks the visitor's IP rate limit, Turnstile and capacity,
|
|
4
|
+
// then starts a container of their own, signs its owner in and sets the
|
|
5
|
+
// rer_demo cookie. Every request with that cookie goes to the visitor's
|
|
6
|
+
// container; nothing else on this host is reachable.
|
|
7
|
+
import { startPage, demoCookie } from './pages.mjs';
|
|
8
|
+
|
|
9
|
+
export { DemoContainer } from './container.mjs';
|
|
10
|
+
export { DemoCapacity } from './capacity.mjs';
|
|
11
|
+
|
|
12
|
+
const HOUR = 60 * 60_000;
|
|
13
|
+
const home = () => new Response(null, { status: 303, headers: { location: '/' } });
|
|
14
|
+
|
|
15
|
+
/** The visitor's demo from its cookie; ids are checked by idFromString, so a forged one is no demo. */
|
|
16
|
+
function demoId(request, env) {
|
|
17
|
+
const value = (request.headers.get('cookie') ?? '').split(';').map(part => part.trim()).find(part => part.startsWith('rer_demo='))?.slice(9);
|
|
18
|
+
if (!value) return null;
|
|
19
|
+
try { return env.DEMO.idFromString(value); } catch { return null; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The demo's end in the visitor's time zone, as the Demo bar shows it (HH:MM).
|
|
23
|
+
function localTime(time, timeZone = 'UTC') {
|
|
24
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit',
|
|
25
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit', timeZoneName: 'longOffset' }).formatToParts(time).map(part => [part.type, part.value]));
|
|
26
|
+
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}${parts.timeZoneName === 'GMT' ? '+00:00' : parts.timeZoneName.slice(3)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function human(env, token, ip) {
|
|
30
|
+
if (typeof token !== 'string' || !token || token.length > 2048) return false;
|
|
31
|
+
const body = new URLSearchParams({ secret: env.TURNSTILE_SECRET_KEY, response: token });
|
|
32
|
+
if (ip) body.set('remoteip', ip);
|
|
33
|
+
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', body });
|
|
34
|
+
const result = response.ok ? await response.json() : {};
|
|
35
|
+
if (result.success !== true) console.error('Turnstile refused a start:', response.status, JSON.stringify(result['error-codes'] ?? []));
|
|
36
|
+
return result.success === true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function start(request, env) {
|
|
40
|
+
if (request.headers.get('origin') !== env.DEMO_ORIGIN) return startPage(env, { status: 403, notice: 'Start the demo from this page.' });
|
|
41
|
+
const ip = request.headers.get('cf-connecting-ip') ?? '';
|
|
42
|
+
if (!(await env.STARTS.limit({ key: ip })).success) return startPage(env, { status: 429, notice: 'Too many demos started from your network. Try again in a minute.' });
|
|
43
|
+
const form = await request.formData();
|
|
44
|
+
if (!await human(env, form.get('cf-turnstile-response'), ip)) return startPage(env, { status: 403, notice: 'The check did not pass. Try again.' });
|
|
45
|
+
const id = env.DEMO.newUniqueId(), ends = Date.now() + HOUR;
|
|
46
|
+
if (!await env.CAPACITY.get(env.CAPACITY.idFromName('capacity')).acquire(id.toString(), ends, Number(env.DEMO_MAX_INSTANCES))) {
|
|
47
|
+
return startPage(env, { status: 503, notice: 'Every demo is in use right now. Try again in a few minutes.' });
|
|
48
|
+
}
|
|
49
|
+
const demo = env.DEMO.get(id);
|
|
50
|
+
try {
|
|
51
|
+
const signedIn = await demo.start({ origin: env.DEMO_ORIGIN, originVar: env.DEMO_ORIGIN_VAR, ends, endsLocal: localTime(ends, request.cf?.timezone), settings: JSON.parse(env.DEMO_SETTINGS) });
|
|
52
|
+
const headers = new Headers({ location: signedIn.location, 'cache-control': 'no-store' });
|
|
53
|
+
for (const cookie of [...signedIn.cookies, demoCookie(env, id.toString())]) headers.append('set-cookie', cookie);
|
|
54
|
+
return new Response(null, { status: 303, headers });
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error('Demo start failed:', error.message);
|
|
57
|
+
await demo.end();
|
|
58
|
+
return startPage(env, { status: 502, notice: 'The demo did not start. Try again.' });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default {
|
|
63
|
+
async fetch(request, env) {
|
|
64
|
+
const url = new URL(request.url);
|
|
65
|
+
if (url.pathname === '/__demo/start') return request.method === 'POST' ? start(request, env) : home();
|
|
66
|
+
const id = demoId(request, env);
|
|
67
|
+
if (id) return env.DEMO.get(id).fetch(request);
|
|
68
|
+
if (!['GET', 'HEAD'].includes(request.method)) return new Response('Start a demo first.', { status: 403 });
|
|
69
|
+
if (url.pathname !== '/') return home();
|
|
70
|
+
return startPage(env);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
@@ -193,6 +193,17 @@ No Single Redirect (dynamic redirect ruleset) permission is needed. Base's refer
|
|
|
193
193
|
|
|
194
194
|
SQL files have ordered unique names and SHA-256 identities. `d1_migrations` holds controller history with ordered IDs, names, timestamps, SHA-256 hashes and states. Native archives already exclude this installation table. Standard Wrangler name-only inserts remain possible, but their missing hashes/states block subsequent Base deployment pending reviewed adoption. Applied entries must be an unchanged prefix of the desired set. A new migration gets an `applying` claim before its SQL and completion marker are submitted together through the [Cloudflare D1 SQL import API](https://developers.cloudflare.com/api/resources/d1/subresources/database/methods/import/), using the same whole-file protocol as Wrangler to preserve compound trigger bodies and atomic SQL/completion. Uploads use bounded HTTPS R2 destinations, without account credentials; polling is bounded and signed URLs and import response text are never diagnostic output. Any interrupted, failed or uncertain outcome remains blocked and must be inspected explicitly; the kit never automatically retries, rolls back user data, or marks an unknown outcome successful. Readback checks every final identity.
|
|
195
195
|
|
|
196
|
+
A seed that had to rewrite a migration it already released declares the rewrite
|
|
197
|
+
in `migrations/rewrites.json`: `{ "version": 1, "rewrites": [{ "migration":
|
|
198
|
+
"0019_import_custody.sql", "from": "<released SHA-256>", "to": "<current SHA-256>",
|
|
199
|
+
"reason": "...", "reviewed": true }] }`. An applied row holding `from` then counts
|
|
200
|
+
as that migration, so an upgrade past it proceeds; the ledger keeps the released
|
|
201
|
+
digest it applied. A later migration must converge databases that applied the
|
|
202
|
+
released bytes. The record is refused unless `reviewed` is `true`, it names a
|
|
203
|
+
migration in the set, and `to` is that migration's current bytes; every other
|
|
204
|
+
change to applied history is still refused. Keep the record for as long as any
|
|
205
|
+
installation may hold the released digest.
|
|
206
|
+
|
|
196
207
|
Completion preserves the original SQL bytes and always starts on a new line.
|
|
197
208
|
Comment-only migrations are rejected before any provider request or ledger claim.
|
|
198
209
|
The controller adds a separator only when the final non-comment token is not a
|
|
@@ -290,6 +301,22 @@ sleep}` selects a smaller bound and deterministic time; `healthOptions.now` and
|
|
|
290
301
|
replaces the mode-0600 receipt and refuses any change to its identity; the default
|
|
291
302
|
CLI and owner-account state do this. A no-op repeat never rewrites the original receipt.
|
|
292
303
|
|
|
304
|
+
Owner-account secret changes use the same bounded `waitHealth` after their new
|
|
305
|
+
Worker version has settled and been recorded. This includes explicit
|
|
306
|
+
`--set-secret`/`--unset-secret` commands, a fresh owner setup token, and the
|
|
307
|
+
post-claim retirement performed by standard deploys and custom deploy hooks.
|
|
308
|
+
Readiness checks the receipt source SHA, fingerprint and actual new version;
|
|
309
|
+
it needs neither build output nor clean source for a secret-only command.
|
|
310
|
+
Workers without a public route retain provider-only verification.
|
|
311
|
+
|
|
312
|
+
A failed readiness check leaves the applied secret change, owner claim and token
|
|
313
|
+
retirement recorded. The public command fails with the last HTTP status and the
|
|
314
|
+
first bounded, redacted response line. Correct the seed/app configuration and
|
|
315
|
+
redeploy; an app that requires an already spent setup token must remove that
|
|
316
|
+
runtime requirement, rather than restoring the token or undoing ownership.
|
|
317
|
+
The ownership receipt records readiness success/failure for the new version;
|
|
318
|
+
untrusted response prefixes are never written into that record.
|
|
319
|
+
|
|
293
320
|
## Read-only teardown verification
|
|
294
321
|
|
|
295
322
|
The kit does not perform deletes. After a seed has validated its receipt and
|
|
@@ -21,6 +21,7 @@ export async function readDeployEnvFile(root, envFile, localOnlyKeys = []) {
|
|
|
21
21
|
if (Object.hasOwn(settings, match[1])) throw new Error('Repeated deploy env key');
|
|
22
22
|
settings[match[1]] = match[2].replace(/^"(.*)"$/, '$1');
|
|
23
23
|
}
|
|
24
|
-
|
|
24
|
+
const local = [...LOCAL_ONLY_KEYS, ...localOnlyKeys].find(key => Object.hasOwn(settings, key));
|
|
25
|
+
if (local) throw new Error(`Local-only key ${local} is refused for deploy; remove it from the file`);
|
|
25
26
|
return settings;
|
|
26
27
|
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
1
2
|
import { assert } from './validation.mjs';
|
|
2
3
|
|
|
4
|
+
// Do not flush an incomplete final character: a replacement would corrupt a
|
|
5
|
+
// truncated credential prefix before the caller can redact it.
|
|
6
|
+
const decodePrefix = bytes => new StringDecoder('utf8').write(bytes.subarray(0,256));
|
|
7
|
+
|
|
3
8
|
async function bodyPrefix(response) {
|
|
4
9
|
if (!response.body) return '';
|
|
5
10
|
const reader = response.body.getReader(); let bytes = Buffer.alloc(0);
|
|
@@ -11,7 +16,7 @@ async function bodyPrefix(response) {
|
|
|
11
16
|
}
|
|
12
17
|
} catch { /* HTTP status remains authoritative if its diagnostic body breaks. */ }
|
|
13
18
|
finally { await reader.cancel().catch(() => {}); }
|
|
14
|
-
return bytes
|
|
19
|
+
return decodePrefix(bytes);
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
/** One credential-free, bounded request. Retain only bounded response evidence. */
|
|
@@ -41,7 +46,7 @@ export async function healthRequest({origin, receipt, versionId, request, timeou
|
|
|
41
46
|
const {done,value} = await reader.read();
|
|
42
47
|
if (done) break;
|
|
43
48
|
bytes = Buffer.concat([bytes, value]);
|
|
44
|
-
attempt.body = bytes
|
|
49
|
+
attempt.body = decodePrefix(bytes);
|
|
45
50
|
assert(bytes.length <= 65536,'response-too-large');
|
|
46
51
|
}
|
|
47
52
|
} finally { await reader.cancel().catch(() => {}); }
|
|
@@ -4,7 +4,44 @@ export const ledger = 'd1_migrations';
|
|
|
4
4
|
const legacy = '_rer_deploy_migrations';
|
|
5
5
|
export const literal = value => `'${value.replaceAll("'","''")}'`;
|
|
6
6
|
export const createLedger = `CREATE TABLE ${ledger} (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, sha256 TEXT, state TEXT CHECK(state IN ('applying','applied')))`;
|
|
7
|
-
|
|
7
|
+
|
|
8
|
+
// Every ledger shape a deployment can hold: Wrangler's d1_migrations; that
|
|
9
|
+
// table after the kit adopted it (sha256 and state added); the kit's own
|
|
10
|
+
// d1_migrations, which rer 0.2.2 and later create; and the kit's
|
|
11
|
+
// _rer_deploy_migrations from rer 0.2.1 and earlier, which stays authoritative
|
|
12
|
+
// until the next deploy moves it into d1_migrations. Seed recovery code that
|
|
13
|
+
// checks the ledger before reading or restoring it accepts all four with
|
|
14
|
+
// migrationLedgerShape.
|
|
15
|
+
const shapeColumns = table => `SELECT '${table}' AS ledger,cid,name,upper(type) AS type,"notnull",dflt_value,pk,hidden FROM pragma_table_xinfo('${table}')`;
|
|
16
|
+
export const ledgerShapeQuery = `${shapeColumns(legacy)} UNION ALL ${shapeColumns(ledger)} ORDER BY ledger,cid`;
|
|
17
|
+
const column = (name, type, notnull = 0, dflt_value = null, pk = 0) => ({ name, type, notnull, dflt_value, pk, hidden: 0 });
|
|
18
|
+
const wrangler = [column('id','INTEGER',0,null,1), column('name','TEXT'), column('applied_at','TIMESTAMP',1,'CURRENT_TIMESTAMP')];
|
|
19
|
+
const tracked = [column('sha256','TEXT'), column('state','TEXT')];
|
|
20
|
+
const shapes = {
|
|
21
|
+
[ledger]: {
|
|
22
|
+
wrangler,
|
|
23
|
+
adopted: [...wrangler, ...tracked],
|
|
24
|
+
kit: [column('id','INTEGER',0,null,1), column('name','TEXT',1), column('applied_at','TEXT',1,'CURRENT_TIMESTAMP'), ...tracked],
|
|
25
|
+
},
|
|
26
|
+
[legacy]: { legacy: [column('name','TEXT',0,null,1), column('sha256','TEXT',1), column('state','TEXT',1)] },
|
|
27
|
+
};
|
|
28
|
+
/** The ledger's shape from its ledgerShapeQuery rows (node:sqlite or D1):
|
|
29
|
+
* { name, columns, shape } where shape is 'wrangler', 'adopted', 'kit' or
|
|
30
|
+
* 'legacy'. When both tables exist, _rer_deploy_migrations is the history, as
|
|
31
|
+
* restores read it. Any other shape throws invalid-migration-ledger. */
|
|
32
|
+
export function migrationLedgerShape(rows) {
|
|
33
|
+
// Rows without a ledger column come from the d1_migrations-only query of rer 0.2.3.
|
|
34
|
+
const tables = Map.groupBy(rows ?? [], row => row.ledger ?? ledger);
|
|
35
|
+
const name = tables.has(legacy) ? legacy : ledger;
|
|
36
|
+
const read = (tables.get(name) ?? []).map(row => ({ name: row.name, type: String(row.type).toUpperCase(), notnull: Number(row.notnull), dflt_value: row.dflt_value ?? null, pk: Number(row.pk), hidden: Number(row.hidden) }));
|
|
37
|
+
const shape = Object.keys(shapes[name]).find(key => JSON.stringify(read) === JSON.stringify(shapes[name][key]));
|
|
38
|
+
assert(shape, 'invalid-migration-ledger');
|
|
39
|
+
return { name, columns: read.map(row => row.name), shape };
|
|
40
|
+
}
|
|
41
|
+
/** An applied row records this migration: its current bytes, or bytes a seed's
|
|
42
|
+
* reviewed rewrite record says this migration replaced. */
|
|
43
|
+
export const recorded = (row, migration) => row.name === migration?.name && (row.sha256 === migration.sha256 || (migration.rewrites ?? []).some(rewrite => rewrite.from === row.sha256));
|
|
44
|
+
const validPrefix = (rows, migrations) => rows.every((row, index) => recorded(row, migrations[index]) && row.state === 'applied');
|
|
8
45
|
const same = (a, b) => a.length === b.length && a.every((row, index) => row.name === b[index].name && row.sha256 === b[index].sha256 && row.state === b[index].state);
|
|
9
46
|
|
|
10
47
|
export async function readLedger(api, id, migrations, query) {
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
1
2
|
import { assert, hash } from './validation.mjs';
|
|
3
|
+
import { regularBytes } from './artifact.mjs';
|
|
2
4
|
import { assertMigrationSqlSafe } from './sql-safety.mjs';
|
|
3
5
|
import { sqlTokens } from './sql-tokens.mjs';
|
|
4
|
-
import { ledger, literal, createLedger, readLedger } from './migration-ledger.mjs';
|
|
6
|
+
import { ledger, literal, createLedger, readLedger, recorded } from './migration-ledger.mjs';
|
|
5
7
|
export function validateMigrations(migrations) {
|
|
6
8
|
let previous = '';
|
|
7
9
|
for (const migration of migrations) {
|
|
@@ -9,8 +11,42 @@ export function validateMigrations(migrations) {
|
|
|
9
11
|
assert(typeof migration.sql === 'string' && migration.sql.trim() && hash(migration.sql) === migration.sha256 && migration.sql.length <= 512000, 'invalid-migration-bytes');
|
|
10
12
|
assertMigrationSqlSafe(migration.sql);
|
|
11
13
|
assert(sqlTokens(migration.sql).length > 0, 'invalid-migration-bytes');
|
|
14
|
+
assert(migration.rewrites === undefined || Array.isArray(migration.rewrites) && migration.rewrites.every(rewrite => /^[a-f0-9]{64}$/.test(rewrite?.from ?? '') && rewrite.from !== migration.sha256 && typeof rewrite.reason === 'string' && rewrite.reason.trim()), 'invalid-migration-rewrite');
|
|
12
15
|
}
|
|
13
16
|
}
|
|
17
|
+
const rewriteKeys = ['migration','from','to','reason','reviewed'];
|
|
18
|
+
/** A seed's reviewed rewrites of already released migrations, from
|
|
19
|
+
* migrations/rewrites.json: { version: 1, rewrites: [{ migration, from, to,
|
|
20
|
+
* reason, reviewed: true }] }. `from` is the released bytes' SHA-256, `to` the
|
|
21
|
+
* current bytes'. Each named migration then also accepts an applied row holding
|
|
22
|
+
* `from`; the ledger keeps that row as applied. Any other change stays refused. */
|
|
23
|
+
export async function readMigrationRewrites(product) {
|
|
24
|
+
const text = await regularBytes(join(product,'migrations/rewrites.json'),1024*1024).then(String,error => { if (error.code === 'ENOENT') return undefined; throw error; });
|
|
25
|
+
if (text === undefined) return [];
|
|
26
|
+
let declaration; try { declaration = JSON.parse(text); } catch { throw new Error('invalid-migration-rewrites'); }
|
|
27
|
+
assert(declaration?.version === 1 && Object.keys(declaration).every(key => ['version','rewrites'].includes(key)) && Array.isArray(declaration.rewrites) && declaration.rewrites.length <= 1000, 'invalid-migration-rewrites');
|
|
28
|
+
for (const item of declaration.rewrites) {
|
|
29
|
+
assert(item && Object.keys(item).every(key => rewriteKeys.includes(key)) && /^[0-9]+_[a-zA-Z0-9_-]+\.sql$/.test(item.migration ?? '') && /^[a-f0-9]{64}$/.test(item.from ?? '') && /^[a-f0-9]{64}$/.test(item.to ?? '') && item.from !== item.to
|
|
30
|
+
&& typeof item.reason === 'string' && item.reason.trim() && item.reason.length <= 1000, 'invalid-migration-rewrites');
|
|
31
|
+
assert(item.reviewed === true, 'unreviewed-migration-rewrite');
|
|
32
|
+
}
|
|
33
|
+
assert(new Set(declaration.rewrites.map(item => `${item.migration} ${item.from}`)).size === declaration.rewrites.length, 'invalid-migration-rewrites');
|
|
34
|
+
return declaration.rewrites;
|
|
35
|
+
}
|
|
36
|
+
/** Attach declared rewrites to the migrations they name. A record must name a
|
|
37
|
+
* migration in the list (unless `partial`, for a list that is one of several)
|
|
38
|
+
* and its `to` must be that migration's current bytes. */
|
|
39
|
+
export function withRewrites(migrations, rewrites, { partial = false } = {}) {
|
|
40
|
+
for (const item of rewrites) {
|
|
41
|
+
const migration = migrations.find(x => x.name === item.migration);
|
|
42
|
+
assert(migration || partial, 'unknown-migration-rewrite');
|
|
43
|
+
if (migration) assert(migration.sha256 === item.to, 'migration-rewrite-mismatch');
|
|
44
|
+
}
|
|
45
|
+
return migrations.map(migration => {
|
|
46
|
+
const declared = rewrites.filter(item => item.migration === migration.name);
|
|
47
|
+
return declared.length ? { ...migration, rewrites: declared.map(({ from, reason }) => ({ from, reason })) } : migration;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
14
50
|
export async function query(api, id, sql) {
|
|
15
51
|
const rows = await api.request(`/d1/database/${id}/query`,{method:'POST',body:{sql}});
|
|
16
52
|
assert(Array.isArray(rows) && rows.length > 0 && rows.every(x => x.success === true && Array.isArray(x.results)), 'd1-query-failed');
|
|
@@ -38,7 +74,7 @@ export async function migrate(api, id, migrations, { apply = true, upgrade = app
|
|
|
38
74
|
}
|
|
39
75
|
if (pending.length) {
|
|
40
76
|
const final = (await query(api,id,`SELECT name, sha256, state FROM ${ledger} ORDER BY id`))[0].results;
|
|
41
|
-
assert(final.length === migrations.length && final.every((x,i) => x.state === 'applied' && x
|
|
77
|
+
assert(final.length === migrations.length && final.every((x,i) => x.state === 'applied' && recorded(x, migrations[i])), 'migration-readback-mismatch');
|
|
42
78
|
}
|
|
43
79
|
return { applied:pending.map(x => x.name), history:migrations.map(({name,sha256}) => ({name,sha256})) };
|
|
44
80
|
}
|
|
@@ -7,13 +7,14 @@ import { assert, hexId } from './validation.mjs';
|
|
|
7
7
|
import { regularBytes } from './artifact.mjs';
|
|
8
8
|
import { packageBuild } from './prepare.mjs';
|
|
9
9
|
import { cloudflare } from './provider.mjs';
|
|
10
|
-
import { ownerTarget, ownerPlan, ownerDeploy, ownerHealth, settleOwnerState } from './owner.mjs';
|
|
10
|
+
import { ownerTarget, ownerPlan, ownerDeploy, ownerHealth, settleOwnerState, sameDeployment, localOnlyBinding, withStateLock, readOwnerState, writeOwnerState } from './owner.mjs';
|
|
11
|
+
import { activeWorker } from './worker.mjs';
|
|
11
12
|
import { ownerSecret } from './owner-secret.mjs';
|
|
12
13
|
import { ownerRemove } from './owner-remove.mjs';
|
|
13
14
|
import { refuseLocalModels } from './local-only.mjs';
|
|
14
15
|
|
|
15
16
|
export const cronNotice = 'This Worker has a Cron Trigger. Cloudflare may take up to about two hours to start its first scheduled run on some accounts.';
|
|
16
|
-
const usage = 'usage: owner-cli.mjs deploy|health|remove --declaration <file> --environment <name> [--product <dir>] [--state <file>] | secret ... --set NAME|--unset NAME';
|
|
17
|
+
const usage = 'usage: owner-cli.mjs deploy|health|remove|inspect|record-owner --declaration <file> --environment <name> [--product <dir>] [--state <file>] [--outcome claimed|kept] | secret ... --set NAME|--unset NAME';
|
|
17
18
|
|
|
18
19
|
/** The owner's committed source and existing local build. Run the seed's build first. */
|
|
19
20
|
async function ownerArtifact(root, product) {
|
|
@@ -24,13 +25,14 @@ async function ownerArtifact(root, product) {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/** Inputs: CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, plus the secrets the declaration names on first deploy. */
|
|
27
|
-
export async function main(argv = process.argv.slice(2), env = process.env, {fetch = globalThis.fetch, cwd = process.cwd(), healthOptions, localOnlyKeys = [], notice = line => console.error(line), onCreated} = {}) {
|
|
28
|
+
export async function main(argv = process.argv.slice(2), env = process.env, {fetch = globalThis.fetch, cwd = process.cwd(), healthOptions, settle, localOnlyKeys = [], notice = line => console.error(line), onCreated} = {}) {
|
|
28
29
|
let parsed;
|
|
29
|
-
try { parsed = parseArgs({args:argv,allowPositionals:true,strict:true,options:{declaration:{type:'string'},environment:{type:'string'},product:{type:'string',default:''},state:{type:'string'},set:{type:'string'},unset:{type:'string'}}}); }
|
|
30
|
+
try { parsed = parseArgs({args:argv,allowPositionals:true,strict:true,options:{declaration:{type:'string'},environment:{type:'string'},product:{type:'string',default:''},state:{type:'string'},set:{type:'string'},unset:{type:'string'},outcome:{type:'string'}}}); }
|
|
30
31
|
catch { throw new Error('usage'); }
|
|
31
32
|
const {positionals:[command,...rest],values} = parsed;
|
|
32
|
-
if (!['deploy','health','secret','remove'].includes(command) || rest.length || !values.declaration || !values.environment) throw new Error('usage');
|
|
33
|
+
if (!['deploy','health','secret','remove','inspect','record-owner'].includes(command) || rest.length || !values.declaration || !values.environment) throw new Error('usage');
|
|
33
34
|
if ((command === 'secret') !== (values.set !== undefined || values.unset !== undefined) || values.set !== undefined && values.unset !== undefined) throw new Error('usage');
|
|
35
|
+
if (values.outcome !== undefined && command !== 'record-owner' || command === 'record-owner' && !['claimed','kept'].includes(values.outcome)) throw new Error('usage');
|
|
34
36
|
await refuseLocalModels(resolve(cwd));
|
|
35
37
|
const missing = ['CLOUDFLARE_ACCOUNT_ID','CLOUDFLARE_API_TOKEN'].filter(key => typeof env[key] !== 'string' || !env[key].trim());
|
|
36
38
|
if (missing.length) { const error = new Error('missing-input'); error.inputs = missing; throw error; }
|
|
@@ -48,10 +50,29 @@ export async function main(argv = process.argv.slice(2), env = process.env, {fet
|
|
|
48
50
|
const binding = values.set ?? values.unset, item = (Array.isArray(declaration.secrets) ? declaration.secrets : []).find(entry => entry?.binding === binding);
|
|
49
51
|
if (!item) { const error = new Error('undeclared-secret'); error.resource = `secret:${binding}`; throw error; }
|
|
50
52
|
const value = values.set === undefined ? undefined : env[item.environment];
|
|
51
|
-
return {ok:true,...await ownerSecret({api,target,statePath,binding,value}).catch(error => { throw api.annotateFailure(error,'secrets'); })};
|
|
53
|
+
return {ok:true,...await ownerSecret({api,target,statePath,binding,value,fetch,healthOptions}).catch(error => { throw api.annotateFailure(error,'secrets'); })};
|
|
52
54
|
}
|
|
53
55
|
// A secret rer retired (the spent setup token) is no longer part of the plan.
|
|
54
|
-
const
|
|
56
|
+
const state = await settleOwnerState({api,target,statePath});
|
|
57
|
+
const {retiredSecrets = []} = state;
|
|
58
|
+
if (command === 'record-owner') return withStateLock(statePath, async () => {
|
|
59
|
+
const current = await readOwnerState(statePath, target);
|
|
60
|
+
assert(current.receipt && !current.pendingUpload && !current.pendingSecret, 'missing-owner-receipt');
|
|
61
|
+
const live = await activeWorker(api, target.worker);
|
|
62
|
+
assert(live && sameDeployment(live, current.receipt.provider), 'owned-resource-replaced');
|
|
63
|
+
current.ownerClaim = { outcome: values.outcome };
|
|
64
|
+
await writeOwnerState(statePath, current);
|
|
65
|
+
return {ok:true, owner:values.outcome};
|
|
66
|
+
});
|
|
67
|
+
if (command === 'inspect') {
|
|
68
|
+
assert(!localOnlyBinding(declaration, localOnlyKeys), 'local-only-binding');
|
|
69
|
+
assert(state.receipt && !state.pendingUpload && !state.pendingSecret, 'missing-owner-receipt');
|
|
70
|
+
const live = await activeWorker(api, target.worker);
|
|
71
|
+
assert(live && sameDeployment(live, state.receipt.provider), 'owned-resource-replaced');
|
|
72
|
+
const rows = await api.request(`/workers/scripts/${target.worker}/secrets`);
|
|
73
|
+
assert(Array.isArray(rows), 'invalid-secret-list');
|
|
74
|
+
return {ok:true, origin:target.origin, versionId:live.versionId, retiredSecrets, ownerClaim:state.ownerClaim, liveSecrets:rows.map(row => row.name)};
|
|
75
|
+
}
|
|
55
76
|
const current = retiredSecrets.length && Array.isArray(declaration.secrets) ? {...declaration,secrets:declaration.secrets.filter(item => !retiredSecrets.includes(item?.binding))} : declaration;
|
|
56
77
|
const artifact = await ownerArtifact(root,values.product);
|
|
57
78
|
const plan = ownerPlan(target,current,artifact,{localOnlyKeys});
|
|
@@ -61,11 +82,11 @@ export async function main(argv = process.argv.slice(2), env = process.env, {fet
|
|
|
61
82
|
}
|
|
62
83
|
// Dispatch is the provider's: on some accounts a new Cron starts only at the account scheduler's next refresh.
|
|
63
84
|
if (plan.crons.length) notice(cronNotice);
|
|
64
|
-
const {receipt,created,sentSecrets} = await ownerDeploy({api,target,plan,artifact,statePath,env,fetch,healthOptions}).catch(error => {
|
|
85
|
+
const {receipt,created,sentSecrets} = await ownerDeploy({api,target,plan,artifact,statePath,env,fetch,healthOptions,settle}).catch(error => {
|
|
65
86
|
api.annotateFailure(error,error.message.startsWith('missing-secret-') ? 'secrets' : undefined);
|
|
66
87
|
throw error;
|
|
67
88
|
});
|
|
68
|
-
return {ok:true,changed:receipt.changed,worker:target.worker,origin:receipt.origin,versionId:receipt.provider.versionId,created,sentSecrets,retiredSecrets,state:statePath};
|
|
89
|
+
return {ok:true,changed:receipt.changed,worker:target.worker,origin:receipt.origin,versionId:receipt.provider.versionId,created,sentSecrets,retiredSecrets,...(state.ownerClaim ? {ownerClaim:state.ownerClaim} : {}),state:statePath};
|
|
69
90
|
}
|
|
70
91
|
|
|
71
92
|
/** Prints refusal codes and resource names only; never inputs, tokens or provider bodies. */
|
|
@@ -43,7 +43,7 @@ export async function ownerRemove({api: provider, target, statePath, clock, slee
|
|
|
43
43
|
const api = withSettledLists(withScopeHints(provider));
|
|
44
44
|
return withStateLock(statePath, async () => {
|
|
45
45
|
const state = await readOwnerState(statePath, target);
|
|
46
|
-
if (await settlePendingSecret(api, target.worker, state)) await writeOwnerState(statePath, state);
|
|
46
|
+
if (await settlePendingSecret(api, target.worker, state, {preflight:true})) await writeOwnerState(statePath, state);
|
|
47
47
|
const owned = KINDS.flatMap(kind => Object.entries(state.owned[kind]).map(([name, entry]) => ({kind, name, entry})));
|
|
48
48
|
assert(state.owned.worker || owned.length, 'nothing-to-remove');
|
|
49
49
|
// Preflight, read-only.
|
|
@@ -94,7 +94,10 @@ export async function ownerRemove({api: provider, target, statePath, clock, slee
|
|
|
94
94
|
if (remaining.length) {
|
|
95
95
|
// Keep the record of what is left, so a later --remove finishes it.
|
|
96
96
|
for (const kind of KINDS) for (const name of Object.keys(state.owned[kind])) if (!remaining.includes(`${kind}:${name}`)) delete state.owned[kind][name];
|
|
97
|
-
if (!remaining.includes(`worker:${target.worker}`)) {
|
|
97
|
+
if (!remaining.includes(`worker:${target.worker}`)) {
|
|
98
|
+
state.owned.worker = null; state.receipt = null; delete state.pendingUpload;
|
|
99
|
+
delete state.ownerClaim; delete state.retiredSecrets;
|
|
100
|
+
}
|
|
98
101
|
await writeOwnerState(statePath, state);
|
|
99
102
|
const error = new Error('remove-incomplete'); error.remaining = remaining; error.failed = failed; error.removed = removed;
|
|
100
103
|
throw error;
|
|
@@ -1,49 +1,94 @@
|
|
|
1
1
|
import { assert, identifier } from './validation.mjs';
|
|
2
|
-
import { activeWorker
|
|
2
|
+
import { activeWorker } from './worker.mjs';
|
|
3
|
+
import { waitHealth } from './health-request.mjs';
|
|
4
|
+
import { definiteProviderRefusal } from './provider.mjs';
|
|
3
5
|
import { readOwnerState, writeOwnerState, withStateLock, withScopeHints, sameDeployment, refuse, recordSecretChange, settlePendingSecret } from './owner.mjs';
|
|
4
6
|
|
|
5
7
|
/** Set (`value` given) or remove one Worker secret of the owner's last deployment, then
|
|
6
8
|
* record the Worker version the change made in the state file, so the next deploy
|
|
7
9
|
* recognises its own Worker. A Worker someone else replaced is refused untouched.
|
|
8
10
|
* The change is recorded as pending first: if the provider applies it but rer never
|
|
9
|
-
* sees it settle, a later run
|
|
10
|
-
*
|
|
11
|
-
*
|
|
11
|
+
* sees it settle, a later run can recognise its immediate successor (see settlePendingSecret).
|
|
12
|
+
* Both success and ambiguous failure read back for up to `windowMs`. An explicit refusal
|
|
13
|
+
* clears the pending record. An accepted write keeps its intent until confirmed.
|
|
14
|
+
* An ambiguous failure clears it only when readback corroborates that the receipt's
|
|
15
|
+
* deployment is still live. An ambiguous rotation
|
|
16
|
+
* re-PUTs the value before accepting its successor as proof of the rotation.
|
|
12
17
|
* A removed declared secret is retired: later plans omit it until it is set again. */
|
|
13
|
-
export async function ownerSecret({api: provider, target, statePath, binding, value, windowMs = 30000, clock = () => performance.now(), sleep = ms => new Promise(resolve => setTimeout(resolve, ms))}) {
|
|
18
|
+
export async function ownerSecret({api: provider, target, statePath, binding, value, fetch = globalThis.fetch, healthOptions = {}, windowMs = 30000, clock = () => performance.now(), sleep = ms => new Promise(resolve => setTimeout(resolve, ms))}) {
|
|
14
19
|
const api = withScopeHints(provider);
|
|
15
20
|
assert(identifier(binding), 'invalid-secret-name');
|
|
16
21
|
assert(value === undefined || typeof value === 'string' && value.trim(), `missing-secret-${binding}`);
|
|
17
22
|
return withStateLock(statePath, async () => {
|
|
18
23
|
const state = await readOwnerState(statePath, target);
|
|
19
24
|
assert(state.receipt && !state.pendingUpload, 'missing-owner-receipt');
|
|
20
|
-
if (await settlePendingSecret(api, target.worker, state)) await writeOwnerState(statePath, state);
|
|
25
|
+
if (await settlePendingSecret(api, target.worker, state, {preflight:true})) await writeOwnerState(statePath, state);
|
|
21
26
|
const live = await activeWorker(api, target.worker);
|
|
22
27
|
if (!live) throw refuse('owned-resource-missing', 'worker', target.worker);
|
|
23
28
|
if (!sameDeployment(live, state.receipt.provider)) throw refuse('owned-resource-replaced', 'worker', target.worker);
|
|
24
29
|
const path = `/workers/scripts/${target.worker}/secrets`, present = value !== undefined;
|
|
25
30
|
const names = async () => { const rows = await api.request(path); assert(Array.isArray(rows), 'invalid-secret-list'); return rows.map(row => row.name); };
|
|
26
|
-
const
|
|
27
|
-
state.pendingSecret = {binding, present}; await writeOwnerState(statePath, state);
|
|
31
|
+
const existed = (await names()).includes(binding), changed = present || existed;
|
|
32
|
+
state.pendingSecret = {binding, present, versionId:state.receipt.provider.versionId}; await writeOwnerState(statePath, state);
|
|
33
|
+
let failure, unchanged = false, confirming = false;
|
|
34
|
+
const put = () => api.request(path, {method:'PUT', body:{name:binding, text:value, type:'secret_text'}});
|
|
28
35
|
try {
|
|
29
|
-
if (present) await
|
|
36
|
+
if (present) await put();
|
|
30
37
|
else if (changed) await api.request(`${path}/${binding}`, {method:'DELETE'});
|
|
31
38
|
} catch (error) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (error.status !== undefined) { delete state.pendingSecret; await writeOwnerState(statePath, state); }
|
|
35
|
-
throw error;
|
|
39
|
+
if (definiteProviderRefusal(error)) { delete state.pendingSecret; await writeOwnerState(statePath, state); throw error; }
|
|
40
|
+
failure = error;
|
|
36
41
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
try {
|
|
43
|
+
if (!changed) recordSecretChange(state, live, state.pendingSecret);
|
|
44
|
+
else for (const start = clock(); ; await sleep(2000)) {
|
|
45
|
+
try {
|
|
46
|
+
if (await settlePendingSecret(api, target.worker, state, {
|
|
47
|
+
observe: current => { unchanged = sameDeployment(current, state.receipt.provider); },
|
|
48
|
+
...(failure && present && existed ? {confirm: async current => {
|
|
49
|
+
confirming = true;
|
|
50
|
+
state.pendingSecret.repeatVersionId = current.versionId;
|
|
51
|
+
await writeOwnerState(statePath, state);
|
|
52
|
+
await put();
|
|
53
|
+
confirming = false;
|
|
54
|
+
}} : {})
|
|
55
|
+
})) break;
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
unchanged = false;
|
|
59
|
+
// Another ambiguous PUT cannot prove a rotation by presence either.
|
|
60
|
+
// Keep both starting versions for recovery; do not report it done.
|
|
61
|
+
if (confirming) throw error;
|
|
62
|
+
if (error.message !== 'provider-transport-failed' && !(error.status >= 500 && error.status < 600)) throw error;
|
|
63
|
+
}
|
|
64
|
+
if (clock() - start + 2000 > windowMs) throw failure ?? new Error('secret-change-unsettled');
|
|
65
|
+
}
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (unchanged && failure) delete state.pendingSecret;
|
|
68
|
+
await writeOwnerState(statePath, state);
|
|
69
|
+
throw failure ?? error;
|
|
42
70
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
71
|
+
const result = {worker:target.worker, binding, present, versionId:state.receipt.provider.versionId};
|
|
72
|
+
// Settlement is durable before readiness: a failed hosted check must never
|
|
73
|
+
// erase the applied mutation or cause the next command to reject our version.
|
|
74
|
+
state.receipt.health = {ok:false,versionId:result.versionId};
|
|
46
75
|
await writeOwnerState(statePath, state);
|
|
47
|
-
|
|
76
|
+
try {
|
|
77
|
+
if (state.receipt.origin) await waitHealth({
|
|
78
|
+
origin:state.receipt.origin, receipt:state.receipt, versionId:result.versionId, request:fetch,
|
|
79
|
+
timeoutMs:healthOptions.timeoutMs,
|
|
80
|
+
clock:healthOptions.now, sleep:healthOptions.sleep, ...healthOptions.readiness,
|
|
81
|
+
});
|
|
82
|
+
const after = await activeWorker(api, target.worker);
|
|
83
|
+
assert(after && sameDeployment(after, state.receipt.provider), 'deployment-changed-during-health');
|
|
84
|
+
// Response prefixes belong only in the caller's redacted diagnostic, never
|
|
85
|
+
// in the ownership record: an unhealthy app may accidentally echo a secret.
|
|
86
|
+
state.receipt.health = {ok:true,versionId:result.versionId};
|
|
87
|
+
await writeOwnerState(statePath, state);
|
|
88
|
+
return result;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
error.secretChange = result;
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
48
93
|
});
|
|
49
94
|
}
|