@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,17 +1,18 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from 'bun:test';
|
|
2
|
-
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, statSync } from 'node:fs';
|
|
3
3
|
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
6
|
+
import {
|
|
7
|
+
CloudflareApiError,
|
|
8
|
+
type CloudflareClient,
|
|
9
|
+
type CloudflareZone,
|
|
10
|
+
type D1DatabaseRecord,
|
|
11
|
+
type DnsRecord,
|
|
12
|
+
type R2Bucket,
|
|
13
|
+
type WorkerDomain,
|
|
14
|
+
type WorkerRoute,
|
|
15
|
+
type WorkerScript,
|
|
15
16
|
} from './cloudflare.js';
|
|
16
17
|
import {
|
|
17
18
|
childEnvironment,
|
|
@@ -22,6 +23,12 @@ import {
|
|
|
22
23
|
type ProcessRunOptions,
|
|
23
24
|
writeTemporaryConfigForTest,
|
|
24
25
|
} from './deploy-stage.js';
|
|
26
|
+
import {
|
|
27
|
+
type FetchLike,
|
|
28
|
+
findVersionIdByTag,
|
|
29
|
+
LIVE_VERSION_CACHE_TTL_MS,
|
|
30
|
+
readCachedLiveVersion,
|
|
31
|
+
} from './live-version.js';
|
|
25
32
|
import type { LiveKvNamespace } from './stage.js';
|
|
26
33
|
|
|
27
34
|
const HASH = '16577780061662788004';
|
|
@@ -58,19 +65,49 @@ class FakeRunner implements ProcessRunner {
|
|
|
58
65
|
secretsJson: string | undefined;
|
|
59
66
|
/** Runs before the fake reports success, so a test can read a temporary file the command was handed. */
|
|
60
67
|
onCall: ((args: string[], cwd: string) => Promise<void>) | undefined;
|
|
68
|
+
/**
|
|
69
|
+
* How many `deployments status` reads still answer with the OLD version after a traffic shift.
|
|
70
|
+
* This is the whole point of the fake: Cloudflare accepts the shift before it serves it.
|
|
71
|
+
*/
|
|
72
|
+
propagationPolls = 0;
|
|
73
|
+
|
|
74
|
+
private pendingVersionId: string | null = null;
|
|
61
75
|
|
|
62
76
|
constructor(
|
|
63
|
-
private
|
|
64
|
-
private
|
|
77
|
+
private versions: unknown = [],
|
|
78
|
+
private deployment: unknown = {},
|
|
65
79
|
) {}
|
|
66
80
|
|
|
81
|
+
/** The versions payload as a growable list, so an upload can add the version it just created. */
|
|
82
|
+
private versionList(): unknown[] {
|
|
83
|
+
const list: unknown[] = Array.isArray(this.versions) ? this.versions : [this.versions];
|
|
84
|
+
this.versions = list;
|
|
85
|
+
return list;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private shiftTrafficTo(versionId: string): void {
|
|
89
|
+
this.pendingVersionId = versionId;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private readDeployment(): unknown {
|
|
93
|
+
if (this.pendingVersionId !== null) {
|
|
94
|
+
if (this.propagationPolls > 0) {
|
|
95
|
+
this.propagationPolls -= 1;
|
|
96
|
+
} else {
|
|
97
|
+
this.deployment = { versions: [{ version_id: this.pendingVersionId, percentage: 100 }] };
|
|
98
|
+
this.pendingVersionId = null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return this.deployment;
|
|
102
|
+
}
|
|
103
|
+
|
|
67
104
|
async run(command: string, args: string[], options: ProcessRunOptions): Promise<ProcessResult> {
|
|
68
105
|
this.calls.push({ command, args, cwd: options.cwd, env: childEnvironment(options) });
|
|
69
106
|
if (args[0] === 'versions' && args[1] === 'list') {
|
|
70
107
|
return success(this.versions);
|
|
71
108
|
}
|
|
72
109
|
if (args[0] === 'deployments' && args[1] === 'status') {
|
|
73
|
-
return success(this.
|
|
110
|
+
return success(this.readDeployment());
|
|
74
111
|
}
|
|
75
112
|
const configIndex = args.indexOf('--config');
|
|
76
113
|
if (configIndex >= 0) {
|
|
@@ -84,6 +121,21 @@ class FakeRunner implements ProcessRunner {
|
|
|
84
121
|
this.secretsMode = statSync(secretsPath).mode & 0o777;
|
|
85
122
|
this.secretsJson = readFileSync(secretsPath, 'utf8');
|
|
86
123
|
}
|
|
124
|
+
if (args[0] === 'versions' && args[1] === 'deploy') {
|
|
125
|
+
// Resolved with the same reader the deploy uses, so the fake cannot agree with a lookup
|
|
126
|
+
// production would miss.
|
|
127
|
+
const tag = args[args.indexOf('--version-tag') + 1];
|
|
128
|
+
const existing = tag ? findVersionIdByTag(this.versions, tag) : null;
|
|
129
|
+
if (existing) this.shiftTrafficTo(existing);
|
|
130
|
+
}
|
|
131
|
+
if (args[0] === 'deploy') {
|
|
132
|
+
const tagIndex = args.indexOf('--tag');
|
|
133
|
+
if (tagIndex >= 0) {
|
|
134
|
+
const versionId = `uploaded-${args[tagIndex + 1]}`;
|
|
135
|
+
this.versionList().push({ id: versionId, annotations: { 'workers/tag': args[tagIndex + 1] } });
|
|
136
|
+
this.shiftTrafficTo(versionId);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
87
139
|
await this.onCall?.(args, options.cwd);
|
|
88
140
|
return success({});
|
|
89
141
|
}
|
|
@@ -93,6 +145,10 @@ class FakeCloudflare implements CloudflareClient {
|
|
|
93
145
|
namespaces: LiveKvNamespace[] = [];
|
|
94
146
|
buckets: R2Bucket[] = [];
|
|
95
147
|
scripts: WorkerScript[] = [{ id: 'fixture-worker-pr123' }];
|
|
148
|
+
/** Secret names each Worker already holds, by Worker name. */
|
|
149
|
+
secrets: Record<string, string[]> = {};
|
|
150
|
+
/** Every Worker whose secrets were queried, in order, so a test can prove one was never asked. */
|
|
151
|
+
secretQueries: string[] = [];
|
|
96
152
|
domains: WorkerDomain[] = [];
|
|
97
153
|
zones: CloudflareZone[] = [];
|
|
98
154
|
routes: Record<string, WorkerRoute[]> = {};
|
|
@@ -132,6 +188,20 @@ class FakeCloudflare implements CloudflareClient {
|
|
|
132
188
|
async listWorkerScripts(): Promise<WorkerScript[]> {
|
|
133
189
|
return this.scripts;
|
|
134
190
|
}
|
|
191
|
+
async listWorkerSecrets(workerName: string): Promise<string[]> {
|
|
192
|
+
this.secretQueries.push(workerName);
|
|
193
|
+
// Cloudflare answers 404 for a Worker that is not there. Wrangler used to hand that back as an
|
|
194
|
+
// empty list; it no longer does, and code that reads the failure as "no secrets" inverts the
|
|
195
|
+
// one check a first deployment depends on.
|
|
196
|
+
if (!this.scripts.some((script) => script.id === workerName)) {
|
|
197
|
+
throw new CloudflareApiError(
|
|
198
|
+
`Cloudflare API /workers/scripts/${workerName}/secrets failed: not found`,
|
|
199
|
+
404,
|
|
200
|
+
[10007],
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return this.secrets[workerName] ?? [];
|
|
204
|
+
}
|
|
135
205
|
async deleteWorkerScript(name: string): Promise<void> {
|
|
136
206
|
this.mutations.push(`delete-worker:${name}`);
|
|
137
207
|
}
|
|
@@ -179,8 +249,8 @@ class FakeCloudflare implements CloudflareClient {
|
|
|
179
249
|
}
|
|
180
250
|
}
|
|
181
251
|
|
|
182
|
-
describe('deploy-stage
|
|
183
|
-
it('
|
|
252
|
+
describe('deploy-stage against live state', () => {
|
|
253
|
+
it('uploads nothing and shifts no traffic when the live version already is the task hash', async () => {
|
|
184
254
|
const root = await fixtureRoot();
|
|
185
255
|
const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
|
|
186
256
|
versions: [{ version_id: 'version-1', percentage: 100 }],
|
|
@@ -189,14 +259,17 @@ describe('deploy-stage remote version fallback', () => {
|
|
|
189
259
|
const result = await deployStage(root, { stage: 'pr123' }, dependencies(runner, new FakeCloudflare()));
|
|
190
260
|
|
|
191
261
|
expect(result.action).toBe('remote-cache-hit');
|
|
262
|
+
// Two reads and nothing else. This is what makes a redundant deploy — the one a cross-project
|
|
263
|
+
// `dependsOn` edge adds — provably free instead of assumed free.
|
|
192
264
|
expect(runner.calls.map((call) => call.args.slice(0, 2))).toEqual([
|
|
193
265
|
['versions', 'list'],
|
|
194
266
|
['deployments', 'status'],
|
|
195
267
|
]);
|
|
196
268
|
});
|
|
197
269
|
|
|
198
|
-
it('
|
|
270
|
+
it('redeploys after a rollback, when the live version is no longer the task hash', async () => {
|
|
199
271
|
const root = await fixtureRoot();
|
|
272
|
+
// The build for this hash is still uploaded; someone rolled traffic back to the older version.
|
|
200
273
|
const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
|
|
201
274
|
versions: [{ version_id: 'version-2', percentage: 100 }],
|
|
202
275
|
});
|
|
@@ -204,11 +277,108 @@ describe('deploy-stage remote version fallback', () => {
|
|
|
204
277
|
const result = await deployStage(root, { stage: 'pr123' }, dependencies(runner, new FakeCloudflare()));
|
|
205
278
|
|
|
206
279
|
expect(result.action).toBe('activated');
|
|
207
|
-
|
|
208
|
-
expect(runner.
|
|
280
|
+
const shiftIndex = runner.calls.findIndex((call) => call.args[0] === 'versions' && call.args[1] === 'deploy');
|
|
281
|
+
expect(runner.calls[shiftIndex]?.args.slice(0, 4)).toEqual(['versions', 'deploy', '--version-tag', `nx-${HASH}`]);
|
|
282
|
+
// And the step does not return until it has read live state again after that shift.
|
|
283
|
+
expect(runner.calls.slice(shiftIndex + 1).some((call) => call.args[0] === 'deployments')).toBe(true);
|
|
209
284
|
expect(existsSync(requiredTestValue(runner.configPathSeen, 'config path'))).toBe(false);
|
|
210
285
|
});
|
|
211
286
|
|
|
287
|
+
it('waits out propagation before reporting the deploy done', async () => {
|
|
288
|
+
const root = await fixtureRoot();
|
|
289
|
+
const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
|
|
290
|
+
versions: [{ version_id: 'version-2', percentage: 100 }],
|
|
291
|
+
});
|
|
292
|
+
runner.propagationPolls = 3;
|
|
293
|
+
|
|
294
|
+
const result = await deployStage(root, { stage: 'pr123' }, dependencies(runner, new FakeCloudflare()));
|
|
295
|
+
|
|
296
|
+
expect(result.action).toBe('activated');
|
|
297
|
+
const statusReads = runner.calls.filter((call) => call.args[0] === 'deployments').length;
|
|
298
|
+
expect(statusReads).toBe(5);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('fails with what it expected and what it saw when the version never becomes live', async () => {
|
|
302
|
+
const root = await fixtureRoot();
|
|
303
|
+
const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
|
|
304
|
+
versions: [{ version_id: 'version-2', percentage: 100 }],
|
|
305
|
+
});
|
|
306
|
+
runner.propagationPolls = Number.POSITIVE_INFINITY;
|
|
307
|
+
|
|
308
|
+
await expect(deployStage(root, { stage: 'pr123' }, dependencies(runner, new FakeCloudflare()))).rejects.toThrow(
|
|
309
|
+
`fixture-worker-pr123: deployed version nx-${HASH} was not serving traffic after 10s; live is an untagged version (version-2)`,
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('leaves the cache holding the tag it made live, so the next query needs no API call', async () => {
|
|
314
|
+
const root = await fixtureRoot();
|
|
315
|
+
const cacheDirectory = await mkdtemp(join(tmpdir(), 'smoo-live-version-'));
|
|
316
|
+
roots.push(cacheDirectory);
|
|
317
|
+
const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
|
|
318
|
+
versions: [{ version_id: 'version-2', percentage: 100 }],
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
await deployStage(
|
|
322
|
+
root,
|
|
323
|
+
{ stage: 'pr123' },
|
|
324
|
+
{ ...dependencies(runner, new FakeCloudflare()), liveVersionCacheDirectory: cacheDirectory },
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
const cached = await readCachedLiveVersion(
|
|
328
|
+
cacheDirectory,
|
|
329
|
+
{ accountId: 'account-1', workerName: 'fixture-worker-pr123', stage: 'pr123' },
|
|
330
|
+
LIVE_VERSION_CACHE_TTL_MS,
|
|
331
|
+
);
|
|
332
|
+
expect(cached).toMatchObject({ versionTag: `nx-${HASH}`, versionId: 'version-1' });
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('is not done until a declared version endpoint answers with the new tag', async () => {
|
|
336
|
+
const root = await fixtureRoot();
|
|
337
|
+
const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
|
|
338
|
+
versions: [{ version_id: 'version-2', percentage: 100 }],
|
|
339
|
+
});
|
|
340
|
+
const bodies = [`nx-${'0'.repeat(20)}`, `nx-${'0'.repeat(20)}`, `nx-${HASH}`];
|
|
341
|
+
const requested: string[] = [];
|
|
342
|
+
const stubFetch: FetchLike = async (input) => {
|
|
343
|
+
requested.push(String(input));
|
|
344
|
+
return new Response(bodies.shift() ?? `nx-${HASH}`);
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const result = await deployStage(
|
|
348
|
+
root,
|
|
349
|
+
{ stage: 'pr123', versionEndpoint: 'https://fixture.example.test/__version' },
|
|
350
|
+
{
|
|
351
|
+
...dependencies(runner, new FakeCloudflare()),
|
|
352
|
+
wait: { ...fakeWait(), fetch: stubFetch },
|
|
353
|
+
},
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
expect(result.action).toBe('activated');
|
|
357
|
+
expect(requested).toEqual([
|
|
358
|
+
'https://fixture.example.test/__version',
|
|
359
|
+
'https://fixture.example.test/__version',
|
|
360
|
+
'https://fixture.example.test/__version',
|
|
361
|
+
]);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('fails naming the endpoint answer when the edge never reports the new tag', async () => {
|
|
365
|
+
const root = await fixtureRoot();
|
|
366
|
+
const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
|
|
367
|
+
versions: [{ version_id: 'version-2', percentage: 100 }],
|
|
368
|
+
});
|
|
369
|
+
const stubFetch: FetchLike = async () => new Response('nx-previous');
|
|
370
|
+
|
|
371
|
+
await expect(
|
|
372
|
+
deployStage(
|
|
373
|
+
root,
|
|
374
|
+
{ stage: 'pr123', versionEndpoint: 'https://fixture.example.test/__version' },
|
|
375
|
+
{ ...dependencies(runner, new FakeCloudflare()), wait: { ...fakeWait(), fetch: stubFetch } },
|
|
376
|
+
),
|
|
377
|
+
).rejects.toThrow(
|
|
378
|
+
`fixture-worker-pr123: https://fixture.example.test/__version did not report version nx-${HASH} after 10s; it reported nx-previous`,
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
|
|
212
382
|
it('uploads a missing tag with a temporary config and secure secrets file, then removes both', async () => {
|
|
213
383
|
const root = await fixtureRoot();
|
|
214
384
|
await writeFile(join(root, '.dev.vars.example'), 'FIXTURE_SECRET=""\nFIXTURE_TOKEN=""\n');
|
|
@@ -231,9 +401,12 @@ describe('deploy-stage remote version fallback', () => {
|
|
|
231
401
|
);
|
|
232
402
|
|
|
233
403
|
expect(result.action).toBe('deployed');
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
404
|
+
const upload = requiredTestValue(
|
|
405
|
+
runner.calls.find((call) => call.args[0] === 'deploy'),
|
|
406
|
+
'upload call',
|
|
407
|
+
);
|
|
408
|
+
expect(upload.args).toContain('--tag');
|
|
409
|
+
expect(upload.args).toContain(`nx-${HASH}`);
|
|
237
410
|
expect(runner.secretsMode).toBe(0o600);
|
|
238
411
|
expect(JSON.parse(requiredTestValue(runner.secretsJson, 'secrets JSON'))).toEqual({
|
|
239
412
|
FIXTURE_SECRET: 'shared-secret',
|
|
@@ -243,11 +416,13 @@ describe('deploy-stage remote version fallback', () => {
|
|
|
243
416
|
expect(existsSync(requiredTestValue(runner.secretsPathSeen, 'secrets path'))).toBe(false);
|
|
244
417
|
});
|
|
245
418
|
|
|
246
|
-
it('
|
|
419
|
+
it('sends only the values it has and leaves a secret the Worker already holds untouched', async () => {
|
|
247
420
|
const root = await fixtureRoot();
|
|
248
421
|
await writeFile(join(root, '.dev.vars.example'), 'FIXTURE_SECRET=""\nFIXTURE_TOKEN=""\n');
|
|
249
422
|
const runner = new FakeRunner([], {});
|
|
250
423
|
const cloudflare = new FakeCloudflare();
|
|
424
|
+
cloudflare.scripts = [{ id: 'fixture-worker-staging' }];
|
|
425
|
+
cloudflare.secrets['fixture-worker-staging'] = ['FIXTURE_TOKEN'];
|
|
251
426
|
|
|
252
427
|
const result = await deployStage(
|
|
253
428
|
root,
|
|
@@ -263,6 +438,8 @@ describe('deploy-stage remote version fallback', () => {
|
|
|
263
438
|
},
|
|
264
439
|
);
|
|
265
440
|
|
|
441
|
+
// `--secrets-file` applies additively, so naming only what this shell has leaves FIXTURE_TOKEN
|
|
442
|
+
// exactly as the Worker holds it. That silence is only safe because the gate proved it is held.
|
|
266
443
|
expect(result.action).toBe('deployed');
|
|
267
444
|
expect(JSON.parse(requiredTestValue(runner.secretsJson, 'secrets JSON'))).toEqual({
|
|
268
445
|
FIXTURE_SECRET: 'shared-secret',
|
|
@@ -313,6 +490,230 @@ describe('deploy-stage remote version fallback', () => {
|
|
|
313
490
|
});
|
|
314
491
|
});
|
|
315
492
|
|
|
493
|
+
/**
|
|
494
|
+
* `smoo.wrangler.secretStages` answers two questions from one declaration, and both are load
|
|
495
|
+
* bearing: which secrets a stage must have before it may deploy, and which secrets it is allowed
|
|
496
|
+
* to receive at all. The second direction is the security-relevant one — a capability CI exports
|
|
497
|
+
* for preview stages must not ride a production deploy just because the variable is set.
|
|
498
|
+
*/
|
|
499
|
+
describe('stage-scoped deployment secrets', () => {
|
|
500
|
+
const STAGED_FIXTURE = `${FIXTURE}
|
|
501
|
+
[env.production]
|
|
502
|
+
name = "fixture-worker-production"
|
|
503
|
+
workers_dev = false
|
|
504
|
+
|
|
505
|
+
[env.production.vars]
|
|
506
|
+
ENVIRONMENT = "production"
|
|
507
|
+
`;
|
|
508
|
+
|
|
509
|
+
async function scopedRoot(secrets: string[], secretStages?: Record<string, string[]>): Promise<string> {
|
|
510
|
+
const root = await fixtureRoot(STAGED_FIXTURE);
|
|
511
|
+
await writeFile(join(root, '.dev.vars.example'), secrets.map((name) => `${name}=""\n`).join(''));
|
|
512
|
+
if (secretStages) {
|
|
513
|
+
await writeFile(
|
|
514
|
+
join(root, 'package.json'),
|
|
515
|
+
`${JSON.stringify({ name: '@acme/api', smoo: { wrangler: { secretStages } } }, null, 2)}\n`,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
return root;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function environment(values: Record<string, string> = {}): NodeJS.ProcessEnv {
|
|
522
|
+
return { CLOUDFLARE_ACCOUNT_ID: 'account-1', CLOUDFLARE_API_TOKEN: 'token', ...values };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
it('refuses a first deploy whose stage-required secret is exported nowhere, naming every one', async () => {
|
|
526
|
+
const root = await scopedRoot(['SESSION_SECRET', 'OAUTH_STATE_KEY'], {
|
|
527
|
+
OAUTH_STATE_KEY: ['staging', 'production'],
|
|
528
|
+
});
|
|
529
|
+
const runner = new FakeRunner([], {});
|
|
530
|
+
const cloudflare = new FakeCloudflare();
|
|
531
|
+
|
|
532
|
+
await expect(
|
|
533
|
+
deployStage(root, { stage: 'production' }, { runner, cloudflare, processEnv: environment() }),
|
|
534
|
+
).rejects.toThrow(
|
|
535
|
+
/Refusing to deploy fixture-worker-production to production[\s\S]*SESSION_SECRET[\s\S]*OAUTH_STATE_KEY/,
|
|
536
|
+
);
|
|
537
|
+
expect(cloudflare.mutations).toEqual([]);
|
|
538
|
+
expect(runner.calls).toEqual([]);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('treats a Worker that is not there as holding nothing, never as needing nothing', async () => {
|
|
542
|
+
const root = await scopedRoot(['OAUTH_STATE_KEY']);
|
|
543
|
+
const cloudflare = new FakeCloudflare();
|
|
544
|
+
|
|
545
|
+
// The double answers 404 for an absent Worker, exactly as Cloudflare does. Reading that error
|
|
546
|
+
// as "no secrets" would let this deploy through; asking at all would surface the 404 instead
|
|
547
|
+
// of the refusal. Neither happens: the script listing already said the Worker holds nothing.
|
|
548
|
+
await expect(
|
|
549
|
+
deployStage(
|
|
550
|
+
root,
|
|
551
|
+
{ stage: 'production' },
|
|
552
|
+
{ runner: new FakeRunner([], {}), cloudflare, processEnv: environment() },
|
|
553
|
+
),
|
|
554
|
+
).rejects.toThrow(/Refusing to deploy fixture-worker-production to production[\s\S]*OAUTH_STATE_KEY/);
|
|
555
|
+
expect(cloudflare.secretQueries).toEqual([]);
|
|
556
|
+
expect(cloudflare.mutations).toEqual([]);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('requires an unscoped secret of every stage, including a pull-request stage', async () => {
|
|
560
|
+
const root = await scopedRoot(['SESSION_SECRET'], { OTHER_SECRET: ['staging'] });
|
|
561
|
+
await writeFile(join(root, '.dev.vars.example'), 'SESSION_SECRET=""\nOTHER_SECRET=""\n');
|
|
562
|
+
const cloudflare = new FakeCloudflare();
|
|
563
|
+
|
|
564
|
+
await expect(
|
|
565
|
+
deployStage(root, { stage: 'pr77' }, dependencies(new FakeRunner([], {}), cloudflare)),
|
|
566
|
+
).rejects.toThrow(/fixture-worker-pr77[\s\S]*SESSION_SECRET — unscoped, so every stage requires it/);
|
|
567
|
+
expect(cloudflare.mutations).toEqual([]);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('does not require a preview-scoped secret of production', async () => {
|
|
571
|
+
const root = await scopedRoot(['E2E_CONTROL_TOKEN'], { E2E_CONTROL_TOKEN: ['staging', 'preview'] });
|
|
572
|
+
const runner = new FakeRunner([], {});
|
|
573
|
+
const cloudflare = new FakeCloudflare();
|
|
574
|
+
|
|
575
|
+
const result = await deployStage(root, { stage: 'production' }, { runner, cloudflare, processEnv: environment() });
|
|
576
|
+
|
|
577
|
+
expect(result).toMatchObject({ action: 'deployed', workerName: 'fixture-worker-production' });
|
|
578
|
+
expect(runner.secretsPathSeen).toBeUndefined();
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('withholds a preview-scoped secret from production even when the deploying shell exports it', async () => {
|
|
582
|
+
const root = await scopedRoot(['API_TOKEN', 'E2E_CONTROL_TOKEN'], { E2E_CONTROL_TOKEN: ['staging', 'preview'] });
|
|
583
|
+
const runner = new FakeRunner([], {});
|
|
584
|
+
const cloudflare = new FakeCloudflare();
|
|
585
|
+
// The real environment, because that is what a child process inherits and what the shell of a
|
|
586
|
+
// laptop deploy actually looks like when CI has exported a preview capability.
|
|
587
|
+
process.env.E2E_CONTROL_TOKEN = 'teardown-value';
|
|
588
|
+
|
|
589
|
+
try {
|
|
590
|
+
const result = await deployStage(
|
|
591
|
+
root,
|
|
592
|
+
{ stage: 'production' },
|
|
593
|
+
{
|
|
594
|
+
runner,
|
|
595
|
+
cloudflare,
|
|
596
|
+
processEnv: environment({ API_TOKEN: 'api-value', E2E_CONTROL_TOKEN: 'teardown-value' }),
|
|
597
|
+
},
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
expect(result.action).toBe('deployed');
|
|
601
|
+
expect(JSON.parse(requiredTestValue(runner.secretsJson, 'secrets JSON'))).toEqual({ API_TOKEN: 'api-value' });
|
|
602
|
+
// Not in the payload, and not readable by the deploy either: the capability is absent from
|
|
603
|
+
// this stage at the process boundary, not merely left out of a file.
|
|
604
|
+
for (const call of runner.calls) {
|
|
605
|
+
expect(call.env.E2E_CONTROL_TOKEN).toBeUndefined();
|
|
606
|
+
}
|
|
607
|
+
expect(runner.calls.length).toBeGreaterThan(0);
|
|
608
|
+
} finally {
|
|
609
|
+
delete process.env.E2E_CONTROL_TOKEN;
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('sends a preview-scoped secret to the stages it is scoped to', async () => {
|
|
614
|
+
const scopes = { E2E_CONTROL_TOKEN: ['staging', 'preview'] };
|
|
615
|
+
const values = { API_TOKEN: 'api-value', E2E_CONTROL_TOKEN: 'teardown-value' };
|
|
616
|
+
|
|
617
|
+
for (const stage of ['staging', 'pr77'] as const) {
|
|
618
|
+
const root = await scopedRoot(['API_TOKEN', 'E2E_CONTROL_TOKEN'], scopes);
|
|
619
|
+
const runner = new FakeRunner([], {});
|
|
620
|
+
|
|
621
|
+
await deployStage(root, { stage }, { runner, cloudflare: new FakeCloudflare(), processEnv: environment(values) });
|
|
622
|
+
|
|
623
|
+
expect(JSON.parse(requiredTestValue(runner.secretsJson, `secrets JSON for ${stage}`))).toEqual(values);
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it('treats an empty scope as no stage at all, not as every stage', async () => {
|
|
628
|
+
const root = await scopedRoot(['API_TOKEN', 'LOCAL_ONLY_KEY'], { LOCAL_ONLY_KEY: [] });
|
|
629
|
+
const runner = new FakeRunner([], {});
|
|
630
|
+
|
|
631
|
+
// `[]` and "absent from the map" are opposite declarations. A check written as
|
|
632
|
+
// `!scope?.length` would collapse them and ship a local-development value to every stage.
|
|
633
|
+
const result = await deployStage(
|
|
634
|
+
root,
|
|
635
|
+
{ stage: 'production' },
|
|
636
|
+
{
|
|
637
|
+
runner,
|
|
638
|
+
cloudflare: new FakeCloudflare(),
|
|
639
|
+
processEnv: environment({ API_TOKEN: 'api-value', LOCAL_ONLY_KEY: 'laptop-value' }),
|
|
640
|
+
},
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
expect(result.action).toBe('deployed');
|
|
644
|
+
expect(JSON.parse(requiredTestValue(runner.secretsJson, 'secrets JSON'))).toEqual({ API_TOKEN: 'api-value' });
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it('refuses when the Worker holds a secret this stage is scoped out of, since a deploy cannot remove it', async () => {
|
|
648
|
+
const root = await scopedRoot(['E2E_CONTROL_TOKEN'], { E2E_CONTROL_TOKEN: ['staging', 'preview'] });
|
|
649
|
+
const cloudflare = new FakeCloudflare();
|
|
650
|
+
cloudflare.scripts = [{ id: 'fixture-worker-production' }];
|
|
651
|
+
cloudflare.secrets['fixture-worker-production'] = ['E2E_CONTROL_TOKEN'];
|
|
652
|
+
|
|
653
|
+
await expect(
|
|
654
|
+
deployStage(
|
|
655
|
+
root,
|
|
656
|
+
{ stage: 'production' },
|
|
657
|
+
{ runner: new FakeRunner([], {}), cloudflare, processEnv: environment() },
|
|
658
|
+
),
|
|
659
|
+
).rejects.toThrow(/keeps out of production[\s\S]*E2E_CONTROL_TOKEN — scoped to staging, preview/);
|
|
660
|
+
expect(cloudflare.mutations).toEqual([]);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it('refuses a scope written against a name no secret has, rather than scoping nothing', async () => {
|
|
664
|
+
const root = await scopedRoot(['E2E_CONTROL_TOKEN'], { E2E_CONTROL_TOEKN: ['staging'] });
|
|
665
|
+
|
|
666
|
+
await expect(
|
|
667
|
+
deployStage(
|
|
668
|
+
root,
|
|
669
|
+
{ stage: 'production' },
|
|
670
|
+
{ runner: new FakeRunner([], {}), cloudflare: new FakeCloudflare(), processEnv: environment() },
|
|
671
|
+
),
|
|
672
|
+
).rejects.toThrow(/secretStages scopes E2E_CONTROL_TOEKN, which .dev.vars.example does not declare/);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it('names a malformed scope declaration by its path in the manifest', async () => {
|
|
676
|
+
const root = await scopedRoot(['E2E_CONTROL_TOKEN'], { E2E_CONTROL_TOKEN: ['pr7'] });
|
|
677
|
+
|
|
678
|
+
await expect(
|
|
679
|
+
deployStage(
|
|
680
|
+
root,
|
|
681
|
+
{ stage: 'production' },
|
|
682
|
+
{ runner: new FakeRunner([], {}), cloudflare: new FakeCloudflare(), processEnv: environment() },
|
|
683
|
+
),
|
|
684
|
+
).rejects.toThrow(
|
|
685
|
+
/package\.json declares an invalid smoo\.wrangler block: smoo\.wrangler\.secretStages\.E2E_CONTROL_TOKEN\[0\]/,
|
|
686
|
+
);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it('keeps every secret value out of the refusal and off every command line', async () => {
|
|
690
|
+
const root = await scopedRoot(['API_TOKEN', 'SESSION_SECRET']);
|
|
691
|
+
const runner = new FakeRunner([], {});
|
|
692
|
+
const cloudflare = new FakeCloudflare();
|
|
693
|
+
|
|
694
|
+
const refused = await deployStage(
|
|
695
|
+
root,
|
|
696
|
+
{ stage: 'production' },
|
|
697
|
+
{ runner, cloudflare, processEnv: environment({ API_TOKEN: 'api-value' }) },
|
|
698
|
+
).catch((error: unknown) => (error instanceof Error ? error.message : String(error)));
|
|
699
|
+
|
|
700
|
+
expect(refused).toContain('SESSION_SECRET');
|
|
701
|
+
expect(refused).not.toContain('api-value');
|
|
702
|
+
|
|
703
|
+
// And the same on the path that does deploy: values reach wrangler through a 0600 file only.
|
|
704
|
+
cloudflare.scripts = [{ id: 'fixture-worker-production' }];
|
|
705
|
+
cloudflare.secrets['fixture-worker-production'] = ['SESSION_SECRET'];
|
|
706
|
+
await deployStage(
|
|
707
|
+
root,
|
|
708
|
+
{ stage: 'production' },
|
|
709
|
+
{ runner, cloudflare, processEnv: environment({ API_TOKEN: 'api-value' }) },
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
expect(runner.calls.flatMap((call) => call.args).join('\u0000')).not.toContain('api-value');
|
|
713
|
+
expect(JSON.parse(requiredTestValue(runner.secretsJson, 'secrets JSON'))).toEqual({ API_TOKEN: 'api-value' });
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
|
|
316
717
|
describe('cleanup-pr exact stage matching', () => {
|
|
317
718
|
it('rejects an invalid PR before touching the client', async () => {
|
|
318
719
|
const cloudflare = new FakeCloudflare();
|
|
@@ -615,6 +1016,10 @@ describe('deployStage with a flat JSON config', () => {
|
|
|
615
1016
|
['deployments', 'status'],
|
|
616
1017
|
['d1', 'migrations'],
|
|
617
1018
|
['versions', 'deploy'],
|
|
1019
|
+
// The traffic shift is not the end of the deploy: the step re-reads live state and only
|
|
1020
|
+
// returns once the tag it activated is the one being served.
|
|
1021
|
+
['deployments', 'status'],
|
|
1022
|
+
['versions', 'list'],
|
|
618
1023
|
]);
|
|
619
1024
|
});
|
|
620
1025
|
|
|
@@ -679,7 +1084,7 @@ describe('deployStage with a flat JSON config', () => {
|
|
|
679
1084
|
|
|
680
1085
|
await expect(
|
|
681
1086
|
deployStage(root, { stage: 'pr7', config: configPath }, dependencies(new FakeRunner(), cloudflare)),
|
|
682
|
-
).rejects.toThrow(/
|
|
1087
|
+
).rejects.toThrow(/Refusing to deploy fixture-website-preview-pr7 to pr7[\s\S]*FIXTURE_SECRET/);
|
|
683
1088
|
expect(cloudflare.mutations).toEqual([]);
|
|
684
1089
|
});
|
|
685
1090
|
|
|
@@ -736,10 +1141,30 @@ async function fixtureRoot(toml = FIXTURE): Promise<string> {
|
|
|
736
1141
|
return root;
|
|
737
1142
|
}
|
|
738
1143
|
|
|
1144
|
+
/**
|
|
1145
|
+
* A wait whose clock only advances when the code under test sleeps: real bounds, no real seconds,
|
|
1146
|
+
* and a deterministic poll count a test can assert on.
|
|
1147
|
+
*/
|
|
1148
|
+
function fakeWait(): { budgetMs: number; intervalMs: number; now: () => number; sleep: (ms: number) => Promise<void> } {
|
|
1149
|
+
let clock = 0;
|
|
1150
|
+
return {
|
|
1151
|
+
budgetMs: 10_000,
|
|
1152
|
+
intervalMs: 1_000,
|
|
1153
|
+
now: () => clock,
|
|
1154
|
+
sleep: async (ms: number) => {
|
|
1155
|
+
clock += ms;
|
|
1156
|
+
},
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
|
|
739
1160
|
function dependencies(runner: ProcessRunner, cloudflare: CloudflareClient) {
|
|
1161
|
+
const cacheDirectory = mkdtempSync(join(tmpdir(), 'smoo-live-version-'));
|
|
1162
|
+
roots.push(cacheDirectory);
|
|
740
1163
|
return {
|
|
741
1164
|
runner,
|
|
742
1165
|
cloudflare,
|
|
1166
|
+
wait: fakeWait(),
|
|
1167
|
+
liveVersionCacheDirectory: cacheDirectory,
|
|
743
1168
|
processEnv: {
|
|
744
1169
|
CLOUDFLARE_ACCOUNT_ID: 'account-1',
|
|
745
1170
|
CLOUDFLARE_API_TOKEN: 'token',
|