@smoothbricks/cli 0.11.16 → 0.11.18
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 +65 -4
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +47 -0
- package/dist/github-ci/index.d.ts +1 -1
- package/dist/github-ci/index.d.ts.map +1 -1
- package/dist/github-ci/index.js +7 -10
- package/dist/lib/secret-names.d.ts +35 -0
- package/dist/lib/secret-names.d.ts.map +1 -0
- package/dist/lib/secret-names.js +61 -0
- package/dist/monorepo/ci-workflow.d.ts +56 -0
- package/dist/monorepo/ci-workflow.d.ts.map +1 -1
- package/dist/monorepo/ci-workflow.js +214 -8
- package/dist/monorepo/managed-files.d.ts +23 -0
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +39 -1
- package/dist/monorepo/publish-workflow.d.ts +10 -1
- package/dist/monorepo/publish-workflow.d.ts.map +1 -1
- package/dist/monorepo/publish-workflow.js +58 -20
- package/dist/release/github-release.d.ts +10 -0
- package/dist/release/github-release.d.ts.map +1 -1
- package/dist/release/github-release.js +11 -5
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +4 -5
- package/dist/secrets/commands.d.ts +84 -0
- package/dist/secrets/commands.d.ts.map +1 -0
- package/dist/secrets/commands.js +375 -0
- package/dist/secrets/index.d.ts +103 -0
- package/dist/secrets/index.d.ts.map +1 -0
- package/dist/secrets/index.js +255 -0
- package/dist/secrets/repository.d.ts +60 -0
- package/dist/secrets/repository.d.ts.map +1 -0
- package/dist/secrets/repository.js +145 -0
- package/dist/wrangler/cloudflare.d.ts +7 -0
- package/dist/wrangler/cloudflare.d.ts.map +1 -1
- package/dist/wrangler/cloudflare.js +10 -0
- package/dist/wrangler/deploy-stage.d.ts +13 -2
- package/dist/wrangler/deploy-stage.d.ts.map +1 -1
- package/dist/wrangler/deploy-stage.js +89 -78
- package/dist/wrangler/deployed-version.d.ts +44 -0
- package/dist/wrangler/deployed-version.d.ts.map +1 -0
- package/dist/wrangler/deployed-version.js +87 -0
- package/dist/wrangler/live-version.d.ts +146 -0
- package/dist/wrangler/live-version.d.ts.map +1 -0
- package/dist/wrangler/live-version.js +326 -0
- package/dist/wrangler/stage-secrets.d.ts +57 -0
- package/dist/wrangler/stage-secrets.d.ts.map +1 -0
- package/dist/wrangler/stage-secrets.js +178 -0
- package/managed/raw/tooling/direnv/devenv.smoo.nix +9 -1
- package/managed/raw/tooling/git-hooks/pre-push.sh +30 -29
- package/package.json +2 -2
- package/src/cli.ts +69 -1
- package/src/github-ci/index.test.ts +92 -8
- package/src/github-ci/index.ts +7 -10
- package/src/lib/secret-names.ts +70 -0
- package/src/monorepo/__tests__/ci-workflow.test.ts +188 -2
- package/src/monorepo/__tests__/publish-workflow.test.ts +33 -15
- package/src/monorepo/ci-workflow.ts +294 -8
- package/src/monorepo/managed-files.test.ts +28 -1
- package/src/monorepo/managed-files.ts +56 -2
- package/src/monorepo/package-policy.test.ts +1 -1
- package/src/monorepo/publish-workflow.ts +65 -19
- package/src/monorepo/secret-references.test.ts +1 -1
- package/src/release/__tests__/github-release.test.ts +8 -4
- package/src/release/__tests__/private-npm-status.test.ts +2 -2
- package/src/release/github-release.ts +13 -5
- package/src/release/index.ts +4 -4
- package/src/secrets/commands.test.ts +28 -0
- package/src/secrets/commands.ts +412 -0
- package/src/secrets/index.test.ts +209 -0
- package/src/secrets/index.ts +287 -0
- package/src/secrets/repository.test.ts +98 -0
- package/src/secrets/repository.ts +164 -0
- package/src/wrangler/cloudflare.ts +18 -0
- package/src/wrangler/deploy-stage.test.ts +448 -23
- package/src/wrangler/deploy-stage.ts +142 -83
- package/src/wrangler/deployed-version.test.ts +150 -0
- package/src/wrangler/deployed-version.ts +130 -0
- package/src/wrangler/live-version.ts +363 -0
- package/src/wrangler/stage-secrets.ts +146 -0
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
3
2
|
import { readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
3
|
import { dirname, join } from 'node:path';
|
|
5
|
-
import typia from 'typia';
|
|
6
4
|
import { parseJsonFileText } from '../lib/json.js';
|
|
7
5
|
import { mergeEnv, printCommandOutput } from '../lib/run.js';
|
|
8
6
|
import { type CloudflareClient, CloudflareRestClient, type D1DatabaseRecord } from './cloudflare.js';
|
|
9
7
|
import { type FlatWranglerConfig, parseFlatWranglerConfig, planFlatStageResources } from './flat-config.js';
|
|
10
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
awaitLiveVersion,
|
|
10
|
+
awaitVersionEndpoint,
|
|
11
|
+
currentDeploymentVersionId,
|
|
12
|
+
findVersionIdByTag,
|
|
13
|
+
type LiveVersionProbe,
|
|
14
|
+
liveVersionCacheDirectory,
|
|
15
|
+
type VersionEndpointWaitOptions,
|
|
16
|
+
writeCachedLiveVersion,
|
|
17
|
+
} from './live-version.js';
|
|
11
18
|
import {
|
|
12
19
|
type ConfiguredStageResourcePlan,
|
|
13
20
|
type DeploymentStage,
|
|
@@ -23,6 +30,13 @@ import {
|
|
|
23
30
|
planPullRequestResources,
|
|
24
31
|
pullRequestStage,
|
|
25
32
|
} from './stage.js';
|
|
33
|
+
import {
|
|
34
|
+
planStageSecrets,
|
|
35
|
+
readDeclaredSecretNames,
|
|
36
|
+
readSecretStageMap,
|
|
37
|
+
type StageSecretPlan,
|
|
38
|
+
stageSecretRefusal,
|
|
39
|
+
} from './stage-secrets.js';
|
|
26
40
|
|
|
27
41
|
export interface ProcessResult {
|
|
28
42
|
exitCode: number;
|
|
@@ -69,6 +83,10 @@ export interface WranglerCommandDependencies {
|
|
|
69
83
|
runner?: ProcessRunner;
|
|
70
84
|
cloudflare?: CloudflareClient;
|
|
71
85
|
processEnv?: NodeJS.ProcessEnv;
|
|
86
|
+
/** Bounds and clock for the post-deploy wait; tests drive it without sleeping. */
|
|
87
|
+
wait?: VersionEndpointWaitOptions;
|
|
88
|
+
/** Where a successful deploy records the tag it made live, for `smoo wrangler deployed-version`. */
|
|
89
|
+
liveVersionCacheDirectory?: string;
|
|
72
90
|
}
|
|
73
91
|
|
|
74
92
|
export interface DeployStageResult {
|
|
@@ -78,13 +96,17 @@ export interface DeployStageResult {
|
|
|
78
96
|
versionTag?: string;
|
|
79
97
|
}
|
|
80
98
|
|
|
81
|
-
const isUnknownRecord = typia.createIs<Record<string, unknown>>();
|
|
82
|
-
|
|
83
99
|
export interface DeployStageOptions {
|
|
84
100
|
/** `staging`, `production`, or `prN`. */
|
|
85
101
|
stage: string;
|
|
86
102
|
/** A build-generated flat wrangler.json to deploy instead of `./wrangler.toml` (see `prepareFlatConfig`). */
|
|
87
103
|
config?: string;
|
|
104
|
+
/**
|
|
105
|
+
* A URL, served by this worker, whose trimmed body is the running version tag. When given, the
|
|
106
|
+
* deploy is not done until that URL answers with the tag it just made live — the control plane
|
|
107
|
+
* accepting a traffic shift is not the edge serving it.
|
|
108
|
+
*/
|
|
109
|
+
versionEndpoint?: string;
|
|
88
110
|
}
|
|
89
111
|
|
|
90
112
|
export async function deployStage(
|
|
@@ -101,20 +123,25 @@ export async function deployStage(
|
|
|
101
123
|
dependencies.cloudflare ??
|
|
102
124
|
new CloudflareRestClient(accountId, requiredEnvironmentValue(apiToken, 'CLOUDFLARE_API_TOKEN'));
|
|
103
125
|
const runner = dependencies.runner ?? new BunProcessRunner();
|
|
104
|
-
const
|
|
126
|
+
const secretPlan = planStageSecrets(readDeclaredSecretNames(cwd), stage, readSecretStageMap(cwd));
|
|
127
|
+
// Only the stage's own secrets, and only the ones a value exists for. A secret scoped to other
|
|
128
|
+
// stages is dropped here even when this shell exports it: that is the whole permission rule.
|
|
105
129
|
const secretValues: Record<string, string> = {};
|
|
106
|
-
for (const name of
|
|
130
|
+
for (const name of secretPlan.required) {
|
|
107
131
|
const value = processEnv[name];
|
|
108
132
|
if (value) secretValues[name] = value;
|
|
109
133
|
}
|
|
110
|
-
const
|
|
134
|
+
const gate = stageSecretGate(secretPlan, new Set(Object.keys(secretValues)), cloudflare);
|
|
111
135
|
let temporaryConfigPath: string | undefined;
|
|
112
136
|
let temporarySecretsPath: string | undefined;
|
|
113
137
|
try {
|
|
114
138
|
const prepared = options.config
|
|
115
|
-
? await prepareFlatConfig(options.config, stage, accountId,
|
|
116
|
-
: await prepareTomlConfig(cwd, stage, accountId,
|
|
139
|
+
? await prepareFlatConfig(options.config, stage, accountId, gate, cloudflare)
|
|
140
|
+
: await prepareTomlConfig(cwd, stage, accountId, gate, cloudflare);
|
|
117
141
|
temporaryConfigPath = prepared.temporaryConfigPath;
|
|
142
|
+
// The pull-request path already ran this before provisioning; the gate answers once per Worker.
|
|
143
|
+
// Everything below here writes to Cloudflare.
|
|
144
|
+
await gate(prepared.plan.workerName);
|
|
118
145
|
const workerExists = await reconcileStageResources(prepared.plan, cloudflare);
|
|
119
146
|
const versionTag = nxTaskVersionTag(processEnv);
|
|
120
147
|
const envArgs = prepared.envFlag ? ['--env', prepared.envFlag] : [];
|
|
@@ -122,18 +149,32 @@ export async function deployStage(
|
|
|
122
149
|
// renames the worker `<name>-<CLOUDFLARE_ENV>`. The build that produced the flat config is the
|
|
123
150
|
// caller that sets the variable, so the deploy would silently succeed under a name neither
|
|
124
151
|
// `reconcileStageResources` nor `versions list` looks at.
|
|
125
|
-
|
|
152
|
+
//
|
|
153
|
+
// A secret this stage is scoped out of goes with it. Leaving it out of the secrets payload is
|
|
154
|
+
// what stops it being installed; withholding it from the child as well means the deploy cannot
|
|
155
|
+
// read it at all, so "this stage never sees that capability" holds at the process boundary and
|
|
156
|
+
// not merely in the file we happen to write.
|
|
157
|
+
const unsetEnv = [...(prepared.envFlag ? [] : ['CLOUDFLARE_ENV']), ...secretPlan.withheld];
|
|
158
|
+
const run: ProcessRunOptions = unsetEnv.length > 0 ? { cwd, unsetEnv } : { cwd };
|
|
126
159
|
const workerName = prepared.plan.workerName;
|
|
160
|
+
const probe: LiveVersionProbe = {
|
|
161
|
+
deployments: () => wranglerJson(runner, ['deployments', 'status', '--name', workerName, '--json'], run),
|
|
162
|
+
versions: () => wranglerJson(runner, ['versions', 'list', '--name', workerName, '--json'], run),
|
|
163
|
+
};
|
|
127
164
|
|
|
128
165
|
// The tagged-version lookup runs before the migrations: a cache hit means this exact build is
|
|
129
166
|
// already live, so its migrations ran with it and re-applying them would touch the remote
|
|
130
167
|
// database for nothing. The activation and upload paths below still migrate first.
|
|
168
|
+
//
|
|
169
|
+
// This reads Cloudflare every time and never the local cache. A cache hit here would mean
|
|
170
|
+
// "we believe this hash is live" — exactly the belief a rollback falsifies — and believing it
|
|
171
|
+
// would skip the deploy that repairs the rollback. Reading live state is what makes a
|
|
172
|
+
// redundant deploy a proven no-op instead of an assumed one, which is in turn what makes a
|
|
173
|
+
// cross-project `dependsOn: ["<other>:deploy"]` edge cheap enough to be the ordering mechanism.
|
|
131
174
|
let taggedVersionId: string | null = null;
|
|
132
175
|
if (versionTag && workerExists) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
taggedVersionId = findVersionIdByTag(versions, versionTag);
|
|
136
|
-
if (taggedVersionId && isFullCurrentDeployment(deployments, taggedVersionId)) {
|
|
176
|
+
taggedVersionId = findVersionIdByTag(await probe.versions(), versionTag);
|
|
177
|
+
if (taggedVersionId && currentDeploymentVersionId(await probe.deployments()) === taggedVersionId) {
|
|
137
178
|
return { stage, workerName, action: 'remote-cache-hit', versionTag };
|
|
138
179
|
}
|
|
139
180
|
}
|
|
@@ -163,6 +204,7 @@ export async function deployStage(
|
|
|
163
204
|
],
|
|
164
205
|
run,
|
|
165
206
|
);
|
|
207
|
+
await confirmVersionIsLive(probe, versionTag, { stage, workerName, accountId }, options, dependencies, cwd);
|
|
166
208
|
return { stage, workerName, action: 'activated', versionTag };
|
|
167
209
|
}
|
|
168
210
|
|
|
@@ -174,6 +216,9 @@ export async function deployStage(
|
|
|
174
216
|
deployArgs.push('--secrets-file', temporarySecretsPath);
|
|
175
217
|
}
|
|
176
218
|
await wrangler(runner, deployArgs, run);
|
|
219
|
+
if (versionTag) {
|
|
220
|
+
await confirmVersionIsLive(probe, versionTag, { stage, workerName, accountId }, options, dependencies, cwd);
|
|
221
|
+
}
|
|
177
222
|
return { stage, workerName, action: 'deployed', ...(versionTag ? { versionTag } : {}) };
|
|
178
223
|
} finally {
|
|
179
224
|
if (temporaryConfigPath) {
|
|
@@ -199,7 +244,7 @@ async function prepareTomlConfig(
|
|
|
199
244
|
cwd: string,
|
|
200
245
|
stage: DeploymentStage,
|
|
201
246
|
accountId: string,
|
|
202
|
-
|
|
247
|
+
gate: SecretGate,
|
|
203
248
|
cloudflare: CloudflareClient,
|
|
204
249
|
): Promise<PreparedConfig> {
|
|
205
250
|
const committedConfigPath = join(cwd, 'wrangler.toml');
|
|
@@ -214,12 +259,7 @@ async function prepareTomlConfig(
|
|
|
214
259
|
}
|
|
215
260
|
const liveNamespaces = await cloudflare.listKvNamespaces();
|
|
216
261
|
const plan = planPullRequestResources(committedToml, stage, liveNamespaces);
|
|
217
|
-
const { kvNamespaceIds, d1DatabaseIds } = await provisionPullRequestResources(
|
|
218
|
-
plan,
|
|
219
|
-
missingSecrets,
|
|
220
|
-
cloudflare,
|
|
221
|
-
liveNamespaces,
|
|
222
|
-
);
|
|
262
|
+
const { kvNamespaceIds, d1DatabaseIds } = await provisionPullRequestResources(plan, gate, cloudflare, liveNamespaces);
|
|
223
263
|
const derivedToml = derivePullRequestWranglerConfig(committedToml, {
|
|
224
264
|
stage,
|
|
225
265
|
accountId,
|
|
@@ -241,7 +281,7 @@ async function prepareFlatConfig(
|
|
|
241
281
|
configPath: string,
|
|
242
282
|
stage: DeploymentStage,
|
|
243
283
|
accountId: string,
|
|
244
|
-
|
|
284
|
+
gate: SecretGate,
|
|
245
285
|
cloudflare: CloudflareClient,
|
|
246
286
|
): Promise<PreparedConfig> {
|
|
247
287
|
const flat = parseJsonFileText(configPath, await readFile(configPath, 'utf8'), parseFlatWranglerConfig);
|
|
@@ -254,12 +294,7 @@ async function prepareFlatConfig(
|
|
|
254
294
|
}
|
|
255
295
|
const liveNamespaces = await cloudflare.listKvNamespaces();
|
|
256
296
|
const plan = planPullRequestBindings(flat, stage, liveNamespaces);
|
|
257
|
-
const { kvNamespaceIds, d1DatabaseIds } = await provisionPullRequestResources(
|
|
258
|
-
plan,
|
|
259
|
-
missingSecrets,
|
|
260
|
-
cloudflare,
|
|
261
|
-
liveNamespaces,
|
|
262
|
-
);
|
|
297
|
+
const { kvNamespaceIds, d1DatabaseIds } = await provisionPullRequestResources(plan, gate, cloudflare, liveNamespaces);
|
|
263
298
|
const derived = derivePullRequestStageConfig(flat, { stage, accountId, kvNamespaceIds, d1DatabaseIds });
|
|
264
299
|
// Beside the original: its main/assets/migrations paths are relative to the file.
|
|
265
300
|
const temporaryConfigPath = join(dirname(configPath), `.wrangler.smoo-${process.pid}-${randomUUID()}.json`);
|
|
@@ -293,27 +328,58 @@ function migrationBindings(config: FlatWranglerConfig): string[] {
|
|
|
293
328
|
.map((database) => database.binding);
|
|
294
329
|
}
|
|
295
330
|
|
|
296
|
-
|
|
331
|
+
/**
|
|
332
|
+
* Asks, of one Worker, whether this stage may deploy at all. Called at each path's last read
|
|
333
|
+
* before its first write, so a refusal costs nothing: the pull-request path provisions KV and D1
|
|
334
|
+
* inside config preparation, and every path reconciles buckets, routes and DNS after it. A
|
|
335
|
+
* refusal arriving later would strand resources created for a deploy that was never allowed.
|
|
336
|
+
*/
|
|
337
|
+
type SecretGate = (workerName: string) => Promise<void>;
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* One answer per Worker, reused by both call sites so the read costs one round trip.
|
|
341
|
+
*
|
|
342
|
+
* What the Worker holds comes from the account's script listing first. A Worker absent from it
|
|
343
|
+
* holds nothing — exactly the first-deployment case — and Cloudflare answers 404 rather than an
|
|
344
|
+
* empty list when asked for a missing Worker's secrets. Reading that failure as "then nothing is
|
|
345
|
+
* needed" would disarm the check at the one moment it matters most, so it is never asked.
|
|
346
|
+
*/
|
|
347
|
+
function stageSecretGate(
|
|
348
|
+
plan: StageSecretPlan,
|
|
349
|
+
exported: ReadonlySet<string>,
|
|
350
|
+
cloudflare: CloudflareClient,
|
|
351
|
+
): SecretGate {
|
|
352
|
+
const answered = new Map<string, Promise<void>>();
|
|
353
|
+
return (workerName) => {
|
|
354
|
+
let pending = answered.get(workerName);
|
|
355
|
+
if (!pending) {
|
|
356
|
+
pending = assertStageSecrets(plan, exported, cloudflare, workerName);
|
|
357
|
+
answered.set(workerName, pending);
|
|
358
|
+
}
|
|
359
|
+
return pending;
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function assertStageSecrets(
|
|
364
|
+
plan: StageSecretPlan,
|
|
365
|
+
exported: ReadonlySet<string>,
|
|
297
366
|
cloudflare: CloudflareClient,
|
|
298
367
|
workerName: string,
|
|
299
|
-
missingSecrets: string[],
|
|
300
368
|
): Promise<void> {
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
);
|
|
306
|
-
}
|
|
369
|
+
const live = (await cloudflare.listWorkerScripts()).some((script) => script.id === workerName);
|
|
370
|
+
const held = new Set(live ? await cloudflare.listWorkerSecrets(workerName) : []);
|
|
371
|
+
const refusal = stageSecretRefusal(plan, exported, held, workerName);
|
|
372
|
+
if (refusal) throw new Error(refusal);
|
|
307
373
|
}
|
|
308
374
|
|
|
309
|
-
/** Isolation refusals already ran in the plan;
|
|
375
|
+
/** Isolation refusals already ran in the plan; the gate below is the last one before the first write. */
|
|
310
376
|
async function provisionPullRequestResources(
|
|
311
377
|
plan: PullRequestResourcePlan,
|
|
312
|
-
|
|
378
|
+
gate: SecretGate,
|
|
313
379
|
cloudflare: CloudflareClient,
|
|
314
380
|
liveNamespaces: LiveKvNamespace[],
|
|
315
381
|
): Promise<{ kvNamespaceIds: Map<string, string>; d1DatabaseIds: Map<string, string> }> {
|
|
316
|
-
await
|
|
382
|
+
await gate(plan.workerName);
|
|
317
383
|
const kvNamespaceIds = await ensureKvNamespaces(plan.kvNamespaces, liveNamespaces, cloudflare);
|
|
318
384
|
const d1DatabaseIds =
|
|
319
385
|
plan.d1Databases.length === 0
|
|
@@ -578,49 +644,47 @@ export function nxTaskVersionTag(environment: NodeJS.ProcessEnv): string | undef
|
|
|
578
644
|
throw new Error('NX_TASK_HASH must be canonical decimal digits or at least 32 hexadecimal characters.');
|
|
579
645
|
}
|
|
580
646
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
}
|
|
587
|
-
return null;
|
|
588
|
-
}
|
|
589
|
-
if (!isUnknownRecord(value)) return null;
|
|
590
|
-
const annotations = isUnknownRecord(value.annotations) ? value.annotations : undefined;
|
|
591
|
-
const metadata = isUnknownRecord(value.metadata) ? value.metadata : undefined;
|
|
592
|
-
const annotationTag = annotations?.['workers/tag'];
|
|
593
|
-
const candidateTag =
|
|
594
|
-
typeof annotationTag === 'string' ? annotationTag : typeof value.tag === 'string' ? value.tag : metadata?.tag;
|
|
595
|
-
if (candidateTag === tag) {
|
|
596
|
-
if (typeof value.id === 'string') return value.id;
|
|
597
|
-
if (typeof value.version_id === 'string') return value.version_id;
|
|
598
|
-
}
|
|
599
|
-
for (const nested of Object.values(value)) {
|
|
600
|
-
const found = findVersionIdByTag(nested, tag);
|
|
601
|
-
if (found) return found;
|
|
602
|
-
}
|
|
603
|
-
return null;
|
|
647
|
+
/** What a freshly activated version has to be true of, and where the fact gets recorded. */
|
|
648
|
+
interface DeployedVersionIdentity {
|
|
649
|
+
stage: DeploymentStage;
|
|
650
|
+
workerName: string;
|
|
651
|
+
accountId: string;
|
|
604
652
|
}
|
|
605
653
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
654
|
+
/**
|
|
655
|
+
* Turns "Cloudflare accepted the traffic shift" into "the new version answers".
|
|
656
|
+
*
|
|
657
|
+
* Without this the deploy step goes green while the edge still serves the previous version — the
|
|
658
|
+
* measured gap was about 20 s — so anything ordered after this deploy could call into code that
|
|
659
|
+
* has not shipped yet. The wait is what a `dependsOn: ["<other>:deploy"]` edge actually buys; an
|
|
660
|
+
* edge onto a step that returns early orders nothing.
|
|
661
|
+
*/
|
|
662
|
+
async function confirmVersionIsLive(
|
|
663
|
+
probe: LiveVersionProbe,
|
|
664
|
+
versionTag: string,
|
|
665
|
+
identity: DeployedVersionIdentity,
|
|
666
|
+
options: DeployStageOptions,
|
|
667
|
+
dependencies: WranglerCommandDependencies,
|
|
668
|
+
cwd: string,
|
|
669
|
+
): Promise<void> {
|
|
670
|
+
const wait = dependencies.wait ?? {};
|
|
671
|
+
const live = await awaitLiveVersion(probe, versionTag, wait);
|
|
672
|
+
if (!live.ok) throw new Error(`${identity.workerName}: ${live.error.message}`);
|
|
673
|
+
if (options.versionEndpoint) {
|
|
674
|
+
const answered = await awaitVersionEndpoint(options.versionEndpoint, versionTag, wait);
|
|
675
|
+
if (!answered.ok) throw new Error(`${identity.workerName}: ${answered.error.message}`);
|
|
619
676
|
}
|
|
620
|
-
|
|
677
|
+
const processEnv = dependencies.processEnv ?? process.env;
|
|
678
|
+
const directory = dependencies.liveVersionCacheDirectory ?? liveVersionCacheDirectory(cwd, processEnv);
|
|
679
|
+
await writeCachedLiveVersion(
|
|
680
|
+
directory,
|
|
681
|
+
{ accountId: identity.accountId, workerName: identity.workerName, stage: identity.stage },
|
|
682
|
+
{ versionTag, versionId: live.value.versionId, fetchedAt: Date.now() },
|
|
683
|
+
);
|
|
621
684
|
}
|
|
622
685
|
|
|
623
|
-
|
|
686
|
+
/** One wrangler invocation whose stdout must be JSON; shared by the deploy and the read-only query. */
|
|
687
|
+
export async function wranglerJson(runner: ProcessRunner, args: string[], run: ProcessRunOptions): Promise<unknown> {
|
|
624
688
|
const result = await wrangler(runner, args, run);
|
|
625
689
|
try {
|
|
626
690
|
return JSON.parse(result.stdout);
|
|
@@ -642,8 +706,3 @@ function requiredEnvironmentValue(value: string | undefined, name: string): stri
|
|
|
642
706
|
if (!value) throw new Error(`${name} is required.`);
|
|
643
707
|
return value;
|
|
644
708
|
}
|
|
645
|
-
|
|
646
|
-
function readSecretNames(cwd: string): string[] {
|
|
647
|
-
const path = join(cwd, '.dev.vars.example');
|
|
648
|
-
return existsSync(path) ? parseDevVarsExample(readFileSync(path, 'utf8')) : [];
|
|
649
|
-
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import type { ProcessResult, ProcessRunner, ProcessRunOptions } from './deploy-stage.js';
|
|
7
|
+
import { deployedVersion } from './deployed-version.js';
|
|
8
|
+
import { LIVE_VERSION_CACHE_TTL_MS, liveVersionCachePath, writeCachedLiveVersion } from './live-version.js';
|
|
9
|
+
|
|
10
|
+
const HASH = '16577780061662788004';
|
|
11
|
+
const FIXTURE = `[env.staging]
|
|
12
|
+
name = "fixture-worker-staging"
|
|
13
|
+
workers_dev = false
|
|
14
|
+
|
|
15
|
+
[env.staging.vars]
|
|
16
|
+
ENVIRONMENT = "staging"
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
const roots: string[] = [];
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
class RecordingRunner implements ProcessRunner {
|
|
26
|
+
readonly calls: string[][] = [];
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
private readonly versions: unknown = [{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }],
|
|
30
|
+
private readonly deployment: unknown = { versions: [{ version_id: 'version-1', percentage: 100 }] },
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
async run(_command: string, args: string[], _options: ProcessRunOptions): Promise<ProcessResult> {
|
|
34
|
+
this.calls.push(args);
|
|
35
|
+
const value = args[0] === 'versions' && args[1] === 'list' ? this.versions : this.deployment;
|
|
36
|
+
return { exitCode: 0, stdout: JSON.stringify(value), stderr: '' };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function fixture(): Promise<{ root: string; cacheDirectory: string }> {
|
|
41
|
+
const root = await mkdtemp(join(tmpdir(), 'smoo-deployed-version-'));
|
|
42
|
+
roots.push(root);
|
|
43
|
+
await writeFile(join(root, 'wrangler.toml'), FIXTURE);
|
|
44
|
+
return { root, cacheDirectory: join(root, 'cache') };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const credentials = { CLOUDFLARE_ACCOUNT_ID: 'account-1', CLOUDFLARE_API_TOKEN: 'token' };
|
|
48
|
+
|
|
49
|
+
describe('smoo wrangler deployed-version', () => {
|
|
50
|
+
it('reports the tag serving all traffic and remembers it', async () => {
|
|
51
|
+
const { root, cacheDirectory } = await fixture();
|
|
52
|
+
const runner = new RecordingRunner();
|
|
53
|
+
|
|
54
|
+
const report = await deployedVersion(
|
|
55
|
+
root,
|
|
56
|
+
{ stage: 'staging' },
|
|
57
|
+
{ runner, processEnv: credentials, cacheDirectory },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(report).toMatchObject({
|
|
61
|
+
workerName: 'fixture-worker-staging',
|
|
62
|
+
versionTag: `nx-${HASH}`,
|
|
63
|
+
source: 'cloudflare',
|
|
64
|
+
});
|
|
65
|
+
expect(runner.calls.map((args) => args.slice(0, 2))).toEqual([
|
|
66
|
+
['deployments', 'status'],
|
|
67
|
+
['versions', 'list'],
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('answers a second query from the cache without calling wrangler at all', async () => {
|
|
72
|
+
const { root, cacheDirectory } = await fixture();
|
|
73
|
+
const first = new RecordingRunner();
|
|
74
|
+
await deployedVersion(root, { stage: 'staging' }, { runner: first, processEnv: credentials, cacheDirectory });
|
|
75
|
+
|
|
76
|
+
const second = new RecordingRunner();
|
|
77
|
+
const report = await deployedVersion(
|
|
78
|
+
root,
|
|
79
|
+
{ stage: 'staging' },
|
|
80
|
+
{ runner: second, processEnv: credentials, cacheDirectory },
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(report).toMatchObject({ versionTag: `nx-${HASH}`, source: 'cache' });
|
|
84
|
+
expect(second.calls).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('asks Cloudflare again once the entry is older than the TTL', async () => {
|
|
88
|
+
const { root, cacheDirectory } = await fixture();
|
|
89
|
+
await writeCachedLiveVersion(
|
|
90
|
+
cacheDirectory,
|
|
91
|
+
{ accountId: 'account-1', workerName: 'fixture-worker-staging', stage: 'staging' },
|
|
92
|
+
{ versionTag: 'nx-stale', versionId: 'version-0', fetchedAt: 0 },
|
|
93
|
+
);
|
|
94
|
+
const runner = new RecordingRunner();
|
|
95
|
+
|
|
96
|
+
const report = await deployedVersion(
|
|
97
|
+
root,
|
|
98
|
+
{ stage: 'staging' },
|
|
99
|
+
{ runner, processEnv: credentials, cacheDirectory, now: () => LIVE_VERSION_CACHE_TTL_MS + 1 },
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
expect(report).toMatchObject({ versionTag: `nx-${HASH}`, source: 'cloudflare' });
|
|
103
|
+
expect(runner.calls.length).toBe(2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('refuses without a credential and writes no placeholder for a later run to trust', async () => {
|
|
107
|
+
const { root, cacheDirectory } = await fixture();
|
|
108
|
+
|
|
109
|
+
await expect(
|
|
110
|
+
deployedVersion(
|
|
111
|
+
root,
|
|
112
|
+
{ stage: 'staging' },
|
|
113
|
+
{ runner: new RecordingRunner(), processEnv: { CLOUDFLARE_ACCOUNT_ID: 'account-1' }, cacheDirectory },
|
|
114
|
+
),
|
|
115
|
+
).rejects.toThrow('CLOUDFLARE_API_TOKEN is required.');
|
|
116
|
+
expect(
|
|
117
|
+
existsSync(
|
|
118
|
+
liveVersionCachePath(cacheDirectory, {
|
|
119
|
+
accountId: 'account-1',
|
|
120
|
+
workerName: 'fixture-worker-staging',
|
|
121
|
+
stage: 'staging',
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
124
|
+
).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('refuses to name a live version while traffic is split', async () => {
|
|
128
|
+
const { root, cacheDirectory } = await fixture();
|
|
129
|
+
const runner = new RecordingRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
|
|
130
|
+
versions: [
|
|
131
|
+
{ version_id: 'version-1', percentage: 50 },
|
|
132
|
+
{ version_id: 'version-2', percentage: 50 },
|
|
133
|
+
],
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await expect(
|
|
137
|
+
deployedVersion(root, { stage: 'staging' }, { runner, processEnv: credentials, cacheDirectory }),
|
|
138
|
+
).rejects.toThrow('fixture-worker-staging (staging): no single worker version is serving 100% of traffic');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('names the pull-request worker the deploy would target, without provisioning anything', async () => {
|
|
142
|
+
const { root, cacheDirectory } = await fixture();
|
|
143
|
+
const runner = new RecordingRunner();
|
|
144
|
+
|
|
145
|
+
const report = await deployedVersion(root, { stage: 'pr123' }, { runner, processEnv: credentials, cacheDirectory });
|
|
146
|
+
|
|
147
|
+
expect(report.workerName).toBe('fixture-worker-pr123');
|
|
148
|
+
expect(runner.calls.every((args) => args[0] === 'deployments' || args[0] === 'versions')).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// `smoo wrangler deployed-version --stage <stage>` — what is serving this project's worker right now.
|
|
2
|
+
//
|
|
3
|
+
// Read-only by construction: it resolves the worker name from the committed config and asks
|
|
4
|
+
// Cloudflare. It never provisions, never derives a pull-request stage's resources, and never
|
|
5
|
+
// writes anything except its own cache entry.
|
|
6
|
+
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { readFile } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { parseJsonFileText } from '../lib/json.js';
|
|
11
|
+
import { BunProcessRunner, type ProcessRunner, type ProcessRunOptions, wranglerJson } from './deploy-stage.js';
|
|
12
|
+
import { parseFlatWranglerConfig, planFlatStageResources } from './flat-config.js';
|
|
13
|
+
import {
|
|
14
|
+
LIVE_VERSION_CACHE_TTL_MS,
|
|
15
|
+
type LiveVersionProbe,
|
|
16
|
+
liveVersionCacheDirectory,
|
|
17
|
+
readCachedLiveVersion,
|
|
18
|
+
readLiveVersion,
|
|
19
|
+
writeCachedLiveVersion,
|
|
20
|
+
} from './live-version.js';
|
|
21
|
+
import {
|
|
22
|
+
type DeploymentStage,
|
|
23
|
+
isPullRequestStage,
|
|
24
|
+
parseDeploymentStage,
|
|
25
|
+
planConfiguredStageResources,
|
|
26
|
+
stageResourceName,
|
|
27
|
+
stagingWorkerBaseName,
|
|
28
|
+
} from './stage.js';
|
|
29
|
+
|
|
30
|
+
export interface DeployedVersionOptions {
|
|
31
|
+
/** `staging`, `production`, or `prN`. */
|
|
32
|
+
stage: string;
|
|
33
|
+
/** The build-generated flat wrangler.json the deploy would use, when the project deploys one. */
|
|
34
|
+
config?: string;
|
|
35
|
+
/** Ask Cloudflare even when a fresh cache entry exists. */
|
|
36
|
+
refresh?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DeployedVersionDependencies {
|
|
40
|
+
runner?: ProcessRunner;
|
|
41
|
+
processEnv?: NodeJS.ProcessEnv;
|
|
42
|
+
cacheDirectory?: string;
|
|
43
|
+
ttlMs?: number;
|
|
44
|
+
now?: () => number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface DeployedVersionReport {
|
|
48
|
+
workerName: string;
|
|
49
|
+
stage: DeploymentStage;
|
|
50
|
+
versionTag: string | null;
|
|
51
|
+
versionId: string;
|
|
52
|
+
source: 'cloudflare' | 'cache';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The worker `stage` deploys to, from committed configuration alone.
|
|
57
|
+
*
|
|
58
|
+
* A pull-request stage's worker name is derived from the staging name the same way the deploy
|
|
59
|
+
* derives it, but WITHOUT the account listing the deploy needs for its KV/D1/R2 isolation: a
|
|
60
|
+
* question about what is live must not be able to create anything.
|
|
61
|
+
*/
|
|
62
|
+
export async function stageWorkerName(cwd: string, stage: DeploymentStage, configPath?: string): Promise<string> {
|
|
63
|
+
if (configPath) {
|
|
64
|
+
const flat = parseJsonFileText(configPath, await readFile(configPath, 'utf8'), parseFlatWranglerConfig);
|
|
65
|
+
return isPullRequestStage(stage)
|
|
66
|
+
? stageResourceName(stagingWorkerBaseName(flat.name), stage)
|
|
67
|
+
: planFlatStageResources(flat, stage).workerName;
|
|
68
|
+
}
|
|
69
|
+
const tomlPath = join(cwd, 'wrangler.toml');
|
|
70
|
+
if (!existsSync(tomlPath)) {
|
|
71
|
+
throw new Error(`${tomlPath} does not exist; pass --config for a build-generated flat configuration.`);
|
|
72
|
+
}
|
|
73
|
+
const toml = await readFile(tomlPath, 'utf8');
|
|
74
|
+
return isPullRequestStage(stage)
|
|
75
|
+
? stageResourceName(stagingWorkerBaseName(planConfiguredStageResources(toml, 'staging').workerName), stage)
|
|
76
|
+
: planConfiguredStageResources(toml, stage).workerName;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Answers with the tag serving 100% of this worker's traffic, or refuses.
|
|
81
|
+
*
|
|
82
|
+
* Refusing is the whole contract. A missing credential, an API error, or a traffic split has no
|
|
83
|
+
* honest answer, and returning a placeholder such as `unknown` would be worse than the error: a
|
|
84
|
+
* later run comparing against that placeholder would read it as a match and conclude the desired
|
|
85
|
+
* version is already live. For the same reason nothing here is ever written to the cache except a
|
|
86
|
+
* real observation, and the answer never contains a timestamp or anything else that varies per
|
|
87
|
+
* invocation.
|
|
88
|
+
*/
|
|
89
|
+
export async function deployedVersion(
|
|
90
|
+
cwd: string,
|
|
91
|
+
options: DeployedVersionOptions,
|
|
92
|
+
dependencies: DeployedVersionDependencies = {},
|
|
93
|
+
): Promise<DeployedVersionReport> {
|
|
94
|
+
const stage = parseDeploymentStage(options.stage);
|
|
95
|
+
const processEnv = dependencies.processEnv ?? process.env;
|
|
96
|
+
const accountId = processEnv.CLOUDFLARE_ACCOUNT_ID;
|
|
97
|
+
if (!accountId) throw new Error('CLOUDFLARE_ACCOUNT_ID is required.');
|
|
98
|
+
if (!processEnv.CLOUDFLARE_API_TOKEN) throw new Error('CLOUDFLARE_API_TOKEN is required.');
|
|
99
|
+
const workerName = await stageWorkerName(cwd, stage, options.config);
|
|
100
|
+
const cacheDirectory = dependencies.cacheDirectory ?? liveVersionCacheDirectory(cwd, processEnv);
|
|
101
|
+
const key = { accountId, workerName, stage };
|
|
102
|
+
const ttlMs = dependencies.ttlMs ?? LIVE_VERSION_CACHE_TTL_MS;
|
|
103
|
+
const now = dependencies.now ?? Date.now;
|
|
104
|
+
if (options.refresh !== true) {
|
|
105
|
+
const cached = await readCachedLiveVersion(cacheDirectory, key, ttlMs, now);
|
|
106
|
+
if (cached) {
|
|
107
|
+
return { workerName, stage, versionTag: cached.versionTag, versionId: cached.versionId, source: 'cache' };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const runner = dependencies.runner ?? new BunProcessRunner();
|
|
111
|
+
const run: ProcessRunOptions = { cwd, unsetEnv: ['CLOUDFLARE_ENV'] };
|
|
112
|
+
const probe: LiveVersionProbe = {
|
|
113
|
+
deployments: () => wranglerJson(runner, ['deployments', 'status', '--name', workerName, '--json'], run),
|
|
114
|
+
versions: () => wranglerJson(runner, ['versions', 'list', '--name', workerName, '--json'], run),
|
|
115
|
+
};
|
|
116
|
+
const live = await readLiveVersion(probe);
|
|
117
|
+
if (!live.ok) throw new Error(`${workerName} (${stage}): ${live.error.message}`);
|
|
118
|
+
await writeCachedLiveVersion(cacheDirectory, key, {
|
|
119
|
+
versionTag: live.value.tag,
|
|
120
|
+
versionId: live.value.versionId,
|
|
121
|
+
fetchedAt: now(),
|
|
122
|
+
});
|
|
123
|
+
return {
|
|
124
|
+
workerName,
|
|
125
|
+
stage,
|
|
126
|
+
versionTag: live.value.tag,
|
|
127
|
+
versionId: live.value.versionId,
|
|
128
|
+
source: 'cloudflare',
|
|
129
|
+
};
|
|
130
|
+
}
|