@runeditrun/rer 0.2.1 → 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 +21 -4
- package/cli/README.md +21 -0
- package/cli/auth-copy.mjs +25 -18
- package/cli/bounded.mjs +13 -4
- 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 +55 -0
- package/cli/deploy-observation.mjs +28 -0
- package/cli/deploy-owner.mjs +76 -0
- package/cli/deploy-readiness.mjs +16 -0
- package/cli/deploy.mjs +99 -17
- package/cli/detached-server.mjs +1 -1
- package/cli/door.mjs +1 -1
- package/cli/export.mjs +1 -1
- package/cli/hosted-output.mjs +2 -1
- package/cli/inspect.mjs +24 -32
- package/cli/lifecycle.mjs +31 -8
- package/cli/local.mjs +7 -0
- package/cli/main.mjs +1 -1
- package/cli/managed-server.mjs +27 -18
- package/cli/parse.mjs +9 -2
- package/cli/pnpm-probe.mjs +17 -0
- package/cli/pnpm-runtime.mjs +22 -22
- package/cli/portability-policy.mjs +55 -0
- package/cli/portability-worker.mjs +8 -4
- package/cli/portability.mjs +24 -5
- package/cli/project-pnpm.mjs +67 -0
- package/cli/release.mjs +35 -0
- package/cli/scheduled.mjs +2 -2
- package/cli/server-registry.mjs +2 -2
- package/cli/setup-input.mjs +1 -1
- package/cli/site-check.mjs +15 -6
- package/cli/site-data.mjs +6 -5
- package/cli/site-references.mjs +153 -3
- package/global/CHANGELOG.md +60 -0
- package/global/README.md +21 -4
- package/global/cli/install.mjs +26 -9
- package/global/cli/path-link.mjs +30 -0
- package/global/cli/status.mjs +1 -1
- package/global/cli/uninstall.mjs +5 -1
- package/global/launch/capacity.mjs +1 -1
- package/global/launch/facts.mjs +12 -6
- package/global/launch/install-pinned.mjs +30 -2
- package/global/launch/prepare.mjs +20 -8
- package/global/launch/state.mjs +54 -1
- package/global/main.mjs +43 -20
- package/global/registry.mjs +2 -2
- package/global/update/command.mjs +2 -1
- package/modules/container/build.mjs +4 -2
- package/modules/container/deploy-input.mjs +5 -9
- 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/dependencies/auth.mjs +2 -1
- package/modules/deploy-kit/README.md +52 -7
- package/modules/deploy-kit/d1-import.mjs +35 -0
- package/modules/deploy-kit/deploy.mjs +4 -3
- package/modules/deploy-kit/diagnostics.mjs +11 -1
- package/modules/deploy-kit/env-file.mjs +27 -0
- package/modules/deploy-kit/health-request.mjs +13 -3
- package/modules/deploy-kit/health.mjs +35 -15
- package/modules/deploy-kit/migration-ledger.mjs +85 -0
- package/modules/deploy-kit/migrations.mjs +48 -16
- package/modules/deploy-kit/owner-cli.mjs +42 -8
- package/modules/deploy-kit/owner-remove.mjs +108 -0
- package/modules/deploy-kit/owner-secret.mjs +94 -0
- package/modules/deploy-kit/owner.mjs +124 -19
- package/modules/deploy-kit/prepare.mjs +3 -1
- package/modules/deploy-kit/provider.mjs +18 -2
- package/modules/erasure/README.md +2 -3
- package/modules/job-queue/config.mjs +4 -2
- 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/identity/oidc/README.md +18 -0
- package/modules/operator-auth/identity/oidc/config.mjs +4 -2
- package/modules/operator-auth/identity/oidc/index.d.mts +1 -1
- package/modules/operator-auth/identity/oidc/index.mjs +28 -9
- package/modules/operator-auth/identity/oidc/network.mjs +9 -4
- package/modules/operator-auth/identity/oidc/store.mjs +19 -3
- package/modules/operator-auth/index.d.mts +2 -0
- package/modules/operator-auth/page.mjs +2 -2
- package/modules/operator-auth/routes.mjs +2 -2
- package/modules/portability/README.md +37 -0
- package/modules/portability/bridge.mjs +14 -4
- package/modules/portability/catalogue.mjs +9 -2
- package/modules/portability/import.mjs +4 -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/commands.mjs +3 -1
- 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
- package/scripts/publish-check/publication-config.json +7 -1
|
@@ -1,8 +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
|
-
|
|
5
|
-
const literal = value => `'${value.replaceAll("'","''")}'`;
|
|
6
|
+
import { ledger, literal, createLedger, readLedger, recorded } from './migration-ledger.mjs';
|
|
6
7
|
export function validateMigrations(migrations) {
|
|
7
8
|
let previous = '';
|
|
8
9
|
for (const migration of migrations) {
|
|
@@ -10,27 +11,58 @@ export function validateMigrations(migrations) {
|
|
|
10
11
|
assert(typeof migration.sql === 'string' && migration.sql.trim() && hash(migration.sql) === migration.sha256 && migration.sql.length <= 512000, 'invalid-migration-bytes');
|
|
11
12
|
assertMigrationSqlSafe(migration.sql);
|
|
12
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');
|
|
13
15
|
}
|
|
14
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
|
+
}
|
|
15
50
|
export async function query(api, id, sql) {
|
|
16
51
|
const rows = await api.request(`/d1/database/${id}/query`,{method:'POST',body:{sql}});
|
|
17
52
|
assert(Array.isArray(rows) && rows.length > 0 && rows.every(x => x.success === true && Array.isArray(x.results)), 'd1-query-failed');
|
|
18
53
|
return rows;
|
|
19
54
|
}
|
|
20
|
-
export async function migrate(api, id, migrations, { apply = true } = {}) {
|
|
55
|
+
export async function migrate(api, id, migrations, { apply = true, upgrade = apply } = {}) {
|
|
21
56
|
validateMigrations(migrations);
|
|
22
|
-
const
|
|
23
|
-
let applied = [];
|
|
24
|
-
if (tables.includes(ledger)) applied = (await query(api,id,`SELECT name, sha256, state FROM ${ledger} ORDER BY name`))[0].results;
|
|
25
|
-
// Wrangler history has no hashes. Never assume old SQL bytes from its names alone.
|
|
26
|
-
if (tables.includes('d1_migrations')) {
|
|
27
|
-
const prior = (await query(api,id,'SELECT name FROM d1_migrations ORDER BY id'))[0].results;
|
|
28
|
-
assert(prior.every(x => applied.some(y => x.name === y.name && y.state === 'applied')), 'legacy-migration-history-needs-reviewed-hash-adoption');
|
|
29
|
-
}
|
|
30
|
-
assert(applied.every((row,index) => row.name === migrations[index]?.name && row.sha256 === migrations[index]?.sha256 && row.state === 'applied'), 'changed-or-uncertain-migration-history');
|
|
57
|
+
const { applied, transition, exists } = await readLedger(api, id, migrations, query);
|
|
31
58
|
const pending = migrations.slice(applied.length);
|
|
32
59
|
assert(apply || pending.length === 0,'pending-migrations');
|
|
33
|
-
if (
|
|
60
|
+
if (transition && upgrade) {
|
|
61
|
+
await api.importSql(id, transition);
|
|
62
|
+
const verified = await readLedger(api, id, migrations, query);
|
|
63
|
+
assert(!verified.transition && verified.applied.length === applied.length, 'migration-readback-mismatch');
|
|
64
|
+
}
|
|
65
|
+
if (pending.length && !exists) await query(api,id,createLedger);
|
|
34
66
|
for (const migration of pending) {
|
|
35
67
|
// Claim before execution. A lost response/partial provider failure is never automatically replayed.
|
|
36
68
|
await query(api,id,`INSERT INTO ${ledger}(name,sha256,state) VALUES (${literal(migration.name)},${literal(migration.sha256)},'applying')`);
|
|
@@ -38,11 +70,11 @@ export async function migrate(api, id, migrations, { apply = true } = {}) {
|
|
|
38
70
|
// A second terminator after trailing comments creates an empty D1 statement.
|
|
39
71
|
// Keep the newline even when terminated: a final line comment must end.
|
|
40
72
|
const separator = last?.kind === 'symbol' && last.value === ';' ? '' : '; ';
|
|
41
|
-
await
|
|
73
|
+
await api.importSql(id,`${migration.sql}\n${separator}UPDATE ${ledger} SET state='applied' WHERE name=${literal(migration.name)} AND sha256=${literal(migration.sha256)};`);
|
|
42
74
|
}
|
|
43
75
|
if (pending.length) {
|
|
44
|
-
const final = (await query(api,id,`SELECT name, sha256, state FROM ${ledger} ORDER BY
|
|
45
|
-
assert(final.length === migrations.length && final.every((x,i) => x.state === 'applied' && x
|
|
76
|
+
const final = (await query(api,id,`SELECT name, sha256, state FROM ${ledger} ORDER BY id`))[0].results;
|
|
77
|
+
assert(final.length === migrations.length && final.every((x,i) => x.state === 'applied' && recorded(x, migrations[i])), 'migration-readback-mismatch');
|
|
46
78
|
}
|
|
47
79
|
return { applied:pending.map(x => x.name), history:migrations.map(({name,sha256}) => ({name,sha256})) };
|
|
48
80
|
}
|
|
@@ -7,11 +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 } from './owner.mjs';
|
|
10
|
+
import { ownerTarget, ownerPlan, ownerDeploy, ownerHealth, settleOwnerState, sameDeployment, localOnlyBinding, withStateLock, readOwnerState, writeOwnerState } from './owner.mjs';
|
|
11
|
+
import { activeWorker } from './worker.mjs';
|
|
12
|
+
import { ownerSecret } from './owner-secret.mjs';
|
|
13
|
+
import { ownerRemove } from './owner-remove.mjs';
|
|
11
14
|
import { refuseLocalModels } from './local-only.mjs';
|
|
12
15
|
|
|
13
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.';
|
|
14
|
-
const usage = 'usage: owner-cli.mjs deploy|health --declaration <file> --environment <name> [--product <dir>] [--state <file>]';
|
|
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';
|
|
15
18
|
|
|
16
19
|
/** The owner's committed source and existing local build. Run the seed's build first. */
|
|
17
20
|
async function ownerArtifact(root, product) {
|
|
@@ -22,12 +25,14 @@ async function ownerArtifact(root, product) {
|
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
/** Inputs: CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, plus the secrets the declaration names on first deploy. */
|
|
25
|
-
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} = {}) {
|
|
26
29
|
let parsed;
|
|
27
|
-
try { parsed = parseArgs({args:argv,allowPositionals:true,strict:true,options:{declaration:{type:'string'},environment:{type:'string'},product:{type:'string',default:''},state:{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'}}}); }
|
|
28
31
|
catch { throw new Error('usage'); }
|
|
29
32
|
const {positionals:[command,...rest],values} = parsed;
|
|
30
|
-
if (!['deploy','health'].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');
|
|
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');
|
|
31
36
|
await refuseLocalModels(resolve(cwd));
|
|
32
37
|
const missing = ['CLOUDFLARE_ACCOUNT_ID','CLOUDFLARE_API_TOKEN'].filter(key => typeof env[key] !== 'string' || !env[key].trim());
|
|
33
38
|
if (missing.length) { const error = new Error('missing-input'); error.inputs = missing; throw error; }
|
|
@@ -40,19 +45,48 @@ export async function main(argv = process.argv.slice(2), env = process.env, {fet
|
|
|
40
45
|
const api = deploymentDiagnostics(cloudflare({accountId:env.CLOUDFLARE_ACCOUNT_ID,token:env.CLOUDFLARE_API_TOKEN,fetch}),{onCreated});
|
|
41
46
|
const target = await ownerTarget(api,{project:declaration.project,environment:values.environment});
|
|
42
47
|
const statePath = values.state ? resolve(root,values.state) : join(root,'.rer','deploy',`${target.environment}.json`);
|
|
48
|
+
if (command === 'remove') return {ok:true,...await ownerRemove({api,target,statePath}).catch(error => { throw api.annotateFailure(error,'remove'); })};
|
|
49
|
+
if (command === 'secret') {
|
|
50
|
+
const binding = values.set ?? values.unset, item = (Array.isArray(declaration.secrets) ? declaration.secrets : []).find(entry => entry?.binding === binding);
|
|
51
|
+
if (!item) { const error = new Error('undeclared-secret'); error.resource = `secret:${binding}`; throw error; }
|
|
52
|
+
const value = values.set === undefined ? undefined : env[item.environment];
|
|
53
|
+
return {ok:true,...await ownerSecret({api,target,statePath,binding,value,fetch,healthOptions}).catch(error => { throw api.annotateFailure(error,'secrets'); })};
|
|
54
|
+
}
|
|
55
|
+
// A secret rer retired (the spent setup token) is no longer part of the plan.
|
|
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
|
+
}
|
|
76
|
+
const current = retiredSecrets.length && Array.isArray(declaration.secrets) ? {...declaration,secrets:declaration.secrets.filter(item => !retiredSecrets.includes(item?.binding))} : declaration;
|
|
43
77
|
const artifact = await ownerArtifact(root,values.product);
|
|
44
|
-
const plan = ownerPlan(target,
|
|
78
|
+
const plan = ownerPlan(target,current,artifact,{localOnlyKeys});
|
|
45
79
|
if (command === 'health') {
|
|
46
80
|
await ownerHealth({api,target,plan,statePath,fetch,healthOptions:{...healthOptions,readiness:{single:true,totalMs:30000}}}).catch(error=>{throw api.annotateFailure(error,'health');});
|
|
47
81
|
return {ok:true,worker:target.worker,origin:target.origin};
|
|
48
82
|
}
|
|
49
83
|
// Dispatch is the provider's: on some accounts a new Cron starts only at the account scheduler's next refresh.
|
|
50
84
|
if (plan.crons.length) notice(cronNotice);
|
|
51
|
-
const {receipt,created} = 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 => {
|
|
52
86
|
api.annotateFailure(error,error.message.startsWith('missing-secret-') ? 'secrets' : undefined);
|
|
53
87
|
throw error;
|
|
54
88
|
});
|
|
55
|
-
return {ok:true,changed:receipt.changed,worker:target.worker,origin:receipt.origin,versionId:receipt.provider.versionId,created,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};
|
|
56
90
|
}
|
|
57
91
|
|
|
58
92
|
/** Prints refusal codes and resource names only; never inputs, tokens or provider bodies. */
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { rm } from 'node:fs/promises';
|
|
2
|
+
import { assert } from './validation.mjs';
|
|
3
|
+
import { resourceKinds, findResource } from './resources.mjs';
|
|
4
|
+
import { activeWorker } from './worker.mjs';
|
|
5
|
+
import { deleteWorker } from './teardown.mjs';
|
|
6
|
+
import { workerConsumerScript } from './queue-consumer.mjs';
|
|
7
|
+
import { query } from './migrations.mjs';
|
|
8
|
+
import { KINDS, readOwnerState, writeOwnerState, withStateLock, withScopeHints, withSettledLists, workerSettings, sameDeployment, resourceId, checkResourceIdentity, refuse, settlePendingSecret } from './owner.mjs';
|
|
9
|
+
|
|
10
|
+
const remove = {
|
|
11
|
+
d1: (api, id) => api.request(`/d1/database/${id}`, {method:'DELETE'}),
|
|
12
|
+
r2: (api, _id, name) => api.request(`/r2/buckets/${name}`, {method:'DELETE'}),
|
|
13
|
+
kv: (api, id) => api.request(`/storage/kv/namespaces/${id}`, {method:'DELETE'}),
|
|
14
|
+
queues: (api, id) => api.request(`/queues/${id}`, {method:'DELETE'}),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** A resource whose create response was lost (still `pending` in the state file) may be
|
|
18
|
+
* rer's or someone else's. It is removed only under this environment's derived name and
|
|
19
|
+
* once it is confirmed empty: a D1 with no tables, a KV namespace with no keys, an R2
|
|
20
|
+
* bucket (the provider refuses to delete one holding objects). A queue's backlog cannot
|
|
21
|
+
* be read, so an uncertain queue refuses with its own code. */
|
|
22
|
+
async function confirmEmpty(api, worker, kind, name, id) {
|
|
23
|
+
if (!name.startsWith(`${worker}-`)) throw refuse('uncertain-resource-create', kind, name);
|
|
24
|
+
if (kind === 'queues') throw refuse('uncertain-resource-unverifiable', kind, name);
|
|
25
|
+
if (kind === 'd1') {
|
|
26
|
+
const [{results}] = await query(api, id, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite~_%' ESCAPE '~' AND name NOT LIKE '~_cf~_%' ESCAPE '~'");
|
|
27
|
+
if (results.length) throw refuse('uncertain-resource-not-empty', kind, name);
|
|
28
|
+
}
|
|
29
|
+
if (kind === 'kv') {
|
|
30
|
+
const keys = await api.request(`/storage/kv/namespaces/${id}/keys?limit=10`);
|
|
31
|
+
assert(Array.isArray(keys), 'invalid-kv-key-list');
|
|
32
|
+
if (keys.length) throw refuse('uncertain-resource-not-empty', kind, name);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Delete exactly what this environment's state file says rer created: the Worker (its
|
|
37
|
+
* schedules first; its Durable Objects go with it), then each D1, R2, KV and queue,
|
|
38
|
+
* each only while it is still the one rer created. Read back that each is gone, then
|
|
39
|
+
* drop the state file. Nothing else in the account is touched. Ownership is checked for
|
|
40
|
+
* everything before the first delete; a replaced or uncertain resource refuses it all,
|
|
41
|
+
* except an uncertain one confirmed empty (see confirmEmpty). */
|
|
42
|
+
export async function ownerRemove({api: provider, target, statePath, clock, sleep}) {
|
|
43
|
+
const api = withSettledLists(withScopeHints(provider));
|
|
44
|
+
return withStateLock(statePath, async () => {
|
|
45
|
+
const state = await readOwnerState(statePath, target);
|
|
46
|
+
if (await settlePendingSecret(api, target.worker, state, {preflight:true})) await writeOwnerState(statePath, state);
|
|
47
|
+
const owned = KINDS.flatMap(kind => Object.entries(state.owned[kind]).map(([name, entry]) => ({kind, name, entry})));
|
|
48
|
+
assert(state.owned.worker || owned.length, 'nothing-to-remove');
|
|
49
|
+
// Preflight, read-only.
|
|
50
|
+
let worker = null;
|
|
51
|
+
if (await workerSettings(api, target.worker) !== null) {
|
|
52
|
+
if (!state.owned.worker) throw refuse('resource-name-collision', 'worker', target.worker);
|
|
53
|
+
const expected = state.pendingUpload?.provider ?? state.receipt?.provider;
|
|
54
|
+
if (!expected) throw refuse('uncertain-resource-create', 'worker', target.worker);
|
|
55
|
+
const live = await activeWorker(api, target.worker);
|
|
56
|
+
if (!sameDeployment(live, expected)) throw refuse('owned-resource-replaced', 'worker', target.worker);
|
|
57
|
+
worker = target.worker;
|
|
58
|
+
}
|
|
59
|
+
const listed = {}, doomed = [];
|
|
60
|
+
for (const kind of KINDS) {
|
|
61
|
+
if (!owned.some(item => item.kind === kind)) continue;
|
|
62
|
+
listed[kind] = await resourceKinds[kind].list(api);
|
|
63
|
+
for (const {name, entry} of owned.filter(item => item.kind === kind)) {
|
|
64
|
+
const row = findResource(listed[kind], kind, name);
|
|
65
|
+
if (!row) continue;
|
|
66
|
+
if (entry.status === 'pending') await confirmEmpty(api, target.worker, kind, name, resourceId(kind, row));
|
|
67
|
+
else checkResourceIdentity(kind, name, row, entry);
|
|
68
|
+
doomed.push({kind, name, id:resourceId(kind, row)});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const removed = [];
|
|
72
|
+
// A Worker that consumes one of these queues is released first.
|
|
73
|
+
for (const queue of doomed.filter(item => item.kind === 'queues')) {
|
|
74
|
+
for (const consumer of await api.list(`/queues/${queue.id}/consumers`, {pagination:'single'})) {
|
|
75
|
+
if (workerConsumerScript(consumer) === target.worker) await api.request(`/queues/${queue.id}/consumers/${consumer.consumer_id}`, {method:'DELETE'});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (worker) { await deleteWorker({api, worker, ...(clock ? {clock} : {}), ...(sleep ? {sleep} : {})}); removed.push(`worker:${worker}`); }
|
|
79
|
+
const failed = [];
|
|
80
|
+
for (const item of doomed) {
|
|
81
|
+
try { await remove[item.kind](api, item.id, item.name); removed.push(`${item.kind}:${item.name}`); }
|
|
82
|
+
catch (error) { failed.push({resource:`${item.kind}:${item.name}`, code:error.message}); }
|
|
83
|
+
}
|
|
84
|
+
// Read back: nothing this environment created is still listed.
|
|
85
|
+
const remaining = [];
|
|
86
|
+
if (await workerSettings(api, target.worker) !== null) remaining.push(`worker:${target.worker}`);
|
|
87
|
+
const namespaces = await api.list('/workers/durable_objects/namespaces');
|
|
88
|
+
for (const row of namespaces) if (row.script === target.worker) remaining.push(`durable-object:${row.class}`);
|
|
89
|
+
for (const kind of KINDS) {
|
|
90
|
+
if (!listed[kind]) continue;
|
|
91
|
+
const rows = await resourceKinds[kind].list(api);
|
|
92
|
+
for (const {name} of owned.filter(item => item.kind === kind)) if (findResource(rows, kind, name)) remaining.push(`${kind}:${name}`);
|
|
93
|
+
}
|
|
94
|
+
if (remaining.length) {
|
|
95
|
+
// Keep the record of what is left, so a later --remove finishes it.
|
|
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}`)) {
|
|
98
|
+
state.owned.worker = null; state.receipt = null; delete state.pendingUpload;
|
|
99
|
+
delete state.ownerClaim; delete state.retiredSecrets;
|
|
100
|
+
}
|
|
101
|
+
await writeOwnerState(statePath, state);
|
|
102
|
+
const error = new Error('remove-incomplete'); error.remaining = remaining; error.failed = failed; error.removed = removed;
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
await rm(statePath, {force:true});
|
|
106
|
+
return {worker:target.worker, removed, remaining:[]};
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { assert, identifier } from './validation.mjs';
|
|
2
|
+
import { activeWorker } from './worker.mjs';
|
|
3
|
+
import { waitHealth } from './health-request.mjs';
|
|
4
|
+
import { definiteProviderRefusal } from './provider.mjs';
|
|
5
|
+
import { readOwnerState, writeOwnerState, withStateLock, withScopeHints, sameDeployment, refuse, recordSecretChange, settlePendingSecret } from './owner.mjs';
|
|
6
|
+
|
|
7
|
+
/** Set (`value` given) or remove one Worker secret of the owner's last deployment, then
|
|
8
|
+
* record the Worker version the change made in the state file, so the next deploy
|
|
9
|
+
* recognises its own Worker. A Worker someone else replaced is refused untouched.
|
|
10
|
+
* The change is recorded as pending first: if the provider applies it but rer never
|
|
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.
|
|
17
|
+
* A removed declared secret is retired: later plans omit it until it is set again. */
|
|
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))}) {
|
|
19
|
+
const api = withScopeHints(provider);
|
|
20
|
+
assert(identifier(binding), 'invalid-secret-name');
|
|
21
|
+
assert(value === undefined || typeof value === 'string' && value.trim(), `missing-secret-${binding}`);
|
|
22
|
+
return withStateLock(statePath, async () => {
|
|
23
|
+
const state = await readOwnerState(statePath, target);
|
|
24
|
+
assert(state.receipt && !state.pendingUpload, 'missing-owner-receipt');
|
|
25
|
+
if (await settlePendingSecret(api, target.worker, state, {preflight:true})) await writeOwnerState(statePath, state);
|
|
26
|
+
const live = await activeWorker(api, target.worker);
|
|
27
|
+
if (!live) throw refuse('owned-resource-missing', 'worker', target.worker);
|
|
28
|
+
if (!sameDeployment(live, state.receipt.provider)) throw refuse('owned-resource-replaced', 'worker', target.worker);
|
|
29
|
+
const path = `/workers/scripts/${target.worker}/secrets`, present = value !== undefined;
|
|
30
|
+
const names = async () => { const rows = await api.request(path); assert(Array.isArray(rows), 'invalid-secret-list'); return rows.map(row => row.name); };
|
|
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'}});
|
|
35
|
+
try {
|
|
36
|
+
if (present) await put();
|
|
37
|
+
else if (changed) await api.request(`${path}/${binding}`, {method:'DELETE'});
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (definiteProviderRefusal(error)) { delete state.pendingSecret; await writeOwnerState(statePath, state); throw error; }
|
|
40
|
+
failure = error;
|
|
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;
|
|
70
|
+
}
|
|
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};
|
|
75
|
+
await writeOwnerState(statePath, state);
|
|
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
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { ingressBindings } from './container-config.mjs';
|
|
2
2
|
import { mkdir, open, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
|
-
import { assert, hash, identifier, resourceName, validatePlan } from './validation.mjs';
|
|
4
|
+
import { assert, hash, identifier, resourceName, uuid, validatePlan } from './validation.mjs';
|
|
5
|
+
import { definiteProviderRefusal } from './provider.mjs';
|
|
5
6
|
import { regularBytes } from './artifact.mjs';
|
|
6
7
|
import { resourceKinds, findResource, checkCronOwnership } from './resources.mjs';
|
|
7
8
|
import { secretBindings, activeWorker, deployedFingerprint } from './worker.mjs';
|
|
@@ -16,11 +17,11 @@ import { verifyHealth } from './health.mjs';
|
|
|
16
17
|
|
|
17
18
|
const segment = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
18
19
|
const STATE_KIND = 'rer-deploy-owner-state';
|
|
19
|
-
const KINDS = ['d1','r2','kv','queues'];
|
|
20
|
+
export const KINDS = ['d1','r2','kv','queues'];
|
|
20
21
|
const DECLARATION_KEYS = ['version','project','compatibilityDate','compatibilityFlags','d1','r2','kv','queues','durableObjects','secrets','crons','vars','originVar','services','workersDev','platforms','container'];
|
|
21
22
|
const ENTRY_KEYS = {d1:['binding'],r2:['binding','locationHint'],kv:['binding'],queues:['binding','consumer','deadLetterQueue'],durableObjects:['binding','className','storage'],secrets:['binding','environment','verifyStandIn'],services:['binding','project','entrypoint','allowedHosts','grant']};
|
|
22
23
|
|
|
23
|
-
function refuse(code, kind, name) { const error = new Error(code); error.resource = `${kind}:${name}`; return error; }
|
|
24
|
+
export function refuse(code, kind, name) { const error = new Error(code); error.resource = `${kind}:${name}`; return error; }
|
|
24
25
|
|
|
25
26
|
export function ownerWorkerName(project, environment) {
|
|
26
27
|
assert(typeof project === 'string' && /^(?:[a-z0-9][a-z0-9-]*\/)?[a-z0-9-]+$/.test(project) && segment.test(project.split('/').at(-1)), 'invalid-owner-project');
|
|
@@ -49,6 +50,37 @@ export function withScopeHints(api) {
|
|
|
49
50
|
const guard = method => async (path, ...rest) => { try { return await api[method](path, ...rest); } catch (error) { return hint(path, error); } };
|
|
50
51
|
return {...api, scopeHints:true, request:guard('request'), envelope:guard('envelope'), list:guard('list')};
|
|
51
52
|
}
|
|
53
|
+
/** Right after a create, an account listing can disagree with its own page counts
|
|
54
|
+
* (D1's total_count lagged its rows by about 7 seconds, measured 13 October 2026) or
|
|
55
|
+
* not show the new resource yet. Owner mode discards a partial snapshot and lists
|
|
56
|
+
* again from the start for up to `windowMs`; a partial listing is never accepted. */
|
|
57
|
+
export const LIST_WINDOW_MS = 120000;
|
|
58
|
+
const settleable = error => ['provider-partial-list','provider-pagination-changed'].includes(error.message);
|
|
59
|
+
export function withSettledLists(api, {windowMs = LIST_WINDOW_MS, clock = () => performance.now(), sleep = ms => new Promise(resolve => setTimeout(resolve, ms))} = {}) {
|
|
60
|
+
if (api.settledLists) return api;
|
|
61
|
+
const list = async (...args) => {
|
|
62
|
+
const start = clock();
|
|
63
|
+
for (let delay = 500; ; delay = Math.min(delay * 2, 4000)) {
|
|
64
|
+
try { return await api.list(...args); }
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (!settleable(error) || clock() - start + delay > windowMs) throw error;
|
|
67
|
+
await sleep(delay);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
return {...api, settledLists:true, unsettled:api, list};
|
|
72
|
+
}
|
|
73
|
+
/** Waits until the account's listing shows a resource this run just created, so no later
|
|
74
|
+
* read mistakes it for a missing or replaced one. One that never appears is named. */
|
|
75
|
+
async function listedAfterCreate(api, kind, item, {windowMs = LIST_WINDOW_MS, clock = () => performance.now(), sleep = ms => new Promise(resolve => setTimeout(resolve, ms))} = {}) {
|
|
76
|
+
const start = clock();
|
|
77
|
+
for (let delay = 500; ; delay = Math.min(delay * 2, 4000)) {
|
|
78
|
+
try { if (findResource(await resourceKinds[kind].list(api.unsettled ?? api),kind,item.name)) return; }
|
|
79
|
+
catch (error) { if (!settleable(error)) throw error; }
|
|
80
|
+
if (clock() - start + delay > windowMs) throw refuse('resource-not-listed',kind,item.name);
|
|
81
|
+
await sleep(delay);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
52
84
|
/** Reads the account's workers.dev subdomain; no other provider request. */
|
|
53
85
|
export async function ownerTarget(provider, {project, environment}) {
|
|
54
86
|
const api = withScopeHints(provider);
|
|
@@ -66,9 +98,10 @@ function entries(declaration, key) {
|
|
|
66
98
|
assert(Array.isArray(list) && list.every(item => item && typeof item === 'object' && !Array.isArray(item) && Object.keys(item).every(k => ENTRY_KEYS[key].includes(k))), 'invalid-owner-declaration');
|
|
67
99
|
return list;
|
|
68
100
|
}
|
|
69
|
-
/* RER_DEV_KEY opens local dev identity
|
|
70
|
-
*
|
|
71
|
-
|
|
101
|
+
/* RER_DEV_KEY opens local dev identity and RER_DEMO turns on demo mode (nothing
|
|
102
|
+
* sent, throwaway data); they and every key a seed adapter declares local-only
|
|
103
|
+
* never become a deployed binding, secret or secret source. */
|
|
104
|
+
export const LOCAL_ONLY_KEYS = Object.freeze(['RER_DEV_KEY', 'RER_DEMO', 'RER_MODELS_KEY', 'RER_MODELS_SERVICE', '.rer/local/models.json']);
|
|
72
105
|
export function localOnlyBinding(declaration, localOnlyKeys = []) {
|
|
73
106
|
const refused = new Set([...LOCAL_ONLY_KEYS, ...localOnlyKeys]);
|
|
74
107
|
const names = [...Object.keys(declaration?.vars ?? {}), declaration?.originVar, ...(Array.isArray(declaration?.secrets) ? declaration.secrets.flatMap(item => [item?.binding, item?.environment]) : [])];
|
|
@@ -134,18 +167,24 @@ export async function readOwnerState(path, target) {
|
|
|
134
167
|
&& (state.owned.worker === null || ['pending','created'].includes(state.owned.worker?.status))
|
|
135
168
|
&& KINDS.every(kind => state.owned[kind] && typeof state.owned[kind] === 'object' && Object.values(state.owned[kind]).every(entry => ['pending','created'].includes(entry?.status))), 'invalid-owner-state');
|
|
136
169
|
assert(state.pendingUpload === undefined || typeof state.pendingUpload?.fingerprint === 'string' && typeof state.pendingUpload?.build === 'string', 'invalid-owner-state');
|
|
170
|
+
// Declared secrets rer removed on purpose (the spent setup token): later plans omit them.
|
|
171
|
+
assert(state.pendingSecret === undefined || identifier(state.pendingSecret?.binding) && typeof state.pendingSecret?.present === 'boolean'
|
|
172
|
+
&& (state.pendingSecret.versionId === undefined || uuid(state.pendingSecret.versionId))
|
|
173
|
+
&& (state.pendingSecret.repeatVersionId === undefined || uuid(state.pendingSecret.repeatVersionId)), 'invalid-owner-state');
|
|
174
|
+
assert(state.ownerClaim === undefined || ['claimed','kept'].includes(state.ownerClaim?.outcome), 'invalid-owner-state');
|
|
175
|
+
assert(state.retiredSecrets === undefined || Array.isArray(state.retiredSecrets) && state.retiredSecrets.every(identifier), 'invalid-owner-state');
|
|
137
176
|
assert(state.accountId === target.accountId && state.project === target.project && state.environment === target.environment && state.worker === target.worker, 'owner-state-target-mismatch');
|
|
138
177
|
return state;
|
|
139
178
|
}
|
|
140
|
-
async function writeOwnerState(path, state) {
|
|
179
|
+
export async function writeOwnerState(path, state) {
|
|
141
180
|
await mkdir(dirname(path),{recursive:true,mode:0o700});
|
|
142
181
|
const temporary = `${path}.${process.pid}.tmp`;
|
|
143
182
|
await writeFile(temporary,JSON.stringify(state,null,2)+'\n',{flag:'wx',mode:0o600});
|
|
144
183
|
await rename(temporary,path);
|
|
145
184
|
}
|
|
146
|
-
const resourceId = (kind, row) => ({d1:row.uuid,r2:row.creation_date,kv:row.id,queues:row.queue_id})[kind];
|
|
185
|
+
export const resourceId = (kind, row) => ({d1:row.uuid,r2:row.creation_date,kv:row.id,queues:row.queue_id})[kind];
|
|
147
186
|
|
|
148
|
-
function checkResourceIdentity(kind, name, row, entry) {
|
|
187
|
+
export function checkResourceIdentity(kind, name, row, entry) {
|
|
149
188
|
if (row) {
|
|
150
189
|
if (!entry) throw refuse('resource-name-collision',kind,name);
|
|
151
190
|
if (entry.status !== 'created') throw refuse('uncertain-resource-create',kind,name);
|
|
@@ -194,7 +233,65 @@ async function uploadFingerprint(body) {
|
|
|
194
233
|
assert(typeof fingerprint === 'string' && fingerprint, 'invalid-upload-metadata');
|
|
195
234
|
return fingerprint;
|
|
196
235
|
}
|
|
197
|
-
|
|
236
|
+
/** Record a secret change's outcome on the receipt: the Worker version it made, and whether
|
|
237
|
+
* the secret is now retired (a removed declared secret stays out of later plans). */
|
|
238
|
+
export function recordSecretChange(state, current, {binding, present}) {
|
|
239
|
+
state.receipt.provider = {versionId:current.versionId, deploymentId:current.deploymentId, etag:current.etag};
|
|
240
|
+
const retired = new Set(state.retiredSecrets ?? []);
|
|
241
|
+
if (present) retired.delete(binding); else retired.add(binding);
|
|
242
|
+
if (retired.size) state.retiredSecrets = [...retired].sort(); else delete state.retiredSecrets;
|
|
243
|
+
delete state.pendingSecret;
|
|
244
|
+
}
|
|
245
|
+
/** A secret change rer recorded as pending but never confirmed (a lost response, or a run that
|
|
246
|
+
* ended before the change settled). It is that change only when the Worker's versions list shows
|
|
247
|
+
* exactly one version after the recorded starting version and it is the live one, still running the receipt's
|
|
248
|
+
* exact script and build fingerprint: adopt its version. A confirming PUT also records its
|
|
249
|
+
* own starting version before it writes; its immediate successor can be recovered by the
|
|
250
|
+
* same checks. Anything later is left to the identity check, which refuses it. */
|
|
251
|
+
export async function settlePendingSecret(api, worker, state, {preflight = false, observe, confirm} = {}) {
|
|
252
|
+
if (!state.pendingSecret || !state.receipt) return false;
|
|
253
|
+
const live = await activeWorker(api, worker);
|
|
254
|
+
if (sameDeployment(live, state.receipt.provider)) {
|
|
255
|
+
// A new command sees that the abandoned write made no live version change.
|
|
256
|
+
// During the write's own readback this can still be propagation delay.
|
|
257
|
+
if (!preflight) {
|
|
258
|
+
// Deployment propagation can lag a secret write's version. Only a
|
|
259
|
+
// matching newest version corroborates that the write was unapplied.
|
|
260
|
+
const versions = (await api.request(`/workers/scripts/${worker}/versions`))?.items;
|
|
261
|
+
observe?.(Array.isArray(versions) && versions[0]?.id === live.versionId ? live : null);
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
delete state.pendingSecret;
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
observe?.(live);
|
|
268
|
+
if (state.pendingSecret.versionId !== state.receipt.provider.versionId || !live || live.etag !== state.receipt.provider.etag || deployedFingerprint(live.settings) !== state.receipt.fingerprint) return false;
|
|
269
|
+
// Newest first, as the provider lists them.
|
|
270
|
+
const versions = (await api.request(`/workers/scripts/${worker}/versions`))?.items;
|
|
271
|
+
const ids = Array.isArray(versions) ? versions.map(item => item?.id) : [];
|
|
272
|
+
const {versionId, repeatVersionId} = state.pendingSecret;
|
|
273
|
+
const immediate = ids.indexOf(versionId) === 1;
|
|
274
|
+
const repeated = repeatVersionId && ids[1] === repeatVersionId && ids.indexOf(versionId) === 2;
|
|
275
|
+
// A rotation's ambiguous first answer requires a second PUT before success.
|
|
276
|
+
// Its starting version is saved before that PUT, so interruption can recover
|
|
277
|
+
// either the original immediate successor or the second write's successor.
|
|
278
|
+
if (ids[0] !== live.versionId || !(repeatVersionId ? repeated || preflight && immediate && live.versionId === repeatVersionId : immediate)) return false;
|
|
279
|
+
const rows = await api.request(`/workers/scripts/${worker}/secrets`);
|
|
280
|
+
if (!Array.isArray(rows) || rows.some(row => row.name === state.pendingSecret.binding) !== state.pendingSecret.present) return false;
|
|
281
|
+
if (confirm && !repeatVersionId) { await confirm(live); return false; }
|
|
282
|
+
recordSecretChange(state, live, state.pendingSecret);
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
/** Settle a pending secret change under the state lock, before a plan reads the retired list. */
|
|
286
|
+
export async function settleOwnerState({api: provider, target, statePath}) {
|
|
287
|
+
const api = withScopeHints(provider);
|
|
288
|
+
return withStateLock(statePath, async () => {
|
|
289
|
+
const state = await readOwnerState(statePath, target);
|
|
290
|
+
if (await settlePendingSecret(api, target.worker, state, {preflight:true})) await writeOwnerState(statePath, state);
|
|
291
|
+
return state;
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
export const sameDeployment = (a, b) => a?.versionId === b?.versionId && a?.deploymentId === b?.deploymentId && a?.etag === b?.etag;
|
|
198
295
|
|
|
199
296
|
/** A completed receipt or the exact identity of an interrupted successful upload proves ownership.
|
|
200
297
|
* Only the first upload, with no receipt, may recover a lost response by its pending build. */
|
|
@@ -236,30 +333,38 @@ export async function withStateLock(path, run) {
|
|
|
236
333
|
|
|
237
334
|
/** Find or create every declared resource under the owner's account, migrate, upload and check health.
|
|
238
335
|
* Ownership preflight precedes provider writes. A re-run of the same build writes nothing. */
|
|
239
|
-
export async function ownerDeploy({api: provider, target, plan, artifact, statePath, env = {}, fetch = globalThis.fetch, healthOptions = {}, readiness = {}}) {
|
|
336
|
+
export async function ownerDeploy({api: provider, target, plan, artifact, statePath, env = {}, fetch = globalThis.fetch, healthOptions = {}, readiness = {}, settle = {}}) {
|
|
240
337
|
assert(typeof statePath === 'string' && statePath.length, 'missing-owner-state-path');
|
|
241
|
-
const api = withScopeHints(provider);
|
|
338
|
+
const api = withSettledLists(withScopeHints(provider),settle);
|
|
242
339
|
assert(target?.accountId === api.accountId, 'owner-account-mismatch');
|
|
243
340
|
validatePlan(plan); assertOwnerNames(target, plan);
|
|
244
341
|
for (const item of plan.d1) validateMigrations(item.migrations);
|
|
245
|
-
return withStateLock(statePath, () => deployLocked({api, target, plan, artifact, statePath, env, fetch, healthOptions, readiness}));
|
|
342
|
+
return withStateLock(statePath, () => deployLocked({api, target, plan, artifact, statePath, env, fetch, healthOptions, readiness, settle}));
|
|
246
343
|
}
|
|
247
|
-
async function deployLocked({api, target, plan, artifact, statePath, env, fetch, healthOptions, readiness}) {
|
|
344
|
+
async function deployLocked({api, target, plan, artifact, statePath, env, fetch, healthOptions, readiness, settle}) {
|
|
248
345
|
const state = await readOwnerState(statePath, target);
|
|
249
346
|
const save = () => writeOwnerState(statePath, state);
|
|
347
|
+
if (await settlePendingSecret(api, target.worker, state, {preflight:true})) await save();
|
|
250
348
|
await checkServiceWorkers(api, plan);
|
|
251
349
|
const priorWorker = await checkOwnedWorker(api, plan, artifact, state);
|
|
252
350
|
const exists = priorWorker !== null;
|
|
253
351
|
const create = await ownedResourcesToCreate(api, plan, state);
|
|
254
352
|
if (exists) await checkCronOwnership(api, plan);
|
|
255
|
-
|
|
353
|
+
// The declared secrets this run uploads; every other one is inherited unchanged.
|
|
354
|
+
const {missing:sentSecrets} = await secretBindings(api, plan.worker, plan, env, exists);
|
|
256
355
|
// Record intent before each create. A failed or lost response leaves it pending:
|
|
257
356
|
// absent on resume means create again; present means an uncertain outcome to inspect.
|
|
258
357
|
for (const {kind,item} of create) {
|
|
259
358
|
state.owned[kind][item.name] = {status:'pending'}; await save();
|
|
260
|
-
|
|
359
|
+
let row;
|
|
360
|
+
try { row = await resourceKinds[kind].create(api,item); }
|
|
361
|
+
catch (error) {
|
|
362
|
+
if (definiteProviderRefusal(error)) { delete state.owned[kind][item.name]; await save(); }
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
261
365
|
assert(findResource([row],kind,item.name) === row, 'invalid-created-resource');
|
|
262
366
|
state.owned[kind][item.name] = {status:'created',...(resourceId(kind,row) === undefined ? {} : {id:resourceId(kind,row)})}; await save();
|
|
367
|
+
await listedAfterCreate(api,kind,item,settle);
|
|
263
368
|
}
|
|
264
369
|
if (!exists && !state.owned.worker) { state.owned.worker = {status:'pending'}; await save(); }
|
|
265
370
|
const upload = `/workers/scripts/${plan.worker}?bindings_inherit=strict`;
|
|
@@ -285,15 +390,15 @@ async function deployLocked({api, target, plan, artifact, statePath, env, fetch,
|
|
|
285
390
|
}};
|
|
286
391
|
const receipt = await deploy({api:observed, plan, artifact, artifactSha256:hash(artifact), previousReceipt:state.receipt ?? undefined,
|
|
287
392
|
createResources:false, uploadWithoutReceipt:true, env, fetch, healthOptions, readiness,
|
|
288
|
-
onReceipt: async candidate => { if (candidate.changed || !state.receipt) { state.receipt = candidate; delete state.pendingUpload; await save(); } },
|
|
393
|
+
onReceipt: async candidate => { if (candidate.changed || !state.receipt) { state.receipt = candidate; delete state.pendingUpload; delete state.pendingSecret; await save(); } },
|
|
289
394
|
// Keep each first-health attempt beside the identity it belongs to.
|
|
290
395
|
onHealth: async candidate => { if (candidate.changed) { state.receipt = candidate; await save(); } }});
|
|
291
|
-
return {receipt, created:create.map(({kind,item}) => `${kind}:${item.name}`)};
|
|
396
|
+
return {receipt, created:create.map(({kind,item}) => `${kind}:${item.name}`), sentSecrets};
|
|
292
397
|
}
|
|
293
398
|
|
|
294
399
|
/** Independent read-only health of the last owner deployment recorded in the state file. */
|
|
295
400
|
export async function ownerHealth({api: provider, target, plan, statePath, fetch = globalThis.fetch, healthOptions = {}}) {
|
|
296
|
-
const api = withScopeHints(provider);
|
|
401
|
+
const api = withSettledLists(withScopeHints(provider));
|
|
297
402
|
validatePlan(plan); assertOwnerNames(target, plan);
|
|
298
403
|
const state = await readOwnerState(statePath, target);
|
|
299
404
|
assert(state.receipt, 'missing-owner-receipt');
|