@runeditrun/rer 0.2.1 → 0.2.2
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 +12 -2
- package/cli/auth-copy.mjs +25 -18
- package/cli/bounded.mjs +13 -4
- package/cli/contract.mjs +1 -1
- package/cli/deploy-inputs.mjs +33 -0
- package/cli/deploy-owner.mjs +61 -0
- package/cli/deploy.mjs +87 -15
- package/cli/door.mjs +1 -1
- package/cli/inspect.mjs +24 -32
- package/cli/lifecycle.mjs +14 -4
- package/cli/parse.mjs +3 -1
- package/cli/pnpm-probe.mjs +14 -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 +20 -4
- package/cli/project-pnpm.mjs +67 -0
- package/cli/release.mjs +35 -0
- package/cli/server-registry.mjs +2 -2
- 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 +14 -0
- package/global/README.md +12 -2
- 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/install-pinned.mjs +4 -0
- package/global/main.mjs +28 -3
- package/global/registry.mjs +2 -2
- package/modules/container/deploy-input.mjs +5 -9
- package/modules/dependencies/auth.mjs +2 -1
- package/modules/deploy-kit/README.md +25 -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 +26 -0
- package/modules/deploy-kit/health-request.mjs +6 -1
- package/modules/deploy-kit/health.mjs +35 -15
- package/modules/deploy-kit/migration-ledger.mjs +48 -0
- package/modules/deploy-kit/migrations.mjs +11 -15
- package/modules/deploy-kit/owner-cli.mjs +20 -7
- package/modules/deploy-kit/owner-remove.mjs +105 -0
- package/modules/deploy-kit/owner-secret.mjs +49 -0
- package/modules/deploy-kit/owner.mjs +68 -10
- package/modules/deploy-kit/provider.mjs +15 -2
- package/modules/job-queue/config.mjs +4 -2
- 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/routes.mjs +1 -1
- package/modules/portability/README.md +28 -0
- package/modules/portability/bridge.mjs +10 -3
- package/modules/portability/import.mjs +4 -2
- package/onboarding/commands.mjs +3 -1
- package/package.json +1 -1
- package/scripts/publish-check/publication-config.json +7 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { assert, uuid } from './validation.mjs';
|
|
3
|
+
|
|
4
|
+
// Wrangler's remote --file protocol. D1 imports execute the whole file atomically,
|
|
5
|
+
// including trigger bodies; the query endpoint's semicolon splitting cannot.
|
|
6
|
+
export async function importD1({ envelope, upload }, id, sql) {
|
|
7
|
+
assert(uuid(id) && typeof sql === 'string' && Buffer.byteLength(sql) <= 4 * 1024 * 1024, 'invalid-d1-import');
|
|
8
|
+
const bytes = Buffer.from(sql), etag = createHash('md5').update(bytes).digest('hex');
|
|
9
|
+
const post = async body => (await envelope(`/d1/database/${id}/import`, { method:'POST', body })).result;
|
|
10
|
+
let response = await post({ action:'init', etag });
|
|
11
|
+
if (response && Object.hasOwn(response, 'upload_url')) {
|
|
12
|
+
assert(typeof response.filename === 'string' && response.filename.length > 0 && response.filename.length <= 1024 && !/[\x00-\x1f\x7f]/.test(response.filename), 'invalid-d1-import-filename');
|
|
13
|
+
await upload(response.upload_url, bytes, etag);
|
|
14
|
+
response = await post({ action:'ingest', filename:response.filename, etag });
|
|
15
|
+
}
|
|
16
|
+
for (let polls = 0; polls < 100; polls++) {
|
|
17
|
+
// REST places errors on the envelope; validate nested errors when supplied.
|
|
18
|
+
assert(response?.success === true && response.type === 'import' && (!Object.hasOwn(response, 'errors') || Array.isArray(response.errors) && !response.errors.length), 'd1-import-failed');
|
|
19
|
+
if (response.status === 'complete') {
|
|
20
|
+
assert(Number.isSafeInteger(response.result?.num_queries) && response.result.num_queries > 0 && typeof response.result.final_bookmark === 'string' && response.result.final_bookmark.length > 0, 'd1-import-invalid-result');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
assert(response.status === 'active' && typeof response.at_bookmark === 'string' && response.at_bookmark.length > 0 && response.at_bookmark.length <= 1024, 'd1-import-uncertain');
|
|
24
|
+
response = await post({ action:'poll', current_bookmark:response.at_bookmark });
|
|
25
|
+
}
|
|
26
|
+
throw new Error('d1-import-poll-limit');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function importUploadURL(value) {
|
|
30
|
+
assert(typeof value === 'string' && value.length <= 16384 && !/[\x00-\x20\x7f\\]/.test(value), 'invalid-d1-import-upload-url');
|
|
31
|
+
let url;
|
|
32
|
+
try { url = new URL(value); } catch { throw new Error('invalid-d1-import-upload-url'); }
|
|
33
|
+
assert(url.protocol === 'https:' && /^[a-f0-9]{32}\.r2\.cloudflarestorage\.com$/.test(url.hostname) && !url.port && !url.username && !url.password && !url.hash && url.pathname.length > 1 && url.search.length > 1, 'invalid-d1-import-upload-url');
|
|
34
|
+
return url.href;
|
|
35
|
+
}
|
|
@@ -33,13 +33,14 @@ export async function deploy({api,plan,artifact,artifactSha256,previousReceipt,r
|
|
|
33
33
|
const authenticated = previousReceipt?.fingerprint === fingerprint && previousReceipt.accountId === api.accountId && previousReceipt.worker === plan.worker && previousReceipt.provider?.versionId === existing?.versionId && previousReceipt.provider?.etag === existing?.etag && previousReceipt.provider?.deploymentId === existing?.deploymentId;
|
|
34
34
|
const unchanged = priorFingerprint === fingerprint && secrets.missing.length === 0 && !migration && !(uploadWithoutReceipt && !authenticated);
|
|
35
35
|
if (repeatOnly) assert(unchanged && previousReceipt?.fingerprint === fingerprint && previousReceipt?.provider?.versionId === existing?.versionId && previousReceipt?.provider?.etag === existing?.etag, 'repeat-deployment-drift');
|
|
36
|
-
const migrations = [];
|
|
37
|
-
for (const item of plan.d1) migrations.push({binding:item.binding,...await migrate(api,resources.d1.find(x => x.binding === item.binding).id,item.migrations,{apply:!unchanged})});
|
|
38
36
|
if (unchanged) {
|
|
39
37
|
assert(previousReceipt?.fingerprint === fingerprint && previousReceipt.accountId === api.accountId && previousReceipt.worker === plan.worker && previousReceipt.provider?.versionId === existing.versionId && previousReceipt.provider?.etag === existing.etag && previousReceipt.provider?.deploymentId === existing.deploymentId, 'no-op-requires-authenticated-prior-receipt');
|
|
40
38
|
verifyBindings(existing.settings.bindings,bindings);
|
|
41
39
|
}
|
|
42
|
-
|
|
40
|
+
const migrations = [];
|
|
41
|
+
// A migration failure names its database, so the owner knows which one to inspect.
|
|
42
|
+
for (const item of plan.d1) migrations.push({binding:item.binding,...await migrate(api,resources.d1.find(x => x.binding === item.binding).id,item.migrations,{apply:!unchanged,upgrade:true}).catch(error => { error.resource ??= `d1:${item.name}`; throw error; })});
|
|
43
|
+
if (!unchanged) await uploadWorker(api,{plan,artifact,bindings,secrets,migration,fingerprint});
|
|
43
44
|
const published = unchanged ? existing : await activeWorker(api,plan.worker); assert(published,'missing-deployed-worker');
|
|
44
45
|
verifyBindings(published.settings.bindings,bindings);
|
|
45
46
|
verifyScheduledHandler(published,plan);
|
|
@@ -29,6 +29,7 @@ function operation(path, options = {}) {
|
|
|
29
29
|
if (write && path.startsWith('/workers/')) return 'upload Worker';
|
|
30
30
|
return 'check Worker';
|
|
31
31
|
}
|
|
32
|
+
const CREATED_KINDS = {D1:'d1',R2:'r2',KV:'kv',queue:'queues'};
|
|
32
33
|
export function deploymentDiagnostics(api, {onCreated} = {}) {
|
|
33
34
|
const created = [];
|
|
34
35
|
const recordCreated = resource => { created.push(resource); onCreated?.(resource); };
|
|
@@ -40,9 +41,18 @@ export function deploymentDiagnostics(api, {onCreated} = {}) {
|
|
|
40
41
|
const result = await api[method](path,options);
|
|
41
42
|
if (step.startsWith('create ')) recordCreated(`${step.slice(7)}:${options.body.name ?? options.body.queue_name ?? options.body.title}`);
|
|
42
43
|
return result;
|
|
43
|
-
} catch (error) {
|
|
44
|
+
} catch (error) {
|
|
45
|
+
// A failed create names the resource it asked for: a lost answer may still have created it.
|
|
46
|
+
if (step.startsWith('create ') && error.resource === undefined) error.resource = `${CREATED_KINDS[step.slice(7)]}:${options.body.name ?? options.body.queue_name ?? options.body.title}`;
|
|
47
|
+
throw recordDeployFailure(error,{step,created:[...created]});
|
|
48
|
+
}
|
|
44
49
|
};
|
|
45
50
|
return {...api,request:guard('request'),list:guard('list'),envelope:guard('envelope'),recordCreated,
|
|
51
|
+
async importSql(id, sql) {
|
|
52
|
+
currentStep = 'migrate D1';
|
|
53
|
+
try { return await api.importSql(id, sql); }
|
|
54
|
+
catch (error) { throw recordDeployFailure(error,{step:currentStep,created:[...created]}); }
|
|
55
|
+
},
|
|
46
56
|
annotateFailure(error, step = currentStep) {
|
|
47
57
|
const prior = failures.get(error);
|
|
48
58
|
// Node filesystem/CLI codes are structural; their messages can hold paths
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { open } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { LOCAL_ONLY_KEYS } from './owner.mjs';
|
|
5
|
+
|
|
6
|
+
/** A deploy's private settings file: an owned 0600 regular file of KEY=VALUE lines
|
|
7
|
+
* (blank lines and # comments allowed, one optional pair of double quotes). */
|
|
8
|
+
export async function readDeployEnvFile(root, envFile, localOnlyKeys = []) {
|
|
9
|
+
const handle = await open(resolve(root, envFile), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
10
|
+
let encoded;
|
|
11
|
+
try {
|
|
12
|
+
const status = await handle.stat();
|
|
13
|
+
if (!status.isFile() || status.nlink !== 1 || (status.mode & 0o077) !== 0 || (process.getuid && status.uid !== process.getuid())) throw new Error('Deploy env file must be an owned private regular file (0600)');
|
|
14
|
+
encoded = await handle.readFile('utf8');
|
|
15
|
+
} finally { await handle.close(); }
|
|
16
|
+
const settings = {};
|
|
17
|
+
for (const line of encoded.split(/\r?\n/)) {
|
|
18
|
+
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
19
|
+
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
|
20
|
+
if (!match) throw new Error('Invalid deploy env file');
|
|
21
|
+
if (Object.hasOwn(settings, match[1])) throw new Error('Repeated deploy env key');
|
|
22
|
+
settings[match[1]] = match[2].replace(/^"(.*)"$/, '$1');
|
|
23
|
+
}
|
|
24
|
+
if ([...LOCAL_ONLY_KEYS, ...localOnlyKeys].some(key => Object.hasOwn(settings, key))) throw new Error('Local-only keys are refused for deploy');
|
|
25
|
+
return settings;
|
|
26
|
+
}
|
|
@@ -55,6 +55,10 @@ export async function healthRequest({origin, receipt, versionId, request, timeou
|
|
|
55
55
|
catch { attempt.errorClass = 'malformed-response'; return {attempt,transient:false}; }
|
|
56
56
|
if (body?.ok !== true || body.deployment?.sourceSha !== receipt.source.sha || body.deployment?.fingerprint !== receipt.fingerprint || body.deployment?.versionId !== versionId) {
|
|
57
57
|
attempt.errorClass = 'health-identity-mismatch';
|
|
58
|
+
// Right after an upload the edge can still answer from another version: that
|
|
59
|
+
// settles within the bound. The expected version answering wrongly does not.
|
|
60
|
+
const other = body?.ok === true && typeof body.deployment?.versionId === 'string' && body.deployment.versionId !== versionId;
|
|
61
|
+
return {attempt,transient:other};
|
|
58
62
|
}
|
|
59
63
|
return {attempt,transient:false};
|
|
60
64
|
}
|
|
@@ -74,7 +78,8 @@ export async function waitHealth({origin,receipt,versionId,request,timeoutMs=300
|
|
|
74
78
|
const result = await healthRequest({origin,receipt,versionId,request,timeoutMs:Math.max(1,Math.ceil(Math.min(timeoutMs,remaining))),offsetMs:Math.round(clock()-start)});
|
|
75
79
|
attempts.push(result.attempt); await onAttempt(result.attempt,attempts);
|
|
76
80
|
if (!result.attempt.errorClass) return {ok:true,versionId,attempts};
|
|
77
|
-
|
|
81
|
+
// `single` asks once, except that an edge still answering from the previous version settles within the bound.
|
|
82
|
+
if (!result.transient || single && result.attempt.errorClass !== 'health-identity-mismatch') break;
|
|
78
83
|
const rest = totalMs - (clock()-start);
|
|
79
84
|
if (rest <= 0) break;
|
|
80
85
|
await sleep(Math.min(Math.max(delay,MIN_DELAY_MS),rest)); delay = Math.min(delay*2,MAX_DELAY_MS);
|
|
@@ -46,14 +46,24 @@ export async function waitForDeploymentHealth(url, {
|
|
|
46
46
|
throw healthError(`Deployment did not become healthy at ${url} within ${timeoutMs}ms; last result: ${lastResult}.`, lastResult === 'network error' ? 'health-transport-failed' : 'health-http-failed', lastResult);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
49
|
+
/** An unauthenticated GET of a built file; anything but 200 means the browser gets no assets.
|
|
50
|
+
* After an upload (`windowMs`), an edge still serving the previous version answers 404 for
|
|
51
|
+
* the new build's hashed files: 404, 5xx and transport failures are retried within the window. */
|
|
52
|
+
async function servedAsset(origin, path, request, timeoutMs, {windowMs = 0, clock = () => performance.now(), sleep = ms => new Promise(resolve => setTimeout(resolve, ms))} = {}) {
|
|
53
|
+
const start = clock();
|
|
54
|
+
for (;;) {
|
|
55
|
+
let response, failure;
|
|
56
|
+
try { response = await request(`${origin}/${path.split('/').map(encodeURIComponent).join('/')}`,{redirect:'manual',signal:AbortSignal.timeout(timeoutMs),headers:{'Cache-Control':'no-cache'}}); }
|
|
57
|
+
catch { failure = 'asset-transport-failed'; }
|
|
58
|
+
if (response) {
|
|
59
|
+
await response.body?.cancel().catch(() => {});
|
|
60
|
+
if (response.status === 200 && !response.redirected) return {path,status:response.status};
|
|
61
|
+
failure = 'asset-http-failed';
|
|
62
|
+
if (response.redirected || response.status !== 404 && response.status < 500) throw new Error(failure);
|
|
63
|
+
}
|
|
64
|
+
if (clock() - start + 2000 > windowMs) throw new Error(failure);
|
|
65
|
+
await sleep(2000);
|
|
66
|
+
}
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
/** Authenticated provider readback, then the unauthenticated account-derived /health.
|
|
@@ -82,16 +92,26 @@ export async function verifyHealth({api,plan,receipt,fetch:request=globalThis.fe
|
|
|
82
92
|
if (plan.workersDev === false) health = {ok:true,versionId:current.versionId,route:'none'};
|
|
83
93
|
else if (readiness) health = await waitHealth({origin,receipt,versionId:current.versionId,request,timeoutMs,...readiness});
|
|
84
94
|
else {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
95
|
+
// A secret change makes a new version of the same build; until it reaches
|
|
96
|
+
// every edge, a healthy answer can still name the previous version. Ask again
|
|
97
|
+
// within the window; any other wrong identity fails at once.
|
|
98
|
+
const clock = now ?? Date.now, wait = sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))), start = clock();
|
|
99
|
+
let body;
|
|
100
|
+
for (;;) {
|
|
101
|
+
let response;
|
|
102
|
+
try {
|
|
103
|
+
response = await waitForDeploymentHealth(`${origin}/health`,{timeoutMs,intervalMs,attemptTimeoutMs:timeoutMs,headers:{'Cache-Control':'no-cache','Accept':'application/json'},fetchImpl:request,...(now ? {now} : {}),...(sleep ? {sleep} : {})});
|
|
104
|
+
} catch (error) { throw new Error(error.code ?? 'health-transport-failed'); }
|
|
105
|
+
assert(response.ok && response.status === 200 && !response.redirected,'health-http-failed');
|
|
106
|
+
body = await boundedJSON(response,65536);
|
|
107
|
+
const other = body?.ok === true && typeof body.deployment?.versionId === 'string' && body.deployment.versionId !== current.versionId;
|
|
108
|
+
if (!other || clock() - start + intervalMs > timeoutMs) break;
|
|
109
|
+
await wait(intervalMs);
|
|
110
|
+
}
|
|
91
111
|
assert(body?.ok === true && body.deployment?.sourceSha === receipt.source.sha && body.deployment?.fingerprint === receipt.fingerprint && body.deployment?.versionId === current.versionId,'health-identity-mismatch');
|
|
92
112
|
health = {ok:true,versionId:current.versionId};
|
|
93
113
|
}
|
|
94
|
-
if (asset && origin) health.asset = await servedAsset(origin,asset,request,timeoutMs);
|
|
114
|
+
if (asset && origin) health.asset = await servedAsset(origin,asset,request,timeoutMs,readiness ? {windowMs:60000,...(readiness.clock ? {clock:readiness.clock} : {}),...(readiness.sleep ? {sleep:readiness.sleep} : {})} : {});
|
|
95
115
|
const after = await activeWorker(api,plan.worker);
|
|
96
116
|
assert(after.versionId === current.versionId && after.deploymentId === current.deploymentId && after.etag === current.etag,'deployment-changed-during-health');
|
|
97
117
|
await verifyServiceGrants(api, plan);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { assert } from './validation.mjs';
|
|
2
|
+
|
|
3
|
+
export const ledger = 'd1_migrations';
|
|
4
|
+
const legacy = '_rer_deploy_migrations';
|
|
5
|
+
export const literal = value => `'${value.replaceAll("'","''")}'`;
|
|
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
|
+
const validPrefix = (rows, migrations) => rows.every((row, index) => row.name === migrations[index]?.name && row.sha256 === migrations[index]?.sha256 && row.state === 'applied');
|
|
8
|
+
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
|
+
|
|
10
|
+
export async function readLedger(api, id, migrations, query) {
|
|
11
|
+
const read = async sql => (await query(api, id, sql))[0].results;
|
|
12
|
+
const tables = (await read(`SELECT name FROM sqlite_master WHERE type='table' AND name IN ('${legacy}','${ledger}')`)).map(row => row.name);
|
|
13
|
+
let old = [], applied = [], transition = '';
|
|
14
|
+
if (tables.includes(legacy)) {
|
|
15
|
+
const columns = await read(`SELECT name FROM pragma_table_info('${legacy}')`);
|
|
16
|
+
assert(columns.length === 3 && columns.every(row => ['name','sha256','state'].includes(row.name)), 'invalid-migration-ledger');
|
|
17
|
+
old = await read(`SELECT name, sha256, state FROM ${legacy} ORDER BY name`);
|
|
18
|
+
assert(validPrefix(old, migrations), 'changed-or-uncertain-migration-history');
|
|
19
|
+
}
|
|
20
|
+
if (tables.includes(ledger)) {
|
|
21
|
+
const columns = (await read(`SELECT name FROM pragma_table_info('${ledger}')`)).map(row => row.name);
|
|
22
|
+
assert(['id','name','applied_at'].every(name => columns.includes(name)), 'invalid-migration-ledger');
|
|
23
|
+
if (columns.includes('sha256') && columns.includes('state')) {
|
|
24
|
+
applied = await read(`SELECT name, sha256, state FROM ${ledger} ORDER BY id`);
|
|
25
|
+
assert(validPrefix(applied, migrations), 'changed-or-uncertain-migration-history');
|
|
26
|
+
if (tables.includes(legacy)) assert(same(applied, old), 'changed-or-uncertain-migration-history');
|
|
27
|
+
} else {
|
|
28
|
+
assert(!columns.includes('sha256') && !columns.includes('state'), 'invalid-migration-ledger');
|
|
29
|
+
const prior = await read(`SELECT name FROM ${ledger} ORDER BY id`);
|
|
30
|
+
// Names alone cannot attest historical bytes. Only an already verified kit
|
|
31
|
+
// prefix can supply hashes; preserve Wrangler row IDs and timestamps.
|
|
32
|
+
assert(prior.length <= old.length && prior.every((row,index) => row.name === old[index].name), 'legacy-migration-history-needs-reviewed-hash-adoption');
|
|
33
|
+
transition += `ALTER TABLE ${ledger} ADD COLUMN sha256 TEXT;\nALTER TABLE ${ledger} ADD COLUMN state TEXT;\n`;
|
|
34
|
+
for (const row of old.slice(0,prior.length)) transition += `UPDATE ${ledger} SET sha256=${literal(row.sha256)}, state='applied' WHERE name=${literal(row.name)};\n`;
|
|
35
|
+
for (const row of old.slice(prior.length)) transition += insert(row);
|
|
36
|
+
applied = old;
|
|
37
|
+
}
|
|
38
|
+
} else if (tables.includes(legacy)) {
|
|
39
|
+
transition = `${createLedger};\n`;
|
|
40
|
+
for (const row of old) transition += insert(row);
|
|
41
|
+
applied = old;
|
|
42
|
+
}
|
|
43
|
+
if (tables.includes(legacy)) transition += `DROP TABLE ${legacy};`;
|
|
44
|
+
return { applied, transition, exists:tables.includes(ledger) || tables.includes(legacy) };
|
|
45
|
+
}
|
|
46
|
+
function insert(row) {
|
|
47
|
+
return `INSERT INTO ${ledger}(name,sha256,state) VALUES (${literal(row.name)},${literal(row.sha256)},'applied');\n`;
|
|
48
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { assert, hash } from './validation.mjs';
|
|
2
2
|
import { assertMigrationSqlSafe } from './sql-safety.mjs';
|
|
3
3
|
import { sqlTokens } from './sql-tokens.mjs';
|
|
4
|
-
|
|
5
|
-
const literal = value => `'${value.replaceAll("'","''")}'`;
|
|
4
|
+
import { ledger, literal, createLedger, readLedger } from './migration-ledger.mjs';
|
|
6
5
|
export function validateMigrations(migrations) {
|
|
7
6
|
let previous = '';
|
|
8
7
|
for (const migration of migrations) {
|
|
@@ -17,20 +16,17 @@ export async function query(api, id, sql) {
|
|
|
17
16
|
assert(Array.isArray(rows) && rows.length > 0 && rows.every(x => x.success === true && Array.isArray(x.results)), 'd1-query-failed');
|
|
18
17
|
return rows;
|
|
19
18
|
}
|
|
20
|
-
export async function migrate(api, id, migrations, { apply = true } = {}) {
|
|
19
|
+
export async function migrate(api, id, migrations, { apply = true, upgrade = apply } = {}) {
|
|
21
20
|
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');
|
|
21
|
+
const { applied, transition, exists } = await readLedger(api, id, migrations, query);
|
|
31
22
|
const pending = migrations.slice(applied.length);
|
|
32
23
|
assert(apply || pending.length === 0,'pending-migrations');
|
|
33
|
-
if (
|
|
24
|
+
if (transition && upgrade) {
|
|
25
|
+
await api.importSql(id, transition);
|
|
26
|
+
const verified = await readLedger(api, id, migrations, query);
|
|
27
|
+
assert(!verified.transition && verified.applied.length === applied.length, 'migration-readback-mismatch');
|
|
28
|
+
}
|
|
29
|
+
if (pending.length && !exists) await query(api,id,createLedger);
|
|
34
30
|
for (const migration of pending) {
|
|
35
31
|
// Claim before execution. A lost response/partial provider failure is never automatically replayed.
|
|
36
32
|
await query(api,id,`INSERT INTO ${ledger}(name,sha256,state) VALUES (${literal(migration.name)},${literal(migration.sha256)},'applying')`);
|
|
@@ -38,10 +34,10 @@ export async function migrate(api, id, migrations, { apply = true } = {}) {
|
|
|
38
34
|
// A second terminator after trailing comments creates an empty D1 statement.
|
|
39
35
|
// Keep the newline even when terminated: a final line comment must end.
|
|
40
36
|
const separator = last?.kind === 'symbol' && last.value === ';' ? '' : '; ';
|
|
41
|
-
await
|
|
37
|
+
await api.importSql(id,`${migration.sql}\n${separator}UPDATE ${ledger} SET state='applied' WHERE name=${literal(migration.name)} AND sha256=${literal(migration.sha256)};`);
|
|
42
38
|
}
|
|
43
39
|
if (pending.length) {
|
|
44
|
-
const final = (await query(api,id,`SELECT name, sha256, state FROM ${ledger} ORDER BY
|
|
40
|
+
const final = (await query(api,id,`SELECT name, sha256, state FROM ${ledger} ORDER BY id`))[0].results;
|
|
45
41
|
assert(final.length === migrations.length && final.every((x,i) => x.state === 'applied' && x.name === migrations[i].name && x.sha256 === migrations[i].sha256), 'migration-readback-mismatch');
|
|
46
42
|
}
|
|
47
43
|
return { applied:pending.map(x => x.name), history:migrations.map(({name,sha256}) => ({name,sha256})) };
|
|
@@ -7,11 +7,13 @@ 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 } from './owner.mjs';
|
|
11
|
+
import { ownerSecret } from './owner-secret.mjs';
|
|
12
|
+
import { ownerRemove } from './owner-remove.mjs';
|
|
11
13
|
import { refuseLocalModels } from './local-only.mjs';
|
|
12
14
|
|
|
13
15
|
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>]';
|
|
16
|
+
const usage = 'usage: owner-cli.mjs deploy|health|remove --declaration <file> --environment <name> [--product <dir>] [--state <file>] | secret ... --set NAME|--unset NAME';
|
|
15
17
|
|
|
16
18
|
/** The owner's committed source and existing local build. Run the seed's build first. */
|
|
17
19
|
async function ownerArtifact(root, product) {
|
|
@@ -24,10 +26,11 @@ async function ownerArtifact(root, product) {
|
|
|
24
26
|
/** Inputs: CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, plus the secrets the declaration names on first deploy. */
|
|
25
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} = {}) {
|
|
26
28
|
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'}}}); }
|
|
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'}}}); }
|
|
28
30
|
catch { throw new Error('usage'); }
|
|
29
31
|
const {positionals:[command,...rest],values} = parsed;
|
|
30
|
-
if (!['deploy','health'].includes(command) || rest.length || !values.declaration || !values.environment) throw new Error('usage');
|
|
32
|
+
if (!['deploy','health','secret','remove'].includes(command) || rest.length || !values.declaration || !values.environment) throw new Error('usage');
|
|
33
|
+
if ((command === 'secret') !== (values.set !== undefined || values.unset !== undefined) || values.set !== undefined && values.unset !== undefined) throw new Error('usage');
|
|
31
34
|
await refuseLocalModels(resolve(cwd));
|
|
32
35
|
const missing = ['CLOUDFLARE_ACCOUNT_ID','CLOUDFLARE_API_TOKEN'].filter(key => typeof env[key] !== 'string' || !env[key].trim());
|
|
33
36
|
if (missing.length) { const error = new Error('missing-input'); error.inputs = missing; throw error; }
|
|
@@ -40,19 +43,29 @@ export async function main(argv = process.argv.slice(2), env = process.env, {fet
|
|
|
40
43
|
const api = deploymentDiagnostics(cloudflare({accountId:env.CLOUDFLARE_ACCOUNT_ID,token:env.CLOUDFLARE_API_TOKEN,fetch}),{onCreated});
|
|
41
44
|
const target = await ownerTarget(api,{project:declaration.project,environment:values.environment});
|
|
42
45
|
const statePath = values.state ? resolve(root,values.state) : join(root,'.rer','deploy',`${target.environment}.json`);
|
|
46
|
+
if (command === 'remove') return {ok:true,...await ownerRemove({api,target,statePath}).catch(error => { throw api.annotateFailure(error,'remove'); })};
|
|
47
|
+
if (command === 'secret') {
|
|
48
|
+
const binding = values.set ?? values.unset, item = (Array.isArray(declaration.secrets) ? declaration.secrets : []).find(entry => entry?.binding === binding);
|
|
49
|
+
if (!item) { const error = new Error('undeclared-secret'); error.resource = `secret:${binding}`; throw error; }
|
|
50
|
+
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'); })};
|
|
52
|
+
}
|
|
53
|
+
// A secret rer retired (the spent setup token) is no longer part of the plan.
|
|
54
|
+
const {retiredSecrets = []} = await settleOwnerState({api,target,statePath});
|
|
55
|
+
const current = retiredSecrets.length && Array.isArray(declaration.secrets) ? {...declaration,secrets:declaration.secrets.filter(item => !retiredSecrets.includes(item?.binding))} : declaration;
|
|
43
56
|
const artifact = await ownerArtifact(root,values.product);
|
|
44
|
-
const plan = ownerPlan(target,
|
|
57
|
+
const plan = ownerPlan(target,current,artifact,{localOnlyKeys});
|
|
45
58
|
if (command === 'health') {
|
|
46
59
|
await ownerHealth({api,target,plan,statePath,fetch,healthOptions:{...healthOptions,readiness:{single:true,totalMs:30000}}}).catch(error=>{throw api.annotateFailure(error,'health');});
|
|
47
60
|
return {ok:true,worker:target.worker,origin:target.origin};
|
|
48
61
|
}
|
|
49
62
|
// Dispatch is the provider's: on some accounts a new Cron starts only at the account scheduler's next refresh.
|
|
50
63
|
if (plan.crons.length) notice(cronNotice);
|
|
51
|
-
const {receipt,created} = await ownerDeploy({api,target,plan,artifact,statePath,env,fetch,healthOptions}).catch(error => {
|
|
64
|
+
const {receipt,created,sentSecrets} = await ownerDeploy({api,target,plan,artifact,statePath,env,fetch,healthOptions}).catch(error => {
|
|
52
65
|
api.annotateFailure(error,error.message.startsWith('missing-secret-') ? 'secrets' : undefined);
|
|
53
66
|
throw error;
|
|
54
67
|
});
|
|
55
|
-
return {ok:true,changed:receipt.changed,worker:target.worker,origin:receipt.origin,versionId:receipt.provider.versionId,created,state:statePath};
|
|
68
|
+
return {ok:true,changed:receipt.changed,worker:target.worker,origin:receipt.origin,versionId:receipt.provider.versionId,created,sentSecrets,retiredSecrets,state:statePath};
|
|
56
69
|
}
|
|
57
70
|
|
|
58
71
|
/** Prints refusal codes and resource names only; never inputs, tokens or provider bodies. */
|
|
@@ -0,0 +1,105 @@
|
|
|
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)) 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}`)) { state.owned.worker = null; state.receipt = null; delete state.pendingUpload; }
|
|
98
|
+
await writeOwnerState(statePath, state);
|
|
99
|
+
const error = new Error('remove-incomplete'); error.remaining = remaining; error.failed = failed; error.removed = removed;
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
await rm(statePath, {force:true});
|
|
103
|
+
return {worker:target.worker, removed, remaining:[]};
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { assert, identifier } from './validation.mjs';
|
|
2
|
+
import { activeWorker, deployedFingerprint } from './worker.mjs';
|
|
3
|
+
import { readOwnerState, writeOwnerState, withStateLock, withScopeHints, sameDeployment, refuse, recordSecretChange, settlePendingSecret } from './owner.mjs';
|
|
4
|
+
|
|
5
|
+
/** Set (`value` given) or remove one Worker secret of the owner's last deployment, then
|
|
6
|
+
* record the Worker version the change made in the state file, so the next deploy
|
|
7
|
+
* recognises its own Worker. A Worker someone else replaced is refused untouched.
|
|
8
|
+
* The change is recorded as pending first: if the provider applies it but rer never
|
|
9
|
+
* sees it settle, a later run adopts it (see settlePendingSecret). A change the provider
|
|
10
|
+
* answered with an error clears the pending record. The secret listing
|
|
11
|
+
* and active deployment lag a change, so they are read until they show it, up to `windowMs`.
|
|
12
|
+
* 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))}) {
|
|
14
|
+
const api = withScopeHints(provider);
|
|
15
|
+
assert(identifier(binding), 'invalid-secret-name');
|
|
16
|
+
assert(value === undefined || typeof value === 'string' && value.trim(), `missing-secret-${binding}`);
|
|
17
|
+
return withStateLock(statePath, async () => {
|
|
18
|
+
const state = await readOwnerState(statePath, target);
|
|
19
|
+
assert(state.receipt && !state.pendingUpload, 'missing-owner-receipt');
|
|
20
|
+
if (await settlePendingSecret(api, target.worker, state)) await writeOwnerState(statePath, state);
|
|
21
|
+
const live = await activeWorker(api, target.worker);
|
|
22
|
+
if (!live) throw refuse('owned-resource-missing', 'worker', target.worker);
|
|
23
|
+
if (!sameDeployment(live, state.receipt.provider)) throw refuse('owned-resource-replaced', 'worker', target.worker);
|
|
24
|
+
const path = `/workers/scripts/${target.worker}/secrets`, present = value !== undefined;
|
|
25
|
+
const names = async () => { const rows = await api.request(path); assert(Array.isArray(rows), 'invalid-secret-list'); return rows.map(row => row.name); };
|
|
26
|
+
const changed = present || (await names()).includes(binding);
|
|
27
|
+
state.pendingSecret = {binding, present}; await writeOwnerState(statePath, state);
|
|
28
|
+
try {
|
|
29
|
+
if (present) await api.request(path, {method:'PUT', body:{name:binding, text:value, type:'secret_text'}});
|
|
30
|
+
else if (changed) await api.request(`${path}/${binding}`, {method:'DELETE'});
|
|
31
|
+
} catch (error) {
|
|
32
|
+
// The provider answered and refused: nothing changed, so nothing is pending. Only a lost
|
|
33
|
+
// response (no answer at all) may have applied the change.
|
|
34
|
+
if (error.status !== undefined) { delete state.pendingSecret; await writeOwnerState(statePath, state); }
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
let current;
|
|
38
|
+
for (const start = clock(); ; await sleep(2000)) {
|
|
39
|
+
current = await activeWorker(api, target.worker);
|
|
40
|
+
if (current && (!changed || !sameDeployment(current, live)) && (await names()).includes(binding) === present) break;
|
|
41
|
+
if (clock() - start + 2000 > windowMs) throw new Error('secret-change-unsettled');
|
|
42
|
+
}
|
|
43
|
+
// A secret change makes a new version of the same code: the script and build fingerprint must not move.
|
|
44
|
+
assert(current.etag === live.etag && deployedFingerprint(current.settings) === deployedFingerprint(live.settings), 'secret-change-drift');
|
|
45
|
+
recordSecretChange(state, current, {binding, present});
|
|
46
|
+
await writeOwnerState(statePath, state);
|
|
47
|
+
return {worker:target.worker, binding, present, versionId:current.versionId};
|
|
48
|
+
});
|
|
49
|
+
}
|