@smoothbricks/cli 0.11.17 → 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 +22 -4
- package/dist/cli.js +10 -7
- 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 +199 -2
- 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/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 +52 -13
- package/dist/secrets/commands.d.ts.map +1 -1
- package/dist/secrets/commands.js +220 -34
- package/dist/secrets/index.d.ts +21 -1
- package/dist/secrets/index.d.ts.map +1 -1
- package/dist/secrets/index.js +110 -4
- 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.map +1 -1
- package/dist/wrangler/deploy-stage.js +50 -24
- 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 +33 -10
- package/src/monorepo/__tests__/ci-workflow.test.ts +161 -0
- package/src/monorepo/ci-workflow.ts +277 -2
- package/src/monorepo/managed-files.test.ts +27 -0
- package/src/monorepo/managed-files.ts +56 -2
- package/src/monorepo/package-policy.test.ts +1 -1
- package/src/release/__tests__/github-release.test.ts +8 -4
- 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 +237 -35
- package/src/secrets/index.test.ts +118 -1
- package/src/secrets/index.ts +108 -4
- 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 +258 -11
- package/src/wrangler/deploy-stage.ts +70 -38
- package/src/wrangler/stage-secrets.ts +146 -0
|
@@ -3,15 +3,16 @@ 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,
|
|
@@ -144,6 +145,10 @@ class FakeCloudflare implements CloudflareClient {
|
|
|
144
145
|
namespaces: LiveKvNamespace[] = [];
|
|
145
146
|
buckets: R2Bucket[] = [];
|
|
146
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[] = [];
|
|
147
152
|
domains: WorkerDomain[] = [];
|
|
148
153
|
zones: CloudflareZone[] = [];
|
|
149
154
|
routes: Record<string, WorkerRoute[]> = {};
|
|
@@ -183,6 +188,20 @@ class FakeCloudflare implements CloudflareClient {
|
|
|
183
188
|
async listWorkerScripts(): Promise<WorkerScript[]> {
|
|
184
189
|
return this.scripts;
|
|
185
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
|
+
}
|
|
186
205
|
async deleteWorkerScript(name: string): Promise<void> {
|
|
187
206
|
this.mutations.push(`delete-worker:${name}`);
|
|
188
207
|
}
|
|
@@ -397,11 +416,13 @@ describe('deploy-stage against live state', () => {
|
|
|
397
416
|
expect(existsSync(requiredTestValue(runner.secretsPathSeen, 'secrets path'))).toBe(false);
|
|
398
417
|
});
|
|
399
418
|
|
|
400
|
-
it('
|
|
419
|
+
it('sends only the values it has and leaves a secret the Worker already holds untouched', async () => {
|
|
401
420
|
const root = await fixtureRoot();
|
|
402
421
|
await writeFile(join(root, '.dev.vars.example'), 'FIXTURE_SECRET=""\nFIXTURE_TOKEN=""\n');
|
|
403
422
|
const runner = new FakeRunner([], {});
|
|
404
423
|
const cloudflare = new FakeCloudflare();
|
|
424
|
+
cloudflare.scripts = [{ id: 'fixture-worker-staging' }];
|
|
425
|
+
cloudflare.secrets['fixture-worker-staging'] = ['FIXTURE_TOKEN'];
|
|
405
426
|
|
|
406
427
|
const result = await deployStage(
|
|
407
428
|
root,
|
|
@@ -417,6 +438,8 @@ describe('deploy-stage against live state', () => {
|
|
|
417
438
|
},
|
|
418
439
|
);
|
|
419
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.
|
|
420
443
|
expect(result.action).toBe('deployed');
|
|
421
444
|
expect(JSON.parse(requiredTestValue(runner.secretsJson, 'secrets JSON'))).toEqual({
|
|
422
445
|
FIXTURE_SECRET: 'shared-secret',
|
|
@@ -467,6 +490,230 @@ describe('deploy-stage against live state', () => {
|
|
|
467
490
|
});
|
|
468
491
|
});
|
|
469
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
|
+
|
|
470
717
|
describe('cleanup-pr exact stage matching', () => {
|
|
471
718
|
it('rejects an invalid PR before touching the client', async () => {
|
|
472
719
|
const cloudflare = new FakeCloudflare();
|
|
@@ -837,7 +1084,7 @@ describe('deployStage with a flat JSON config', () => {
|
|
|
837
1084
|
|
|
838
1085
|
await expect(
|
|
839
1086
|
deployStage(root, { stage: 'pr7', config: configPath }, dependencies(new FakeRunner(), cloudflare)),
|
|
840
|
-
).rejects.toThrow(/
|
|
1087
|
+
).rejects.toThrow(/Refusing to deploy fixture-website-preview-pr7 to pr7[\s\S]*FIXTURE_SECRET/);
|
|
841
1088
|
expect(cloudflare.mutations).toEqual([]);
|
|
842
1089
|
});
|
|
843
1090
|
|
|
@@ -1,5 +1,4 @@
|
|
|
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
4
|
import { parseJsonFileText } from '../lib/json.js';
|
|
@@ -16,7 +15,6 @@ import {
|
|
|
16
15
|
type VersionEndpointWaitOptions,
|
|
17
16
|
writeCachedLiveVersion,
|
|
18
17
|
} from './live-version.js';
|
|
19
|
-
import { parseDevVarsExample } from './prepare-env.js';
|
|
20
18
|
import {
|
|
21
19
|
type ConfiguredStageResourcePlan,
|
|
22
20
|
type DeploymentStage,
|
|
@@ -32,6 +30,13 @@ import {
|
|
|
32
30
|
planPullRequestResources,
|
|
33
31
|
pullRequestStage,
|
|
34
32
|
} from './stage.js';
|
|
33
|
+
import {
|
|
34
|
+
planStageSecrets,
|
|
35
|
+
readDeclaredSecretNames,
|
|
36
|
+
readSecretStageMap,
|
|
37
|
+
type StageSecretPlan,
|
|
38
|
+
stageSecretRefusal,
|
|
39
|
+
} from './stage-secrets.js';
|
|
35
40
|
|
|
36
41
|
export interface ProcessResult {
|
|
37
42
|
exitCode: number;
|
|
@@ -118,20 +123,25 @@ export async function deployStage(
|
|
|
118
123
|
dependencies.cloudflare ??
|
|
119
124
|
new CloudflareRestClient(accountId, requiredEnvironmentValue(apiToken, 'CLOUDFLARE_API_TOKEN'));
|
|
120
125
|
const runner = dependencies.runner ?? new BunProcessRunner();
|
|
121
|
-
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.
|
|
122
129
|
const secretValues: Record<string, string> = {};
|
|
123
|
-
for (const name of
|
|
130
|
+
for (const name of secretPlan.required) {
|
|
124
131
|
const value = processEnv[name];
|
|
125
132
|
if (value) secretValues[name] = value;
|
|
126
133
|
}
|
|
127
|
-
const
|
|
134
|
+
const gate = stageSecretGate(secretPlan, new Set(Object.keys(secretValues)), cloudflare);
|
|
128
135
|
let temporaryConfigPath: string | undefined;
|
|
129
136
|
let temporarySecretsPath: string | undefined;
|
|
130
137
|
try {
|
|
131
138
|
const prepared = options.config
|
|
132
|
-
? await prepareFlatConfig(options.config, stage, accountId,
|
|
133
|
-
: await prepareTomlConfig(cwd, stage, accountId,
|
|
139
|
+
? await prepareFlatConfig(options.config, stage, accountId, gate, cloudflare)
|
|
140
|
+
: await prepareTomlConfig(cwd, stage, accountId, gate, cloudflare);
|
|
134
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);
|
|
135
145
|
const workerExists = await reconcileStageResources(prepared.plan, cloudflare);
|
|
136
146
|
const versionTag = nxTaskVersionTag(processEnv);
|
|
137
147
|
const envArgs = prepared.envFlag ? ['--env', prepared.envFlag] : [];
|
|
@@ -139,7 +149,13 @@ export async function deployStage(
|
|
|
139
149
|
// renames the worker `<name>-<CLOUDFLARE_ENV>`. The build that produced the flat config is the
|
|
140
150
|
// caller that sets the variable, so the deploy would silently succeed under a name neither
|
|
141
151
|
// `reconcileStageResources` nor `versions list` looks at.
|
|
142
|
-
|
|
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 };
|
|
143
159
|
const workerName = prepared.plan.workerName;
|
|
144
160
|
const probe: LiveVersionProbe = {
|
|
145
161
|
deployments: () => wranglerJson(runner, ['deployments', 'status', '--name', workerName, '--json'], run),
|
|
@@ -228,7 +244,7 @@ async function prepareTomlConfig(
|
|
|
228
244
|
cwd: string,
|
|
229
245
|
stage: DeploymentStage,
|
|
230
246
|
accountId: string,
|
|
231
|
-
|
|
247
|
+
gate: SecretGate,
|
|
232
248
|
cloudflare: CloudflareClient,
|
|
233
249
|
): Promise<PreparedConfig> {
|
|
234
250
|
const committedConfigPath = join(cwd, 'wrangler.toml');
|
|
@@ -243,12 +259,7 @@ async function prepareTomlConfig(
|
|
|
243
259
|
}
|
|
244
260
|
const liveNamespaces = await cloudflare.listKvNamespaces();
|
|
245
261
|
const plan = planPullRequestResources(committedToml, stage, liveNamespaces);
|
|
246
|
-
const { kvNamespaceIds, d1DatabaseIds } = await provisionPullRequestResources(
|
|
247
|
-
plan,
|
|
248
|
-
missingSecrets,
|
|
249
|
-
cloudflare,
|
|
250
|
-
liveNamespaces,
|
|
251
|
-
);
|
|
262
|
+
const { kvNamespaceIds, d1DatabaseIds } = await provisionPullRequestResources(plan, gate, cloudflare, liveNamespaces);
|
|
252
263
|
const derivedToml = derivePullRequestWranglerConfig(committedToml, {
|
|
253
264
|
stage,
|
|
254
265
|
accountId,
|
|
@@ -270,7 +281,7 @@ async function prepareFlatConfig(
|
|
|
270
281
|
configPath: string,
|
|
271
282
|
stage: DeploymentStage,
|
|
272
283
|
accountId: string,
|
|
273
|
-
|
|
284
|
+
gate: SecretGate,
|
|
274
285
|
cloudflare: CloudflareClient,
|
|
275
286
|
): Promise<PreparedConfig> {
|
|
276
287
|
const flat = parseJsonFileText(configPath, await readFile(configPath, 'utf8'), parseFlatWranglerConfig);
|
|
@@ -283,12 +294,7 @@ async function prepareFlatConfig(
|
|
|
283
294
|
}
|
|
284
295
|
const liveNamespaces = await cloudflare.listKvNamespaces();
|
|
285
296
|
const plan = planPullRequestBindings(flat, stage, liveNamespaces);
|
|
286
|
-
const { kvNamespaceIds, d1DatabaseIds } = await provisionPullRequestResources(
|
|
287
|
-
plan,
|
|
288
|
-
missingSecrets,
|
|
289
|
-
cloudflare,
|
|
290
|
-
liveNamespaces,
|
|
291
|
-
);
|
|
297
|
+
const { kvNamespaceIds, d1DatabaseIds } = await provisionPullRequestResources(plan, gate, cloudflare, liveNamespaces);
|
|
292
298
|
const derived = derivePullRequestStageConfig(flat, { stage, accountId, kvNamespaceIds, d1DatabaseIds });
|
|
293
299
|
// Beside the original: its main/assets/migrations paths are relative to the file.
|
|
294
300
|
const temporaryConfigPath = join(dirname(configPath), `.wrangler.smoo-${process.pid}-${randomUUID()}.json`);
|
|
@@ -322,27 +328,58 @@ function migrationBindings(config: FlatWranglerConfig): string[] {
|
|
|
322
328
|
.map((database) => database.binding);
|
|
323
329
|
}
|
|
324
330
|
|
|
325
|
-
|
|
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>,
|
|
326
366
|
cloudflare: CloudflareClient,
|
|
327
367
|
workerName: string,
|
|
328
|
-
missingSecrets: string[],
|
|
329
368
|
): Promise<void> {
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
);
|
|
335
|
-
}
|
|
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);
|
|
336
373
|
}
|
|
337
374
|
|
|
338
|
-
/** 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. */
|
|
339
376
|
async function provisionPullRequestResources(
|
|
340
377
|
plan: PullRequestResourcePlan,
|
|
341
|
-
|
|
378
|
+
gate: SecretGate,
|
|
342
379
|
cloudflare: CloudflareClient,
|
|
343
380
|
liveNamespaces: LiveKvNamespace[],
|
|
344
381
|
): Promise<{ kvNamespaceIds: Map<string, string>; d1DatabaseIds: Map<string, string> }> {
|
|
345
|
-
await
|
|
382
|
+
await gate(plan.workerName);
|
|
346
383
|
const kvNamespaceIds = await ensureKvNamespaces(plan.kvNamespaces, liveNamespaces, cloudflare);
|
|
347
384
|
const d1DatabaseIds =
|
|
348
385
|
plan.d1Databases.length === 0
|
|
@@ -669,8 +706,3 @@ function requiredEnvironmentValue(value: string | undefined, name: string): stri
|
|
|
669
706
|
if (!value) throw new Error(`${name} is required.`);
|
|
670
707
|
return value;
|
|
671
708
|
}
|
|
672
|
-
|
|
673
|
-
function readSecretNames(cwd: string): string[] {
|
|
674
|
-
const path = join(cwd, '.dev.vars.example');
|
|
675
|
-
return existsSync(path) ? parseDevVarsExample(readFileSync(path, 'utf8')) : [];
|
|
676
|
-
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import typia from 'typia';
|
|
4
|
+
import { formatValidationErrors, parseJsonFileText } from '../lib/json.js';
|
|
5
|
+
import { parseDevVarsExample } from './prepare-env.js';
|
|
6
|
+
import { type DeploymentStage, isPullRequestStage } from './stage.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How a declaration names a stage. The fixed stages go by their own name; every `prN` stage is
|
|
10
|
+
* `preview`, because a scope is written once and pull-request numbers are not knowable in advance.
|
|
11
|
+
*/
|
|
12
|
+
export type SecretStageScope = 'staging' | 'production' | 'preview';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `smoo.wrangler.secretStages`: a declared secret name mapped to the stages it belongs to. The map
|
|
16
|
+
* answers two questions with one declaration, and both directions matter:
|
|
17
|
+
*
|
|
18
|
+
* - requirement — a stage the secret belongs to refuses to deploy without a value for it;
|
|
19
|
+
* - permission — a stage the secret does *not* belong to never receives it, however loudly the
|
|
20
|
+
* deploying shell exports it. A test-only capability exported by CI for preview stages must not
|
|
21
|
+
* ride along into production just because the variable happens to be set.
|
|
22
|
+
*
|
|
23
|
+
* A declared name absent from the map belongs to every stage. A name mapped to `[]` belongs to no
|
|
24
|
+
* stage, which is how a value that exists only for local development is declared.
|
|
25
|
+
*/
|
|
26
|
+
export type SecretStageMap = Record<string, SecretStageScope[]>;
|
|
27
|
+
|
|
28
|
+
/** The one block of a project's package.json this reader needs; every other field is ignored. */
|
|
29
|
+
interface WranglerPackageManifest {
|
|
30
|
+
smoo?: {
|
|
31
|
+
wrangler?: {
|
|
32
|
+
secretStages?: SecretStageMap;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const validateWranglerManifest = typia.json.createValidateParse<WranglerPackageManifest>();
|
|
38
|
+
|
|
39
|
+
/** Secret NAMES the project declares. Values live on the Worker; the repo only ever holds the keys. */
|
|
40
|
+
export function readDeclaredSecretNames(cwd: string): string[] {
|
|
41
|
+
const path = join(cwd, '.dev.vars.example');
|
|
42
|
+
return existsSync(path) ? parseDevVarsExample(readFileSync(path, 'utf8')) : [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `smoo.wrangler.secretStages` from the project's package.json; no file and no block scope nothing. */
|
|
46
|
+
export function readSecretStageMap(cwd: string): SecretStageMap {
|
|
47
|
+
const path = join(cwd, 'package.json');
|
|
48
|
+
if (!existsSync(path)) return {};
|
|
49
|
+
const result = parseJsonFileText(path, readFileSync(path, 'utf8'), validateWranglerManifest);
|
|
50
|
+
if (!result.success) {
|
|
51
|
+
throw new Error(`${path} declares an invalid smoo.wrangler block: ${formatValidationErrors(result.errors)}`);
|
|
52
|
+
}
|
|
53
|
+
return result.data.smoo?.wrangler?.secretStages ?? {};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Which of a project's declared secrets one stage may see, and which belong to other stages. */
|
|
57
|
+
export interface StageSecretPlan {
|
|
58
|
+
stage: DeploymentStage;
|
|
59
|
+
/** Declared names this stage requires — and the only ones a deploy of it may carry. */
|
|
60
|
+
required: string[];
|
|
61
|
+
/** Declared names scoped to other stages: withheld from this deploy even when a value is exported. */
|
|
62
|
+
withheld: string[];
|
|
63
|
+
/** The declaration itself, so a refusal can say why each name is where it is. */
|
|
64
|
+
scopes: SecretStageMap;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Splits the declared secrets by whether `stage` is in scope for each.
|
|
69
|
+
*
|
|
70
|
+
* A scope on an undeclared name is refused rather than ignored: it is almost always a typo of a
|
|
71
|
+
* real secret's name, and its effect is the dangerous direction — the misspelt entry scopes
|
|
72
|
+
* nothing while the real secret, still absent from the map, reaches every stage.
|
|
73
|
+
*/
|
|
74
|
+
export function planStageSecrets(declared: string[], stage: DeploymentStage, scopes: SecretStageMap): StageSecretPlan {
|
|
75
|
+
const declaredNames = new Set(declared);
|
|
76
|
+
const undeclared = Object.keys(scopes).filter((name) => !declaredNames.has(name));
|
|
77
|
+
if (undeclared.length > 0) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`smoo.wrangler.secretStages scopes ${undeclared.join(', ')}, which .dev.vars.example does not declare. ` +
|
|
80
|
+
'A scope on a name no secret has leaves the secret it was meant for unscoped, so that secret reaches every stage.',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
// Every `prN` stage answers to one written token: a scope cannot name pull requests in advance.
|
|
84
|
+
const scope: SecretStageScope = isPullRequestStage(stage) ? 'preview' : stage;
|
|
85
|
+
const required: string[] = [];
|
|
86
|
+
const withheld: string[] = [];
|
|
87
|
+
for (const name of declared) {
|
|
88
|
+
const declaredScope = scopes[name];
|
|
89
|
+
if (declaredScope === undefined || declaredScope.includes(scope)) {
|
|
90
|
+
required.push(name);
|
|
91
|
+
} else {
|
|
92
|
+
withheld.push(name);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { stage, required, withheld, scopes };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Why this deploy must not proceed, or nothing when it may.
|
|
100
|
+
*
|
|
101
|
+
* Two refusals, reported together so one run names every problem:
|
|
102
|
+
*
|
|
103
|
+
* - a required secret with no value anywhere. `--secrets-file` applies additively, so a deploy
|
|
104
|
+
* that never mentions a secret leaves whatever the Worker already holds — silence that reads as
|
|
105
|
+
* success while a secret introduced after the first deploy never arrives, and the code that
|
|
106
|
+
* needs it fails at runtime instead of here.
|
|
107
|
+
* - a withheld secret the Worker already holds. Filtering it out of this deploy's payload cannot
|
|
108
|
+
* remove it, so the scope would be nominal rather than enforced until someone deletes it.
|
|
109
|
+
*
|
|
110
|
+
* A value is never read, never formatted, and never named beyond its key.
|
|
111
|
+
*/
|
|
112
|
+
export function stageSecretRefusal(
|
|
113
|
+
plan: StageSecretPlan,
|
|
114
|
+
exported: ReadonlySet<string>,
|
|
115
|
+
held: ReadonlySet<string>,
|
|
116
|
+
workerName: string,
|
|
117
|
+
): string | undefined {
|
|
118
|
+
const unavailable = plan.required.filter((name) => !exported.has(name) && !held.has(name));
|
|
119
|
+
const installed = plan.withheld.filter((name) => held.has(name));
|
|
120
|
+
if (unavailable.length === 0 && installed.length === 0) return undefined;
|
|
121
|
+
const lines = [`Refusing to deploy ${workerName} to ${plan.stage}.`];
|
|
122
|
+
if (unavailable.length > 0) {
|
|
123
|
+
lines.push(
|
|
124
|
+
`Stage ${plan.stage} requires these secrets and no value exists for them, neither in this environment nor on the Worker:`,
|
|
125
|
+
...unavailable.map((name) => ` ${name} — ${scopeDescription(name, plan.scopes)}`),
|
|
126
|
+
'Export each one in the deploying shell before the deploy. Once the Worker exists,',
|
|
127
|
+
`\`wrangler secret put <NAME> --name ${workerName}\` supplies it too.`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (installed.length > 0) {
|
|
131
|
+
lines.push(
|
|
132
|
+
`The Worker holds these secrets, which smoo.wrangler.secretStages keeps out of ${plan.stage}:`,
|
|
133
|
+
...installed.map((name) => ` ${name} — ${scopeDescription(name, plan.scopes)}`),
|
|
134
|
+
`Delete each one with \`wrangler secret delete <NAME> --name ${workerName}\`. A deploy of this stage`,
|
|
135
|
+
'never sends them, so leaving them installed would keep the scope nominal.',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return lines.join('\n');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function scopeDescription(name: string, scopes: SecretStageMap): string {
|
|
142
|
+
const scope = scopes[name];
|
|
143
|
+
if (scope === undefined) return 'unscoped, so every stage requires it';
|
|
144
|
+
if (scope.length === 0) return 'scoped to no stage (local development only)';
|
|
145
|
+
return `scoped to ${scope.join(', ')}`;
|
|
146
|
+
}
|