@runeditrun/rer 0.2.4 → 0.2.5
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/cli/README.md +16 -7
- package/cli/contract.mjs +3 -0
- package/cli/deploy-command.mjs +26 -12
- package/cli/deploy-hook.mjs +31 -0
- package/cli/deploy-observation.mjs +4 -3
- package/cli/deploy-owner.mjs +9 -4
- package/cli/deploy-source.mjs +24 -0
- package/cli/deploy.mjs +2 -0
- package/cli/inspect.mjs +2 -2
- package/global/CHANGELOG.md +17 -0
- package/global/launch/state.mjs +18 -18
- package/modules/container/README.md +3 -1
- package/modules/container/boundary.mjs +5 -2
- package/modules/deploy-kit/README.md +9 -7
- package/modules/deploy-kit/deploy.mjs +9 -2
- package/modules/deploy-kit/health-request.mjs +7 -5
- package/modules/deploy-kit/owner-remove.mjs +1 -0
- package/modules/deploy-kit/owner-secret-names.mjs +31 -0
- package/modules/deploy-kit/owner-secret.mjs +6 -3
- package/modules/deploy-kit/owner.mjs +18 -12
- package/package.json +1 -1
- package/scripts/publish-check/publication-config.json +11 -1
package/cli/README.md
CHANGED
|
@@ -273,13 +273,22 @@ against that registry.
|
|
|
273
273
|
Owner-account application deploy completion is shared across standard and custom
|
|
274
274
|
hooks. An adapter can declare `deployDeclaration` (or `deploy.declaration`);
|
|
275
275
|
legacy hooks with one root owner deployment declaration are supported too. A
|
|
276
|
-
custom hook
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
276
|
+
custom hook returns `{ deployed: true, origin }` once its upload is live (an
|
|
277
|
+
https origin; http only on a loopback host, or rer sends it nothing), plus
|
|
278
|
+
`setupToken` when its Worker holds a setup token other than the `SETUP_TOKEN`
|
|
279
|
+
rer passed it. rer waits until the origin's `/health` names the checkout's commit,
|
|
280
|
+
then runs the adapter's actual owner bootstrap with that token and prints the
|
|
281
|
+
sign-in address; a hook that reports no deployment, or whose app never names
|
|
282
|
+
the commit, fails with what happened and claims nothing. When the hook deployed
|
|
283
|
+
through the kit's standard deployment record, rer also records successful
|
|
284
|
+
ownership separately from retired secrets and removes the setup token before
|
|
285
|
+
returning success; a hook that keeps its own record keeps its token. An owner
|
|
286
|
+
already claimed by the hook is kept without another bootstrap. An existing owner
|
|
287
|
+
is explicitly reported as kept. Function owner hooks run their complete
|
|
288
|
+
bootstrap flow; setup manifest paths do not replace it. A missing or ambiguous
|
|
289
|
+
declaration refuses completion. A kit or custom-hook deploy, except `--dry-run`,
|
|
290
|
+
first refuses uncommitted tracked source, naming the files, before any build or
|
|
291
|
+
upload.
|
|
283
292
|
|
|
284
293
|
`--env-file` values are available in the command's preflight, custom hook and
|
|
285
294
|
child build environment and are redacted; the caller environment is restored
|
package/cli/contract.mjs
CHANGED
|
@@ -20,6 +20,9 @@ const definitions = {
|
|
|
20
20
|
check: [['--profile'], 'Read-only structural command contract and operations validation.'],
|
|
21
21
|
};
|
|
22
22
|
export const verbs = Object.fromEntries(Object.entries(definitions).map(([name, [flags, effects]]) => [name, { flags: [...commonFlags, ...flags, ...(['build','deploy','dev','preview','export','import'].includes(name)?['--platform']:[]), ...(['install','setup','dev','preview','build','verify','deploy','export','import'].includes(name)?['--no-queue']:[])], effects }]));
|
|
23
|
+
// `rer demo` is `rer dev --demo`; describe lists it as a verb with the dev
|
|
24
|
+
// flags a demo takes whenever the seed's dev takes --demo.
|
|
25
|
+
export const demoVerb = Object.freeze({ flags: [...commonFlags, '--port', '--host', '--idle-timeout', '--stop', '--detach', '--foreground', '--no-queue'], effects: 'A throwaway demo of this seed (rer dev --demo): fresh data in .rer/demo/START (deleted when it stops: --stop, Ctrl-C or its end, one hour by default or --idle-timeout), the saved owner signed in, the seed\'s demo data, a Demo bar, and nothing sent (RER_DEMO=1; the checkout\'s own data is never touched).' });
|
|
23
26
|
export const aliases = Object.fromEntries([...Object.keys(verbs).filter(v => v !== 'install'), 'release', 'deps', 'rer'].map(name => [name, name === 'deps' ? 'install' : name === 'rer' ? null : name]));
|
|
24
27
|
export const releaseFlags = ['--repo', '--project', '--repository', '--output', '--ref', '--dry-run', '--tag', '--clean-history', '--onto', '--env-file', '--no-queue'];
|
|
25
28
|
export const requiredHooks = ['preview', 'dependencies', 'build', 'migrate', 'start', 'health', 'owner', 'signIn', 'verify', 'deploy', 'portability', 'state', 'settings', 'input'];
|
package/cli/deploy-command.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import { deployEnvironment, deployAgain } from './deploy-inputs.mjs';
|
|
|
6
6
|
import { claimHostedOwner } from './deploy-owner.mjs';
|
|
7
7
|
import { mutationReadinessFailure } from './deploy-readiness.mjs';
|
|
8
8
|
import { observeOwner } from './deploy-observation.mjs';
|
|
9
|
+
import { committedSource } from './deploy-source.mjs';
|
|
10
|
+
import { hookOrigin, waitForSource } from './deploy-hook.mjs';
|
|
9
11
|
import { main as owner } from '../modules/deploy-kit/owner-cli.mjs';
|
|
10
12
|
import { localOnlyBinding, ownerWorkerName } from '../modules/deploy-kit/owner.mjs';
|
|
11
13
|
import { refuseLocalModels } from '../modules/deploy-kit/local-only.mjs';
|
|
@@ -45,50 +47,62 @@ export async function deployCommand(context) {
|
|
|
45
47
|
// may perform them; neither a build nor a custom upload runs here.
|
|
46
48
|
if (options.remove || options.setSecret || options.unsetSecret) return ownerDeploy(path, adapter)(context);
|
|
47
49
|
if (adapter.deploy.managesHostedOwner) return adapter.deploy(context);
|
|
50
|
+
const sourceSha = options.dryRun ? undefined : committedSource(root, options);
|
|
48
51
|
const { env, supplied } = await deployEnvironment({ root, options, declaration, setupManifest, settings: adapter.settings, localOnlyKeys });
|
|
49
52
|
const { createRedaction, credentialValues } = await shared('redaction.mjs');
|
|
50
53
|
const redaction = createRedaction();
|
|
51
54
|
const addSecret = value => { redaction.addSecrets([value]); store.addSecrets?.([value]); };
|
|
52
55
|
for (const value of [...credentialValues(env), env.CLOUDFLARE_ACCOUNT_ID, ...(declaration.secrets ?? []).map(item => env[item.environment])].filter(Boolean)) addSecret(value);
|
|
53
56
|
const command = (verb, extra = [], values = env) => owner([verb, '--declaration', path, '--environment', options.environment ?? 'production', ...extra], values, { cwd: root, localOnlyKeys });
|
|
57
|
+
// A hook that deployed through the kit's standard record lets rer issue and
|
|
58
|
+
// retire the setup token; a hook keeping its own record keeps its token.
|
|
54
59
|
let recorded;
|
|
55
|
-
const inspect = async () =>
|
|
60
|
+
const inspect = async () => {
|
|
61
|
+
try { return recorded = await command('inspect'); }
|
|
62
|
+
catch (error) { if (error.message !== 'missing-owner-receipt') throw error; }
|
|
63
|
+
};
|
|
56
64
|
let observedClaims = [];
|
|
57
65
|
let deployed, claimed, linked = false, linkResult, step = 'custom deploy';
|
|
58
|
-
const link = async token => { if (!linked) { const state = await inspect(); linkResult = await linkDependencies?.(state
|
|
66
|
+
const link = async (token, origin) => { if (!linked) { const state = await inspect(); linkResult = await linkDependencies?.(state?.origin ?? origin, token); linked = true; } return linkResult; };
|
|
59
67
|
try {
|
|
60
68
|
return await observeOwner({ paths: ['/auth/bootstrap', adapter.hooks?.owner?.path, setupManifest?.steps?.find(item => item.type === 'owner-claim')?.path].filter(Boolean), inspect, beforeClaim: link, addSecret }, async claims => {
|
|
61
69
|
observedClaims = claims;
|
|
62
70
|
const saved = { ...process.env };
|
|
63
71
|
try {
|
|
64
72
|
Object.assign(process.env, env);
|
|
65
|
-
deployed = await adapter.deploy({ ...context, linkDependencies: async (
|
|
73
|
+
deployed = await adapter.deploy({ ...context, linkDependencies: async (origin, token) => link(token, origin) });
|
|
66
74
|
} finally {
|
|
67
75
|
for (const key of Object.keys(process.env)) if (!Object.hasOwn(saved, key)) delete process.env[key];
|
|
68
76
|
Object.assign(process.env, saved);
|
|
69
77
|
}
|
|
70
78
|
if (options.dryRun) return deployed;
|
|
71
|
-
|
|
79
|
+
const origin = hookOrigin(deployed, deployAgain(options));
|
|
80
|
+
step = 'wait for the deployed source';
|
|
81
|
+
await waitForSource({ origin, sourceSha, again: deployAgain(options) });
|
|
72
82
|
step = 'verify recorded deployment';
|
|
73
83
|
const state = await inspect();
|
|
74
|
-
if (state
|
|
84
|
+
if ((state?.origin ?? origin) !== origin || claims.some(item => item.origin !== origin)) throw new Error('owner-claim-origin-mismatch');
|
|
75
85
|
if (claims.some(item => !item.outcome)) throw new Error('owner-claim-failed');
|
|
76
86
|
claimed = claims.at(-1)?.outcome;
|
|
77
87
|
step = 'claim owner';
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
88
|
+
if (typeof deployed.setupToken === 'string' && deployed.setupToken) addSecret(deployed.setupToken);
|
|
89
|
+
const kit = state ? { secret: (extra, values) => command('secret', extra, values), recordClaim: outcome => command('record-owner', ['--outcome', outcome]) } : { token: deployed.setupToken || undefined };
|
|
90
|
+
claimed = await claimHostedOwner({ hook: adapter.hooks?.owner, setupManifest, declaration, deployed: { ...state, origin, owner: claimed, observedOwner: () => claims.at(-1)?.outcome }, env, supplied, ...kit, addSecret, beforeClaim: token => link(token, origin), store, signal: context.signal });
|
|
91
|
+
if (!linked) await linkDependencies?.(origin);
|
|
92
|
+
if (claimed) store.report(`${claimed === 'claimed' ? 'Claimed the hosted owner' : 'The hosted owner was already claimed and was kept'}${state ? '; the one-time setup token was removed' : ''}. Sign in by email at ${origin}.`);
|
|
93
|
+
const { setupToken: _, ...result } = deployed;
|
|
94
|
+
return { ...result, ...(claimed ? { owner: claimed } : {}), dependenciesLinked: true };
|
|
82
95
|
});
|
|
83
96
|
} catch (error) {
|
|
84
97
|
const again = deployAgain(options);
|
|
85
|
-
if (error instanceof RerError && !deployed?.deployed && !recorded) { error.message = store.redact?.(redaction.redact(error.message)) ?? redaction.redact(error.message); throw error; }
|
|
86
98
|
const { commandFailure } = await shared('failures.mjs');
|
|
99
|
+
if (error.hookFailure) throw commandFailure(store.redact?.(redaction.redact(error.message)) ?? redaction.redact(error.message));
|
|
100
|
+
if (error instanceof RerError && !deployed?.deployed && !recorded) { error.message = store.redact?.(redaction.redact(error.message)) ?? redaction.redact(error.message); throw error; }
|
|
87
101
|
const outcome = error.owner ?? claimed ?? observedClaims.at(-1)?.outcome;
|
|
88
|
-
const state = deployed?.deployed || recorded ? `The application is deployed at ${deployed?.origin ?? recorded.origin}; ${outcome ? `owner ${outcome}` : 'owner claim is not confirmed'}; setup token retirement is not confirmed.` : 'The custom deploy did not finish; inspect its deployment record before recovery.';
|
|
102
|
+
const state = deployed?.deployed || recorded ? `The application is deployed at ${deployed?.origin ?? recorded.origin}; ${outcome ? `owner ${outcome}` : 'owner claim is not confirmed'}${recorded ? '; setup token retirement is not confirmed' : ''}.` : 'The custom deploy did not finish; inspect its deployment record before recovery.';
|
|
89
103
|
const redact = value => store.redact?.(redaction.redact(value)) ?? redaction.redact(value);
|
|
90
104
|
const readiness = mutationReadinessFailure(error, { again, redact });
|
|
91
105
|
if (readiness) throw commandFailure(readiness);
|
|
92
|
-
throw commandFailure(redact(`${state} Failed at ${step}: ${error.message}${error.cause ? ` (${error.cause})` : ''}. Fix the cause and run ${again} again using the same environment credentials. ${outcome ? `To retire the token without rebuilding: ${again} --unset-secret ${setupManifest?.steps?.find(item => item.type === 'owner-claim')?.tokenField ?? 'SETUP_TOKEN'}.` : ''}`));
|
|
106
|
+
throw commandFailure(redact(`${state} Failed at ${step}: ${error.message}${error.cause ? ` (${error.cause})` : ''}. Fix the cause and run ${again} again using the same environment credentials. ${outcome && recorded ? `To retire the token without rebuilding: ${again} --unset-secret ${setupManifest?.steps?.find(item => item.type === 'owner-claim')?.tokenField ?? 'SETUP_TOKEN'}.` : ''}`));
|
|
93
107
|
}
|
|
94
108
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { waitHealth } from '../modules/deploy-kit/health-request.mjs';
|
|
2
|
+
import { isLoopbackHost } from '../modules/operator-auth/loopback.mjs';
|
|
3
|
+
|
|
4
|
+
// A custom deploy hook owns its upload and whatever record it keeps of it. It
|
|
5
|
+
// returns { deployed: true, origin }; rer claims the owner only once that
|
|
6
|
+
// origin's /health names the commit this run deployed. The origin is https
|
|
7
|
+
// (http only on loopback), since the claim sends the one-time setup token there.
|
|
8
|
+
export function hookOrigin(deployed, again) {
|
|
9
|
+
const url = deployed?.deployed === true && typeof deployed.origin === 'string' && URL.canParse(deployed.origin) ? new URL(deployed.origin) : undefined;
|
|
10
|
+
if (url?.protocol === 'http:' && !isLoopbackHost(url.hostname)) throw hookFailure(`The deploy hook reported its deployment at ${url.origin}, which is not https, so rer sent it nothing and claimed no owner. Make the hook return its https origin, then run ${again} again.`);
|
|
11
|
+
if (url && (url.protocol === 'https:' || url.protocol === 'http:') && url.origin === deployed.origin) return url.origin;
|
|
12
|
+
throw hookFailure(`The deploy hook finished without reporting a live deployment, so rer claimed no owner. A deploy hook returns { deployed: true, origin } once its upload is live. Check the hook's output above, fix the hook, then run ${again} again.`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function waitForSource({ origin, sourceSha, again }) {
|
|
16
|
+
try { await waitHealth({ origin, expected: { sourceSha }, request: (url, init) => globalThis.fetch(url, init) }); }
|
|
17
|
+
catch (error) {
|
|
18
|
+
const last = error.attempts?.at(-1);
|
|
19
|
+
let named;
|
|
20
|
+
try { named = JSON.parse(last.body).deployment?.sourceSha; } catch {}
|
|
21
|
+
const seen = !last || last.status === null ? 'it did not answer'
|
|
22
|
+
: last.status !== 200 ? `its last answer was HTTP ${last.status}`
|
|
23
|
+
: typeof named === 'string' && /^[0-9a-f]{7,64}$/.test(named) ? `it still answers for commit ${named.slice(0, 12)}`
|
|
24
|
+
: 'it answers without a deploy identity';
|
|
25
|
+
throw hookFailure(`The deploy hook reported a deployment at ${origin}, but its /health never named this checkout's commit ${sourceSha.slice(0, 12)} (${seen}), so rer claimed no owner. Check that the hook uploaded this build and that /health answers deploymentHealth(env), then run ${again} again.`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hookFailure(message) {
|
|
30
|
+
return Object.assign(new Error(message), { hookFailure: true });
|
|
31
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Observe the actual bootstrap exchange of a custom hook, including hooks that
|
|
2
2
|
// call the shared owner helper themselves. Never issue a second claim after it.
|
|
3
|
-
// The deployment record
|
|
3
|
+
// The kit's deployment record, when the hook keeps one there, is checked before
|
|
4
|
+
// linking with its live setup token; rer checks the origin the hook returns too.
|
|
4
5
|
export async function observeOwner({ paths, inspect, beforeClaim, addSecret }, run) {
|
|
5
6
|
const fetch = globalThis.fetch, claims = [];
|
|
6
7
|
globalThis.fetch = async (input, init = {}) => {
|
|
@@ -12,8 +13,8 @@ export async function observeOwner({ paths, inspect, beforeClaim, addSecret }, r
|
|
|
12
13
|
if (typeof body?.token !== 'string' || !body.token) return fetch(input, init);
|
|
13
14
|
addSecret(body.token);
|
|
14
15
|
const deployed = await inspect();
|
|
15
|
-
if (url.origin !== deployed.origin) throw new Error('owner-claim-origin-mismatch');
|
|
16
|
-
await beforeClaim?.(body.token);
|
|
16
|
+
if (deployed && url.origin !== deployed.origin) throw new Error('owner-claim-origin-mismatch');
|
|
17
|
+
await beforeClaim?.(body.token, url.origin);
|
|
17
18
|
const response = await fetch(input, init);
|
|
18
19
|
let code;
|
|
19
20
|
if (response.status !== 303) {
|
package/cli/deploy-owner.mjs
CHANGED
|
@@ -8,9 +8,11 @@ import { randomBytes } from 'node:crypto';
|
|
|
8
8
|
// did not upload this run gets a fresh one first, unless the person supplied the
|
|
9
9
|
// token (environment or --env-file) and inspection has not found it absent. A
|
|
10
10
|
// seed with its own owner step function runs its own bootstrap; identity-provider
|
|
11
|
-
// linking runs before retirement.
|
|
11
|
+
// linking runs before retirement. Without `secret`, the Worker's secrets are a
|
|
12
|
+
// deploy hook's own: rer claims with the `token` it returned, else the one rer
|
|
13
|
+
// gave it, and leaves that token with the hook.
|
|
12
14
|
// `beforeClaim(token)` runs while the token is live (dependency approvals need it).
|
|
13
|
-
export async function claimHostedOwner({ hook, setupManifest, declaration, deployed, env, supplied = [], secret, addSecret, beforeClaim, recordClaim, inspect, store, signal, fetch = globalThis.fetch }) {
|
|
15
|
+
export async function claimHostedOwner({ hook, setupManifest, declaration, deployed, env, supplied = [], secret, token: hookToken, addSecret, beforeClaim, recordClaim, inspect, store, signal, fetch = globalThis.fetch }) {
|
|
14
16
|
const claim = setupManifest?.steps?.find(step => step.type === 'owner-claim');
|
|
15
17
|
if (!hook || !['object', 'function'].includes(typeof hook) || Array.isArray(hook) || !deployed.origin) {
|
|
16
18
|
if (declaration.secrets?.some(item => item.binding === (claim?.tokenField ?? 'SETUP_TOKEN'))) throw ownerFailure('the owner bootstrap hook is unavailable');
|
|
@@ -20,6 +22,7 @@ export async function claimHostedOwner({ hook, setupManifest, declaration, deplo
|
|
|
20
22
|
const item = (Array.isArray(declaration.secrets) ? declaration.secrets : []).find(entry => entry?.binding === tokenKey);
|
|
21
23
|
if (!item) { if (claim) throw ownerFailure('the deploy declaration does not name the owner setup token secret'); return undefined; }
|
|
22
24
|
const retire = async outcome => {
|
|
25
|
+
if (!secret) return outcome;
|
|
23
26
|
try { await recordClaim?.(outcome); await secret(['--unset', tokenKey], env); }
|
|
24
27
|
catch (cause) {
|
|
25
28
|
if (cause.secretChange) { cause.owner = outcome; cause.tokenKey = tokenKey; cause.ownerFinalization = true; throw cause; }
|
|
@@ -34,7 +37,8 @@ export async function claimHostedOwner({ hook, setupManifest, declaration, deplo
|
|
|
34
37
|
// record or live-secret inspection. Keep their completed deploys idempotent.
|
|
35
38
|
if (!deployed.liveSecrets && deployed.retiredSecrets?.includes(tokenKey)) return undefined;
|
|
36
39
|
const given = (!deployed.liveSecrets || deployed.liveSecrets.includes(tokenKey)) && env[item.environment]?.trim() && !supplied.includes(tokenKey);
|
|
37
|
-
let token = deployed.sentSecrets?.includes(tokenKey) || given ? env[item.environment] : undefined;
|
|
40
|
+
let token = !secret ? hookToken ?? env[item.environment] : deployed.sentSecrets?.includes(tokenKey) || given ? env[item.environment] : undefined;
|
|
41
|
+
if (!token && !secret) throw ownerFailure(`the deploy hook returned no setupToken and ${item.environment} is not set`);
|
|
38
42
|
if (!token) {
|
|
39
43
|
token = randomBytes(32).toString('hex'); addSecret(token);
|
|
40
44
|
// The secret controller confirms readiness of the full new version identity.
|
|
@@ -67,7 +71,8 @@ export async function claimHostedOwner({ hook, setupManifest, declaration, deplo
|
|
|
67
71
|
if (response.status !== 303) { try { const body = await response.json(); if (typeof body?.error === 'string' && /^[a-z_]{1,64}$/.test(body.error)) code = body.error; } catch {} }
|
|
68
72
|
else await response.body?.cancel();
|
|
69
73
|
const outcome = response.status === 303 ? 'claimed' : response.status === 409 && code === 'already_initialized' ? 'kept' : undefined;
|
|
70
|
-
|
|
74
|
+
const advice = code !== 'invalid_setup_token' ? '' : !secret ? `; the Worker holds another setup token: have the deploy hook return the one it deployed as setupToken, or set it as ${item.environment} with --env-file` : given ? `; the ${item.environment} you supplied is not the deployed setup token: leave it unset and rer issues a fresh one` : '';
|
|
75
|
+
if (!outcome) throw ownerFailure(`HTTP ${response.status} (${code})${advice}`);
|
|
71
76
|
return retire(outcome);
|
|
72
77
|
}
|
|
73
78
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { refuse } from './errors.mjs';
|
|
3
|
+
import { deployAgain } from './deploy-inputs.mjs';
|
|
4
|
+
|
|
5
|
+
// rer deploy publishes the checkout's last commit: the deploy identity names it.
|
|
6
|
+
// Say so before any build or upload, naming the tracked files that differ.
|
|
7
|
+
export function committedSource(root, options) {
|
|
8
|
+
const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
9
|
+
const again = deployAgain(options);
|
|
10
|
+
let status, sha;
|
|
11
|
+
try { status = git('status', '--porcelain', '-z', '--untracked-files=no'); sha = git('rev-parse', 'HEAD').trim(); }
|
|
12
|
+
catch { throw refuse(`rer deploy publishes the checkout's last Git commit, and this checkout has none. Commit the source with Git, then run ${again} again. Nothing was uploaded.`); }
|
|
13
|
+
const entries = status.split('\0'), paths = [];
|
|
14
|
+
for (let index = 0; index < entries.length; index++) {
|
|
15
|
+
if (!entries[index]) continue;
|
|
16
|
+
paths.push(entries[index].slice(3));
|
|
17
|
+
// A rename or copy is followed by its original path.
|
|
18
|
+
if (/^([RC].|.[RC])/.test(entries[index])) index++;
|
|
19
|
+
}
|
|
20
|
+
if (!paths.length) return sha;
|
|
21
|
+
const shown = paths.slice(0, 20).map(path => ` ${path}`);
|
|
22
|
+
if (paths.length > shown.length) shown.push(` and ${paths.length - shown.length} more`);
|
|
23
|
+
throw refuse(`Deploy stopped before building: these tracked files have changes that are not committed:\n${shown.join('\n')}\nrer deploy publishes the last commit. Commit the changes (for example git commit -am "Describe the change") or discard them, then run ${again} again. Nothing was uploaded.`);
|
|
24
|
+
}
|
package/cli/deploy.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { refuseLocalModels } from '../modules/deploy-kit/local-only.mjs';
|
|
|
9
9
|
import { deployEnvironment, deployAgain } from './deploy-inputs.mjs';
|
|
10
10
|
import { claimHostedOwner } from './deploy-owner.mjs';
|
|
11
11
|
import { mutationReadinessFailure } from './deploy-readiness.mjs';
|
|
12
|
+
import { committedSource } from './deploy-source.mjs';
|
|
12
13
|
|
|
13
14
|
export function ownerDeploy(declarationPath, lifecycle) {
|
|
14
15
|
// rer verify serves the build with this declaration's vars and secrets.
|
|
@@ -28,6 +29,7 @@ export function ownerDeploy(declarationPath, lifecycle) {
|
|
|
28
29
|
// A dry run reads the settings file too, so a refused key fails here and not only on the real deploy.
|
|
29
30
|
if (options.dryRun && options.envFile) await deployEnvironment({ root, options, declaration, localOnlyKeys });
|
|
30
31
|
if (!change.length) {
|
|
32
|
+
if (!options.dryRun) committedSource(root, options);
|
|
31
33
|
await lifecycle.run(root, {}, store, () => {}, signal, { verb: 'build', operationToken });
|
|
32
34
|
await checkBuild();
|
|
33
35
|
}
|
package/cli/inspect.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { inventory } from './export.mjs';
|
|
|
3
3
|
import { lstat, readFile } from 'node:fs/promises';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
5
|
import { delimiter, dirname, resolve } from 'node:path';
|
|
6
|
-
import { aliases, pins, requiredHooks, serverDefaults, verbs, releaseFlags } from './contract.mjs';
|
|
6
|
+
import { aliases, demoVerb, pins, requiredHooks, serverDefaults, verbs, releaseFlags } from './contract.mjs';
|
|
7
7
|
import { runningVersion } from './version.mjs';
|
|
8
8
|
import { contained, readPackage } from './project.mjs';
|
|
9
9
|
import { shared } from './shared.mjs';
|
|
@@ -41,7 +41,7 @@ export async function describe(project) {
|
|
|
41
41
|
? operationsEffects
|
|
42
42
|
: verbs[verb]?.effects ?? 'Produce releases; keygen writes a public anchor and, on success, its private key to non-terminal stdout; sign writes a detached signature and prints (but does not execute) the GitHub upload command; receipt verifies against local anchors offline.';
|
|
43
43
|
return [verb, { flags: (verbs[verb]?.flags ?? releaseFlags).filter(flag => capability.flags?.includes(flag)), effects }];
|
|
44
|
-
})),
|
|
44
|
+
}).concat(capabilities.dev?.flags?.includes('--demo') ? [['demo', { flags: demoVerb.flags.filter(flag => capabilities.dev.flags.includes(flag)), effects: demoVerb.effects }]] : [])),
|
|
45
45
|
aliases: Object.fromEntries(Object.entries(aliases).filter(([alias, verb]) => pkg.scripts?.[alias] === commandLine(verb, declaredPackage(pkg) !== undefined))),
|
|
46
46
|
local: { state: project.adapter?.state, settings: project.adapter?.settings ?? [], installerState: '.seed/installer', outbox: '/__dev/outbox', ...await localState(project), origin: await savedOrigin(project), ...(project.adapter?.devIdentity ? { devIdentity: true, devKey: '.rer/local/dev-key.env' } : {}) },
|
|
47
47
|
// This pin serves a checkout through the machine's front door (rer door).
|
package/global/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.5
|
|
4
|
+
|
|
5
|
+
- `rer deploy` with a seed's own deploy hook claims the hosted owner from what the hook returns, `{ deployed: true, origin }`, once the origin's `/health` names the checkout's commit, and prints the sign-in address. A hook that keeps its own deployment record (Penny's does) no longer ends in `missing-owner-receipt` with a live, unclaimed app; such a hook returns `setupToken` too when its Worker holds a setup token other than the `SETUP_TOKEN` rer passed it. A hook that reports no deployment, or whose app never names the commit, fails saying so and claims nothing.
|
|
6
|
+
- `rer deploy` with uncommitted changes to tracked files stops before building, lists the files, says to commit them and that nothing was uploaded (exit 4), instead of building first and failing with `owner-source-not-committed`.
|
|
7
|
+
- `rer deploy --demo --dry-run` and `rer build --platform container` work on an exported seed: the container import boundary admits the demo guard's `import { env } from 'cloudflare:workers'` in `src/modules/demo/index.mjs` (that file and that import only) and still refuses a Cloudflare import anywhere else in `src/`. The release smoke now runs the hosted demo dry run on a package-mode export.
|
|
8
|
+
- `rer describe` lists `demo` as a verb (`rer dev --demo`), with its effects and the flags a demo takes.
|
|
9
|
+
- Two launches claiming the same port, or two workers claiming the same checkout's launch, get exactly one winner: the claim is written complete and linked into place, so the other never reads it half-written and takes it as abandoned.
|
|
10
|
+
- `node scripts/export-cli.mjs --to-package` on a composed foundation drops the removed `scripts/base-cli` copy's entries from `composition.lock.json` along with its files, so `rer check` no longer fails `SC-05.auth-copy` for files the conversion deleted.
|
|
11
|
+
- `rer deploy` refuses a seed deploy hook's `http://` origin (other than a loopback host such as `localhost`, `127.0.0.1` or `[::1]`) before sending it anything, so the one-time setup token only travels over https; it says to make the hook return its https origin.
|
|
12
|
+
- After a redeploy whose app answers unhealthy, the owner state in `.rer/deploy/` keeps each health check's status and timing but not the response text, so a secret an app echoes is never saved there; the secret command already kept none.
|
|
13
|
+
- A secret change `rer deploy` could not yet confirm stays recorded until the provider's records agree, and blocks another secret change, deploy or removal until then; a secret added or removed outside rer meanwhile is refused instead of adopted.
|
|
14
|
+
|
|
15
|
+
### Upgrade notes
|
|
16
|
+
|
|
17
|
+
- A seed's custom deploy hook returns `{ deployed: true, origin }` with an https origin (and may add `setupToken`); `rer deploy` waits for that origin's `/health` to name the checkout's commit, then claims the owner itself.
|
|
18
|
+
- A seed adopting `rer demo` needs rer 0.2.5 for `rer deploy --demo`.
|
|
19
|
+
|
|
3
20
|
## 0.2.4
|
|
4
21
|
|
|
5
22
|
0.2.3 was tagged but not published; its changes are in 0.2.4.
|
package/global/launch/state.mjs
CHANGED
|
@@ -39,6 +39,19 @@ export async function liveWorker(directory) {
|
|
|
39
39
|
return await live(holder) ? holder : undefined;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// A claim file is written to a private file first and hard-linked into place,
|
|
43
|
+
// so it appears complete or not at all: a process finding it never reads a
|
|
44
|
+
// half-written claim as a dead one and removes it. False when one is there.
|
|
45
|
+
async function linkClaim(path, record) {
|
|
46
|
+
const written = `${path}.${process.pid}`;
|
|
47
|
+
await writeFile(written, JSON.stringify(record), { mode: 0o600 });
|
|
48
|
+
try {
|
|
49
|
+
await link(written, path);
|
|
50
|
+
return true;
|
|
51
|
+
} catch (error) { if (error.code !== 'EEXIST') throw error; return false; }
|
|
52
|
+
finally { await rm(written, { force: true }); }
|
|
53
|
+
}
|
|
54
|
+
|
|
42
55
|
// A port chosen for a setup stays claimed while the process that chose it
|
|
43
56
|
// lives, so two launches preparing at once never pick the same port.
|
|
44
57
|
export async function claimPort(port, env = process.env) {
|
|
@@ -46,10 +59,7 @@ export async function claimPort(port, env = process.env) {
|
|
|
46
59
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
47
60
|
const { start } = await processInfo(process.pid);
|
|
48
61
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
49
|
-
|
|
50
|
-
await writeFile(path, JSON.stringify({ pid: process.pid, start }), { flag: 'wx', mode: 0o600 });
|
|
51
|
-
return { release: async () => { if ((await readJson(path))?.pid === process.pid) await rm(path, { force: true }); } };
|
|
52
|
-
} catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
62
|
+
if (await linkClaim(path, { pid: process.pid, start })) return { release: async () => { if ((await readJson(path))?.pid === process.pid) await rm(path, { force: true }); } };
|
|
53
63
|
if (await live(await readJson(path))) return undefined;
|
|
54
64
|
await rm(path, { force: true });
|
|
55
65
|
}
|
|
@@ -68,14 +78,7 @@ export async function claimPinInstall(checkout, { env = process.env, signal, onW
|
|
|
68
78
|
const path = pinInstallPath(checkout, env), { start } = await processInfo(process.pid);
|
|
69
79
|
for (let waited = false; ; ) {
|
|
70
80
|
signal?.throwIfAborted();
|
|
71
|
-
|
|
72
|
-
const written = `${path}.${process.pid}`;
|
|
73
|
-
await writeFile(written, JSON.stringify({ pid: process.pid, start }), { mode: 0o600 });
|
|
74
|
-
try {
|
|
75
|
-
await link(written, path);
|
|
76
|
-
return async () => { if ((await readJson(path))?.pid === process.pid) await rm(path, { force: true }); };
|
|
77
|
-
} catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
78
|
-
finally { await rm(written, { force: true }); }
|
|
81
|
+
if (await linkClaim(path, { pid: process.pid, start })) return async () => { if ((await readJson(path))?.pid === process.pid) await rm(path, { force: true }); };
|
|
79
82
|
const holder = await readJson(path);
|
|
80
83
|
if (!await live(holder)) { await rm(path, { force: true }); continue; }
|
|
81
84
|
if (!waited) { waited = true; onWait?.(holder.pid); }
|
|
@@ -99,15 +102,12 @@ export async function claimLaunch(directory, token) {
|
|
|
99
102
|
const path = join(directory, 'worker.json');
|
|
100
103
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
101
104
|
const { start } = await processInfo(process.pid);
|
|
102
|
-
|
|
103
|
-
await writeFile(path, JSON.stringify({ token, pid: process.pid, start, startedAt: Date.now(), events: `events-${token}.jsonl` }), { flag: 'wx', mode: 0o600 });
|
|
105
|
+
if (await linkClaim(path, { token, pid: process.pid, start, startedAt: Date.now(), events: `events-${token}.jsonl` })) {
|
|
104
106
|
for (const entry of await readdir(directory)) if (/^events-.*\.jsonl$/.test(entry) && entry !== `events-${token}.jsonl`) await rm(join(directory, entry), { force: true });
|
|
105
107
|
return true;
|
|
106
|
-
} catch (error) {
|
|
107
|
-
if (error.code !== 'EEXIST') throw error;
|
|
108
|
-
if (await liveWorker(directory)) return false;
|
|
109
|
-
await rm(path, { force: true });
|
|
110
108
|
}
|
|
109
|
+
if (await liveWorker(directory)) return false;
|
|
110
|
+
await rm(path, { force: true });
|
|
111
111
|
}
|
|
112
112
|
return false;
|
|
113
113
|
}
|
|
@@ -13,7 +13,9 @@ selected through `cacheContract` when the seed implements that contract. This
|
|
|
13
13
|
is not a general Durable Object adapter. A seed requiring KV or other
|
|
14
14
|
Cloudflare-only APIs cannot use Node.
|
|
15
15
|
The import boundary rejects those APIs and new Cloudflare imports outside the
|
|
16
|
-
platform binding/cache boundary.
|
|
16
|
+
platform binding/cache boundary. The one other file it admits is an exported
|
|
17
|
+
seed's demo guard, `src/modules/demo/index.mjs`, and there only
|
|
18
|
+
`import { env } from 'cloudflare:workers'` (it reads `RER_DEMO`).
|
|
17
19
|
|
|
18
20
|
The entrypoint holds a Linux kernel `flock` for the lifetime of the host. Each
|
|
19
21
|
installation has one local data volume and one writer. The lock inode retains
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
-
import { join, relative } from 'node:path';
|
|
2
|
+
import { join, relative, sep } from 'node:path';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
export async function checkBoundary(root, { engine='node', cacheContract }={}) {
|
|
@@ -10,6 +10,9 @@ export async function checkBoundary(root, { engine='node', cacheContract }={}) {
|
|
|
10
10
|
const name=relative(root,path),tree=ts.createSourceFile(path,await readFile(path,'utf8'),ts.ScriptTarget.Latest,true);
|
|
11
11
|
const cache=cacheContract==='lamp-retained-v1'&&/(?:^|\/)platform\/blobs\/snapshot-cache\.ts$/.test(name);
|
|
12
12
|
const bindings=/(?:^|\/)platform\/bindings\.server\.ts$/.test(name);
|
|
13
|
+
// The demo guard an exported seed carries reads RER_DEMO from env: that one
|
|
14
|
+
// file, and only its unrenamed `env` import.
|
|
15
|
+
const demo=name.split(sep).join('/')==='src/modules/demo/index.mjs';
|
|
13
16
|
const fail=api=>failures.push({path:name,api}),localNames=new Set();
|
|
14
17
|
function names(node){if((ts.isVariableDeclaration(node)||ts.isFunctionDeclaration(node)||ts.isImportSpecifier(node))&&node.name&&ts.isIdentifier(node.name))localNames.add(node.name.text);ts.forEachChild(node,names);}names(tree);
|
|
15
18
|
function visit(node){
|
|
@@ -17,7 +20,7 @@ export async function checkBoundary(root, { engine='node', cacheContract }={}) {
|
|
|
17
20
|
const module=node.moduleSpecifier.text;
|
|
18
21
|
if(module.startsWith('cloudflare:')){
|
|
19
22
|
const imports=node.importClause?.namedBindings;
|
|
20
|
-
const allowed=module==='cloudflare:workers'&&imports&&ts.isNamedImports(imports)&&imports.elements.every(element=>(bindings&&(element.propertyName??element.name).text==='env')||(cache&&(element.propertyName??element.name).text==='DurableObject'));
|
|
23
|
+
const allowed=module==='cloudflare:workers'&&imports&&ts.isNamedImports(imports)&&imports.elements.every(element=>(demo&&!node.importClause.name&&!element.propertyName&&element.name.text==='env')||(bindings&&(element.propertyName??element.name).text==='env')||(cache&&(element.propertyName??element.name).text==='DurableObject'));
|
|
21
24
|
if(!allowed)fail('Cloudflare import outside binding/cache contract');
|
|
22
25
|
}
|
|
23
26
|
}
|
|
@@ -114,7 +114,7 @@ The CLI prints JSON: refusal codes and resource names only, never inputs, tokens
|
|
|
114
114
|
|
|
115
115
|
- **Inputs.** Declared secrets come from the environment, then `--env-file FILE` (an owned 0600 file of `KEY=VALUE` lines), which wins. For a secret the Worker does not hold yet, rer supplies the setup manifest's owner email (`emailField`) from the local setup's private settings, and a fresh random value for each secret field the manifest generates (`generate`), such as the session secret and the setup token; a value the Worker already holds is kept. A still-missing secret stops the run before any write with a message that names it.
|
|
116
116
|
- **Hosted owner.** After deploy and health pass, rer claims the setup owner on the hosted app: it POSTs the setup token to the seed's standard owner step (`hooks.owner`, by default `/auth/bootstrap`). 303 means claimed; `409 already_initialized` means an owner was already claimed and is kept. Then rer deletes the token secret from the Worker, records the new Worker version in the state file, and lists the token in `retiredSecrets`, so later plans no longer declare it. A Worker that still holds a token rer did not upload in this run (an older deploy) first gets a fresh token through the same recorded secret change. Dependency approvals that need the token run before the claim. A seed with an owner step function, an identity-provider claim, or no declared token secret claims its owner its own way.
|
|
117
|
-
- **Secret changes.** `rer deploy --set-secret NAME` (value from the environment or `--env-file`, under the declaration's environment name) and `--unset-secret NAME` change one declared secret of the recorded Worker without a build.
|
|
117
|
+
- **Secret changes.** `rer deploy --set-secret NAME` (value from the environment or `--env-file`, under the declaration's environment name) and `--unset-secret NAME` change one declared secret of the recorded Worker without a build. Before writing, rer records the starting version and the exact live names of all other secrets, never their values. Accepted or ambiguous writes retain that intent until readback confirms the change; explicit refusals clear it. An ambiguous failure, or a later command's preflight, can clear an unapplied intent only when the live deployment still matches the receipt and the newest listed version matches it too. A surviving intent blocks further mutations until consistency returns; retry the same command then. Recovery requires the recorded immediate successor (or the recorded confirming rotation's successor), the receipt's script etag and fingerprint, the intended binding's presence, and an unchanged set of other secret names. Ambiguous rotations require a confirming PUT before reporting success. Older intents without names may recover only from the exact starting version's historical bindings, with its id, etag and fingerprint verified; unavailable or malformed historical bindings refuse adoption. Each historical binding must have an exact provider type supported by this kit; unrecognized types cannot prove that a name was not a secret. Added, removed or renamed other secrets refuse deploy and removal before writes. Values remain opaque: these checks cannot distinguish an otherwise identical outside change to the pending binding.
|
|
118
118
|
- **Removal.** `rer deploy --remove --yes` deletes what the state file says rer created for this environment: the Worker (schedules first; its Durable Objects go with it) and each D1, R2, KV and queue that is still the one rer created, after checking all of them before the first delete. A replaced or uncertain resource refuses the whole removal, except a resource whose create answer was lost: under this environment's derived name it is deleted once it is confirmed empty (a D1 with no tables, a KV namespace with no keys, an R2 bucket, which the provider refuses to delete while it holds objects); one holding data is refused as `uncertain-resource-not-empty`, and a queue, whose backlog rer cannot read, as `uncertain-resource-unverifiable` (delete it by hand if it is the empty one from the interrupted run). It then reads back that none is still listed and deletes the state file. An R2 bucket must be empty; a bucket with objects stays, the state file keeps it, and a later `--remove --yes` finishes. Resources rer did not create are never touched.
|
|
119
119
|
|
|
120
120
|
### If the state file is lost
|
|
@@ -286,12 +286,14 @@ retains the existing window, retrying only 404, 503 and network failures for
|
|
|
286
286
|
30 seconds by default.
|
|
287
287
|
|
|
288
288
|
The returned receipt carries `health: {ok, versionId, attempts}`. Each attempt is
|
|
289
|
-
`{offsetMs, status, cfRay, contentType,
|
|
290
|
-
a transport failure
|
|
291
|
-
|
|
292
|
-
message names the class and the last status
|
|
293
|
-
`health-http-failed-last-status-503`) and which carries `attempts
|
|
294
|
-
|
|
289
|
+
`{offsetMs, status, cfRay, contentType, errorClass}`: `status` is null for
|
|
290
|
+
a transport failure and `errorClass` is null for the passing attempt. The receipt
|
|
291
|
+
and owner state keep no response text, since an unhealthy app may echo a secret.
|
|
292
|
+
A failed wait throws an error whose message names the class and the last status
|
|
293
|
+
(for example `health-http-failed-last-status-503`) and which carries `attempts`
|
|
294
|
+
(each with `body`, the first 256 response bytes decoded as UTF-8, for the
|
|
295
|
+
caller's redacted diagnostic), `status` and the identity `receipt` (with
|
|
296
|
+
`health.ok` false). Provider diagnostics are never
|
|
295
297
|
retained; the endpoint must not return credentials. `readiness: {totalMs, clock,
|
|
296
298
|
sleep}` selects a smaller bound and deterministic time; `healthOptions.now` and
|
|
297
299
|
`healthOptions.sleep` act as `clock` and `sleep` for the same purpose.
|
|
@@ -64,16 +64,23 @@ export async function deploy({api,plan,artifact,artifactSha256,previousReceipt,r
|
|
|
64
64
|
return receipt;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// Response prefixes belong only in the caller's redacted diagnostic, never in
|
|
68
|
+
// the ownership record: an unhealthy app may echo a secret, as the secret command also keeps.
|
|
69
|
+
const recorded = attempts => attempts.map(({body:_,...attempt}) => attempt);
|
|
70
|
+
|
|
67
71
|
/** After an upload the route may take a while to appear: wait for /health with a bound and
|
|
68
72
|
* record every attempt in `receipt.health`. `onHealth(receipt)` runs after each attempt and at the end. */
|
|
69
73
|
async function firstHealth({api,plan,receipt,fetch,healthOptions,readiness,onHealth,asset}) {
|
|
70
74
|
receipt.health = {ok:false,attempts:[]};
|
|
71
75
|
const {now,sleep,...options} = healthOptions;
|
|
72
76
|
const wait = {...(now ? {clock:now} : {}),...(sleep ? {sleep} : {}),...readiness,onAttempt:async (_attempt,attempts) => {
|
|
73
|
-
receipt.health.attempts =
|
|
77
|
+
receipt.health.attempts = recorded(attempts);
|
|
74
78
|
if (onHealth) await onHealth(receipt);
|
|
75
79
|
}};
|
|
76
|
-
try {
|
|
80
|
+
try {
|
|
81
|
+
const health = await verifyHealth({...options,api,plan,receipt,fetch,readiness:wait,asset});
|
|
82
|
+
receipt.health = health.attempts ? {...health,attempts:recorded(health.attempts)} : health;
|
|
83
|
+
}
|
|
77
84
|
catch (error) {
|
|
78
85
|
receipt.health.ok = false; receipt.health.errorClass = error.message;
|
|
79
86
|
if (onHealth) await onHealth(receipt);
|
|
@@ -20,7 +20,8 @@ async function bodyPrefix(response) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/** One credential-free, bounded request. Retain only bounded response evidence. */
|
|
23
|
-
|
|
23
|
+
/** `expected` holds the deploy identity fields the caller knows; each must match. */
|
|
24
|
+
export async function healthRequest({origin, expected, request, timeoutMs, offsetMs}) {
|
|
24
25
|
const attempt = {offsetMs,status:null,cfRay:null,contentType:null,body:'',errorClass:null};
|
|
25
26
|
let response;
|
|
26
27
|
try {
|
|
@@ -58,11 +59,12 @@ export async function healthRequest({origin, receipt, versionId, request, timeou
|
|
|
58
59
|
let body;
|
|
59
60
|
try { body = JSON.parse(bytes.toString('utf8')); }
|
|
60
61
|
catch { attempt.errorClass = 'malformed-response'; return {attempt,transient:false}; }
|
|
61
|
-
if (body?.ok !== true ||
|
|
62
|
+
if (body?.ok !== true || Object.entries(expected).some(([key, value]) => body.deployment?.[key] !== value)) {
|
|
62
63
|
attempt.errorClass = 'health-identity-mismatch';
|
|
63
64
|
// Right after an upload the edge can still answer from another version: that
|
|
64
65
|
// settles within the bound. The expected version answering wrongly does not.
|
|
65
|
-
const
|
|
66
|
+
const key = expected.versionId ? 'versionId' : 'sourceSha';
|
|
67
|
+
const other = body?.ok === true && typeof body.deployment?.[key] === 'string' && body.deployment[key] !== expected[key];
|
|
66
68
|
return {attempt,transient:other};
|
|
67
69
|
}
|
|
68
70
|
return {attempt,transient:false};
|
|
@@ -74,13 +76,13 @@ const MIN_DELAY_MS = 2000, MAX_DELAY_MS = 10000;
|
|
|
74
76
|
|
|
75
77
|
/** The total bound includes requests, body reads and backoff; no request starts at the deadline.
|
|
76
78
|
* Only a transient outcome is retried. Every attempt is recorded, in order, whatever the end. */
|
|
77
|
-
export async function waitHealth({origin,receipt,versionId,request,timeoutMs=30000,totalMs=90000,clock=()=>performance.now(),sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms)),onAttempt=()=>{},single=false}) {
|
|
79
|
+
export async function waitHealth({origin,receipt,versionId,expected={sourceSha:receipt.source.sha,fingerprint:receipt.fingerprint,versionId},request,timeoutMs=30000,totalMs=90000,clock=()=>performance.now(),sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms)),onAttempt=()=>{},single=false}) {
|
|
78
80
|
assert(Number.isFinite(totalMs) && totalMs > 0 && totalMs <= 90000 && Number.isFinite(timeoutMs) && timeoutMs > 0,'invalid-health-bound');
|
|
79
81
|
const start = clock(); const attempts = []; let delay = 1000;
|
|
80
82
|
while (true) {
|
|
81
83
|
const remaining = totalMs - (clock()-start);
|
|
82
84
|
if (remaining <= 0) break;
|
|
83
|
-
const result = await healthRequest({origin,
|
|
85
|
+
const result = await healthRequest({origin,expected,request,timeoutMs:Math.max(1,Math.ceil(Math.min(timeoutMs,remaining))),offsetMs:Math.round(clock()-start)});
|
|
84
86
|
attempts.push(result.attempt); await onAttempt(result.attempt,attempts);
|
|
85
87
|
if (!result.attempt.errorClass) return {ok:true,versionId,attempts};
|
|
86
88
|
// `single` asks once, except that an edge still answering from the previous version settles within the bound.
|
|
@@ -56,6 +56,7 @@ export async function ownerRemove({api: provider, target, statePath, clock, slee
|
|
|
56
56
|
if (!sameDeployment(live, expected)) throw refuse('owned-resource-replaced', 'worker', target.worker);
|
|
57
57
|
worker = target.worker;
|
|
58
58
|
}
|
|
59
|
+
assert(!state.pendingSecret, 'secret-change-unsettled');
|
|
59
60
|
const listed = {}, doomed = [];
|
|
60
61
|
for (const kind of KINDS) {
|
|
61
62
|
if (!owned.some(item => item.kind === kind)) continue;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { identifier } from './validation.mjs';
|
|
2
|
+
import { deployedFingerprint } from './worker.mjs';
|
|
3
|
+
|
|
4
|
+
// Materialized binding families emitted by bindingsFor and secretBindings. Upload
|
|
5
|
+
// directives such as inherit, and unrecognized types, cannot classify history.
|
|
6
|
+
const historicalBindingTypes = new Set(['assets','plain_text','version_metadata','d1','r2_bucket','kv_namespace','queue','durable_object_namespace','service','secret_text']);
|
|
7
|
+
|
|
8
|
+
export const validSecretNames = names => Array.isArray(names) && names.every(identifier) && new Set(names).size === names.length;
|
|
9
|
+
|
|
10
|
+
/** Provider secret listings contain names and types, never values to persist. */
|
|
11
|
+
export function secretNames(rows) {
|
|
12
|
+
if (!Array.isArray(rows) || !rows.every(row => row?.type === 'secret_text')) return null;
|
|
13
|
+
const names = rows.map(row => row.name);
|
|
14
|
+
return validSecretNames(names) ? names : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Older intents lack a name snapshot. Only the exact historical starting version
|
|
18
|
+
* can supply it: neither the plan nor today's successor proves yesterday's names.
|
|
19
|
+
* Version resources.bindings is optional in the provider API. Absent, malformed or
|
|
20
|
+
* mismatched historical evidence cannot authorize adoption. */
|
|
21
|
+
export async function pendingSecretNames(api, worker, {pendingSecret, receipt}) {
|
|
22
|
+
if (pendingSecret.names !== undefined) return pendingSecret.names;
|
|
23
|
+
let anchor;
|
|
24
|
+
try { anchor = await api.request(`/workers/scripts/${worker}/versions/${pendingSecret.versionId}`); }
|
|
25
|
+
catch (error) { if (error.status === 404) return null; throw error; }
|
|
26
|
+
const bindings = anchor?.resources?.bindings;
|
|
27
|
+
if (anchor?.id !== pendingSecret.versionId || anchor?.resources?.script?.etag !== receipt.provider.etag
|
|
28
|
+
|| !Array.isArray(bindings) || !bindings.every(row => row && typeof row === 'object' && !Array.isArray(row) && historicalBindingTypes.has(row.type))
|
|
29
|
+
|| !validSecretNames(bindings.map(row => row.name)) || deployedFingerprint({bindings}) !== receipt.fingerprint) return null;
|
|
30
|
+
return bindings.filter(row => row.type === 'secret_text' && row.name !== pendingSecret.binding).map(row => row.name);
|
|
31
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { assert, identifier } from './validation.mjs';
|
|
2
2
|
import { activeWorker } from './worker.mjs';
|
|
3
|
+
import { secretNames } from './owner-secret-names.mjs';
|
|
3
4
|
import { waitHealth } from './health-request.mjs';
|
|
4
5
|
import { definiteProviderRefusal } from './provider.mjs';
|
|
5
6
|
import { readOwnerState, writeOwnerState, withStateLock, withScopeHints, sameDeployment, refuse, recordSecretChange, settlePendingSecret } from './owner.mjs';
|
|
@@ -26,10 +27,12 @@ export async function ownerSecret({api: provider, target, statePath, binding, va
|
|
|
26
27
|
const live = await activeWorker(api, target.worker);
|
|
27
28
|
if (!live) throw refuse('owned-resource-missing', 'worker', target.worker);
|
|
28
29
|
if (!sameDeployment(live, state.receipt.provider)) throw refuse('owned-resource-replaced', 'worker', target.worker);
|
|
30
|
+
assert(!state.pendingSecret, 'secret-change-unsettled');
|
|
29
31
|
const path = `/workers/scripts/${target.worker}/secrets`, present = value !== undefined;
|
|
30
|
-
const names =
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
const names = secretNames(await api.request(path));
|
|
33
|
+
assert(names, 'invalid-secret-list');
|
|
34
|
+
const existed = names.includes(binding), changed = present || existed;
|
|
35
|
+
state.pendingSecret = {binding, present, versionId:state.receipt.provider.versionId, names:names.filter(name => name !== binding).sort()}; await writeOwnerState(statePath, state);
|
|
33
36
|
let failure, unchanged = false, confirming = false;
|
|
34
37
|
const put = () => api.request(path, {method:'PUT', body:{name:binding, text:value, type:'secret_text'}});
|
|
35
38
|
try {
|
|
@@ -9,6 +9,7 @@ import { secretBindings, activeWorker, deployedFingerprint } from './worker.mjs'
|
|
|
9
9
|
import { validateMigrations } from './migrations.mjs';
|
|
10
10
|
import { deploy } from './deploy.mjs';
|
|
11
11
|
import { verifyHealth } from './health.mjs';
|
|
12
|
+
import { validSecretNames, secretNames, pendingSecretNames } from './owner-secret-names.mjs';
|
|
12
13
|
|
|
13
14
|
/* Owner-account mode: the owner's own Cloudflare account, token and account id.
|
|
14
15
|
* Names are derived from the project id and the owner's environment. A local
|
|
@@ -170,7 +171,8 @@ export async function readOwnerState(path, target) {
|
|
|
170
171
|
// Declared secrets rer removed on purpose (the spent setup token): later plans omit them.
|
|
171
172
|
assert(state.pendingSecret === undefined || identifier(state.pendingSecret?.binding) && typeof state.pendingSecret?.present === 'boolean'
|
|
172
173
|
&& (state.pendingSecret.versionId === undefined || uuid(state.pendingSecret.versionId))
|
|
173
|
-
&& (state.pendingSecret.repeatVersionId === undefined || uuid(state.pendingSecret.repeatVersionId))
|
|
174
|
+
&& (state.pendingSecret.repeatVersionId === undefined || uuid(state.pendingSecret.repeatVersionId))
|
|
175
|
+
&& (state.pendingSecret.names === undefined || validSecretNames(state.pendingSecret.names) && !state.pendingSecret.names.includes(state.pendingSecret.binding)), 'invalid-owner-state');
|
|
174
176
|
assert(state.ownerClaim === undefined || ['claimed','kept'].includes(state.ownerClaim?.outcome), 'invalid-owner-state');
|
|
175
177
|
assert(state.retiredSecrets === undefined || Array.isArray(state.retiredSecrets) && state.retiredSecrets.every(identifier), 'invalid-owner-state');
|
|
176
178
|
assert(state.accountId === target.accountId && state.project === target.project && state.environment === target.environment && state.worker === target.worker, 'owner-state-target-mismatch');
|
|
@@ -245,22 +247,20 @@ export function recordSecretChange(state, current, {binding, present}) {
|
|
|
245
247
|
/** A secret change rer recorded as pending but never confirmed (a lost response, or a run that
|
|
246
248
|
* ended before the change settled). It is that change only when the Worker's versions list shows
|
|
247
249
|
* 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
|
|
250
|
+
* exact script and build fingerprint, with the other secret names unchanged: adopt its version. A confirming PUT also records its
|
|
249
251
|
* own starting version before it writes; its immediate successor can be recovered by the
|
|
250
252
|
* same checks. Anything later is left to the identity check, which refuses it. */
|
|
251
253
|
export async function settlePendingSecret(api, worker, state, {preflight = false, observe, confirm} = {}) {
|
|
252
254
|
if (!state.pendingSecret || !state.receipt) return false;
|
|
253
255
|
const live = await activeWorker(api, worker);
|
|
254
256
|
if (sameDeployment(live, state.receipt.provider)) {
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
return false;
|
|
263
|
-
}
|
|
257
|
+
// Both deployment and newest version must corroborate an unapplied write.
|
|
258
|
+
// A stale deployment alone must not erase an accepted but unsettled intent.
|
|
259
|
+
const versions = (await api.request(`/workers/scripts/${worker}/versions`))?.items;
|
|
260
|
+
const unchanged = Array.isArray(versions) && versions.every(item => uuid(item?.id))
|
|
261
|
+
&& new Set(versions.map(item => item.id)).size === versions.length && versions[0]?.id === live.versionId;
|
|
262
|
+
observe?.(unchanged ? live : null);
|
|
263
|
+
if (!preflight || !unchanged) return false;
|
|
264
264
|
delete state.pendingSecret;
|
|
265
265
|
return true;
|
|
266
266
|
}
|
|
@@ -269,6 +269,7 @@ export async function settlePendingSecret(api, worker, state, {preflight = false
|
|
|
269
269
|
// Newest first, as the provider lists them.
|
|
270
270
|
const versions = (await api.request(`/workers/scripts/${worker}/versions`))?.items;
|
|
271
271
|
const ids = Array.isArray(versions) ? versions.map(item => item?.id) : [];
|
|
272
|
+
if (!ids.every(uuid) || new Set(ids).size !== ids.length) return false;
|
|
272
273
|
const {versionId, repeatVersionId} = state.pendingSecret;
|
|
273
274
|
const immediate = ids.indexOf(versionId) === 1;
|
|
274
275
|
const repeated = repeatVersionId && ids[1] === repeatVersionId && ids.indexOf(versionId) === 2;
|
|
@@ -277,7 +278,11 @@ export async function settlePendingSecret(api, worker, state, {preflight = false
|
|
|
277
278
|
// either the original immediate successor or the second write's successor.
|
|
278
279
|
if (ids[0] !== live.versionId || !(repeatVersionId ? repeated || preflight && immediate && live.versionId === repeatVersionId : immediate)) return false;
|
|
279
280
|
const rows = await api.request(`/workers/scripts/${worker}/secrets`);
|
|
280
|
-
|
|
281
|
+
const names = secretNames(rows);
|
|
282
|
+
if (!names || names.includes(state.pendingSecret.binding) !== state.pendingSecret.present) return false;
|
|
283
|
+
const priorNames = await pendingSecretNames(api, worker, state);
|
|
284
|
+
const otherNames = names.filter(name => name !== state.pendingSecret.binding);
|
|
285
|
+
if (!priorNames || priorNames.length !== otherNames.length || !priorNames.every(name => otherNames.includes(name))) return false;
|
|
281
286
|
if (confirm && !repeatVersionId) { await confirm(live); return false; }
|
|
282
287
|
recordSecretChange(state, live, state.pendingSecret);
|
|
283
288
|
return true;
|
|
@@ -347,6 +352,7 @@ async function deployLocked({api, target, plan, artifact, statePath, env, fetch,
|
|
|
347
352
|
if (await settlePendingSecret(api, target.worker, state, {preflight:true})) await save();
|
|
348
353
|
await checkServiceWorkers(api, plan);
|
|
349
354
|
const priorWorker = await checkOwnedWorker(api, plan, artifact, state);
|
|
355
|
+
assert(!state.pendingSecret, 'secret-change-unsettled');
|
|
350
356
|
const exists = priorWorker !== null;
|
|
351
357
|
const create = await ownedResourcesToCreate(api, plan, state);
|
|
352
358
|
if (exists) await checkCronOwnership(api, plan);
|
package/package.json
CHANGED
|
@@ -28,6 +28,16 @@
|
|
|
28
28
|
{ "repository": "just-every/run-easel", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
29
29
|
{ "repository": "just-every/run-willow", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
30
30
|
{ "repository": "just-every/run-pier", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
31
|
-
{ "repository": "just-every/run-linen", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." }
|
|
31
|
+
{ "repository": "just-every/run-linen", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
32
|
+
{ "repository": "just-every/run-gauge", "reason": "Owner-declared public repository for a next-ten seed (15 Oct 2026); created by clean-history export at the first public tag." },
|
|
33
|
+
{ "repository": "just-every/run-lodge", "reason": "Owner-declared public repository for a next-ten seed (15 Oct 2026); created by clean-history export at the first public tag." },
|
|
34
|
+
{ "repository": "just-every/run-beam", "reason": "Owner-declared public repository for a next-ten seed (15 Oct 2026); created by clean-history export at the first public tag." },
|
|
35
|
+
{ "repository": "just-every/run-locket", "reason": "Owner-declared public repository for a next-ten seed (15 Oct 2026); created by clean-history export at the first public tag." },
|
|
36
|
+
{ "repository": "just-every/run-coaster", "reason": "Owner-declared public repository for a next-ten seed (15 Oct 2026); created by clean-history export at the first public tag." },
|
|
37
|
+
{ "repository": "just-every/run-glove", "reason": "Owner-declared public repository for a next-ten seed (15 Oct 2026); created by clean-history export at the first public tag." },
|
|
38
|
+
{ "repository": "just-every/run-gasket", "reason": "Owner-declared public repository for a next-ten seed (15 Oct 2026); created by clean-history export at the first public tag." },
|
|
39
|
+
{ "repository": "just-every/run-pantry", "reason": "Owner-declared public repository for a next-ten seed (15 Oct 2026); created by clean-history export at the first public tag." },
|
|
40
|
+
{ "repository": "just-every/run-nectar", "reason": "Owner-declared public repository for a next-ten seed (15 Oct 2026); created by clean-history export at the first public tag." },
|
|
41
|
+
{ "repository": "just-every/run-larder", "reason": "Owner-declared public repository for a next-ten seed (15 Oct 2026); created by clean-history export at the first public tag." }
|
|
32
42
|
]
|
|
33
43
|
}
|