@smoothbricks/cli 0.11.15 → 0.11.17
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 +43 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +44 -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/json.d.ts +9 -0
- package/dist/lib/json.d.ts.map +1 -1
- package/dist/lib/json.js +65 -53
- 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.map +1 -1
- package/dist/monorepo/ci-workflow.js +15 -6
- package/dist/monorepo/managed-files.d.ts +9 -0
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +28 -2
- 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 +110 -22
- package/dist/release/index.d.ts +1 -1
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +26 -4
- package/dist/release/private-npm.d.ts +5 -0
- package/dist/release/private-npm.d.ts.map +1 -1
- package/dist/release/private-npm.js +26 -5
- package/dist/secrets/commands.d.ts +45 -0
- package/dist/secrets/commands.d.ts.map +1 -0
- package/dist/secrets/commands.js +189 -0
- package/dist/secrets/index.d.ts +83 -0
- package/dist/secrets/index.d.ts.map +1 -0
- package/dist/secrets/index.js +149 -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 +39 -54
- 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/managed/raw/tooling/direnv/devenv.smoo.nix +4 -2
- package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +4 -1
- package/managed/raw/tooling/direnv/toolchain-stamp.ts +88 -0
- package/managed/templates/github/actions/setup-devenv/action.yml +1 -1
- package/package.json +2 -2
- package/src/cli.ts +46 -1
- package/src/github-ci/index.test.ts +92 -8
- package/src/github-ci/index.ts +7 -10
- package/src/lib/json.ts +9 -0
- package/src/lib/secret-names.ts +70 -0
- package/src/monorepo/__tests__/ci-workflow.test.ts +27 -2
- package/src/monorepo/__tests__/publish-workflow.test.ts +85 -13
- package/src/monorepo/ci-workflow.ts +17 -6
- package/src/monorepo/managed-files.test.ts +26 -0
- package/src/monorepo/managed-files.ts +36 -2
- package/src/monorepo/publish-workflow.ts +135 -21
- package/src/monorepo/secret-references.test.ts +1 -1
- package/src/release/__tests__/private-npm-status.test.ts +37 -0
- package/src/release/__tests__/private-npm-workflow.test.ts +20 -0
- package/src/release/index.ts +27 -2
- package/src/release/private-npm.ts +30 -5
- package/src/secrets/commands.ts +210 -0
- package/src/secrets/index.test.ts +92 -0
- package/src/secrets/index.ts +183 -0
- package/src/wrangler/deploy-stage.test.ts +190 -12
- package/src/wrangler/deploy-stage.ts +72 -45
- 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
|
@@ -1,5 +1,5 @@
|
|
|
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';
|
|
@@ -22,6 +22,12 @@ import {
|
|
|
22
22
|
type ProcessRunOptions,
|
|
23
23
|
writeTemporaryConfigForTest,
|
|
24
24
|
} from './deploy-stage.js';
|
|
25
|
+
import {
|
|
26
|
+
type FetchLike,
|
|
27
|
+
findVersionIdByTag,
|
|
28
|
+
LIVE_VERSION_CACHE_TTL_MS,
|
|
29
|
+
readCachedLiveVersion,
|
|
30
|
+
} from './live-version.js';
|
|
25
31
|
import type { LiveKvNamespace } from './stage.js';
|
|
26
32
|
|
|
27
33
|
const HASH = '16577780061662788004';
|
|
@@ -58,19 +64,49 @@ class FakeRunner implements ProcessRunner {
|
|
|
58
64
|
secretsJson: string | undefined;
|
|
59
65
|
/** Runs before the fake reports success, so a test can read a temporary file the command was handed. */
|
|
60
66
|
onCall: ((args: string[], cwd: string) => Promise<void>) | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* How many `deployments status` reads still answer with the OLD version after a traffic shift.
|
|
69
|
+
* This is the whole point of the fake: Cloudflare accepts the shift before it serves it.
|
|
70
|
+
*/
|
|
71
|
+
propagationPolls = 0;
|
|
72
|
+
|
|
73
|
+
private pendingVersionId: string | null = null;
|
|
61
74
|
|
|
62
75
|
constructor(
|
|
63
|
-
private
|
|
64
|
-
private
|
|
76
|
+
private versions: unknown = [],
|
|
77
|
+
private deployment: unknown = {},
|
|
65
78
|
) {}
|
|
66
79
|
|
|
80
|
+
/** The versions payload as a growable list, so an upload can add the version it just created. */
|
|
81
|
+
private versionList(): unknown[] {
|
|
82
|
+
const list: unknown[] = Array.isArray(this.versions) ? this.versions : [this.versions];
|
|
83
|
+
this.versions = list;
|
|
84
|
+
return list;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private shiftTrafficTo(versionId: string): void {
|
|
88
|
+
this.pendingVersionId = versionId;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private readDeployment(): unknown {
|
|
92
|
+
if (this.pendingVersionId !== null) {
|
|
93
|
+
if (this.propagationPolls > 0) {
|
|
94
|
+
this.propagationPolls -= 1;
|
|
95
|
+
} else {
|
|
96
|
+
this.deployment = { versions: [{ version_id: this.pendingVersionId, percentage: 100 }] };
|
|
97
|
+
this.pendingVersionId = null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return this.deployment;
|
|
101
|
+
}
|
|
102
|
+
|
|
67
103
|
async run(command: string, args: string[], options: ProcessRunOptions): Promise<ProcessResult> {
|
|
68
104
|
this.calls.push({ command, args, cwd: options.cwd, env: childEnvironment(options) });
|
|
69
105
|
if (args[0] === 'versions' && args[1] === 'list') {
|
|
70
106
|
return success(this.versions);
|
|
71
107
|
}
|
|
72
108
|
if (args[0] === 'deployments' && args[1] === 'status') {
|
|
73
|
-
return success(this.
|
|
109
|
+
return success(this.readDeployment());
|
|
74
110
|
}
|
|
75
111
|
const configIndex = args.indexOf('--config');
|
|
76
112
|
if (configIndex >= 0) {
|
|
@@ -84,6 +120,21 @@ class FakeRunner implements ProcessRunner {
|
|
|
84
120
|
this.secretsMode = statSync(secretsPath).mode & 0o777;
|
|
85
121
|
this.secretsJson = readFileSync(secretsPath, 'utf8');
|
|
86
122
|
}
|
|
123
|
+
if (args[0] === 'versions' && args[1] === 'deploy') {
|
|
124
|
+
// Resolved with the same reader the deploy uses, so the fake cannot agree with a lookup
|
|
125
|
+
// production would miss.
|
|
126
|
+
const tag = args[args.indexOf('--version-tag') + 1];
|
|
127
|
+
const existing = tag ? findVersionIdByTag(this.versions, tag) : null;
|
|
128
|
+
if (existing) this.shiftTrafficTo(existing);
|
|
129
|
+
}
|
|
130
|
+
if (args[0] === 'deploy') {
|
|
131
|
+
const tagIndex = args.indexOf('--tag');
|
|
132
|
+
if (tagIndex >= 0) {
|
|
133
|
+
const versionId = `uploaded-${args[tagIndex + 1]}`;
|
|
134
|
+
this.versionList().push({ id: versionId, annotations: { 'workers/tag': args[tagIndex + 1] } });
|
|
135
|
+
this.shiftTrafficTo(versionId);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
87
138
|
await this.onCall?.(args, options.cwd);
|
|
88
139
|
return success({});
|
|
89
140
|
}
|
|
@@ -179,8 +230,8 @@ class FakeCloudflare implements CloudflareClient {
|
|
|
179
230
|
}
|
|
180
231
|
}
|
|
181
232
|
|
|
182
|
-
describe('deploy-stage
|
|
183
|
-
it('
|
|
233
|
+
describe('deploy-stage against live state', () => {
|
|
234
|
+
it('uploads nothing and shifts no traffic when the live version already is the task hash', async () => {
|
|
184
235
|
const root = await fixtureRoot();
|
|
185
236
|
const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
|
|
186
237
|
versions: [{ version_id: 'version-1', percentage: 100 }],
|
|
@@ -189,14 +240,17 @@ describe('deploy-stage remote version fallback', () => {
|
|
|
189
240
|
const result = await deployStage(root, { stage: 'pr123' }, dependencies(runner, new FakeCloudflare()));
|
|
190
241
|
|
|
191
242
|
expect(result.action).toBe('remote-cache-hit');
|
|
243
|
+
// Two reads and nothing else. This is what makes a redundant deploy — the one a cross-project
|
|
244
|
+
// `dependsOn` edge adds — provably free instead of assumed free.
|
|
192
245
|
expect(runner.calls.map((call) => call.args.slice(0, 2))).toEqual([
|
|
193
246
|
['versions', 'list'],
|
|
194
247
|
['deployments', 'status'],
|
|
195
248
|
]);
|
|
196
249
|
});
|
|
197
250
|
|
|
198
|
-
it('
|
|
251
|
+
it('redeploys after a rollback, when the live version is no longer the task hash', async () => {
|
|
199
252
|
const root = await fixtureRoot();
|
|
253
|
+
// The build for this hash is still uploaded; someone rolled traffic back to the older version.
|
|
200
254
|
const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
|
|
201
255
|
versions: [{ version_id: 'version-2', percentage: 100 }],
|
|
202
256
|
});
|
|
@@ -204,11 +258,108 @@ describe('deploy-stage remote version fallback', () => {
|
|
|
204
258
|
const result = await deployStage(root, { stage: 'pr123' }, dependencies(runner, new FakeCloudflare()));
|
|
205
259
|
|
|
206
260
|
expect(result.action).toBe('activated');
|
|
207
|
-
|
|
208
|
-
expect(runner.
|
|
261
|
+
const shiftIndex = runner.calls.findIndex((call) => call.args[0] === 'versions' && call.args[1] === 'deploy');
|
|
262
|
+
expect(runner.calls[shiftIndex]?.args.slice(0, 4)).toEqual(['versions', 'deploy', '--version-tag', `nx-${HASH}`]);
|
|
263
|
+
// And the step does not return until it has read live state again after that shift.
|
|
264
|
+
expect(runner.calls.slice(shiftIndex + 1).some((call) => call.args[0] === 'deployments')).toBe(true);
|
|
209
265
|
expect(existsSync(requiredTestValue(runner.configPathSeen, 'config path'))).toBe(false);
|
|
210
266
|
});
|
|
211
267
|
|
|
268
|
+
it('waits out propagation before reporting the deploy done', async () => {
|
|
269
|
+
const root = await fixtureRoot();
|
|
270
|
+
const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
|
|
271
|
+
versions: [{ version_id: 'version-2', percentage: 100 }],
|
|
272
|
+
});
|
|
273
|
+
runner.propagationPolls = 3;
|
|
274
|
+
|
|
275
|
+
const result = await deployStage(root, { stage: 'pr123' }, dependencies(runner, new FakeCloudflare()));
|
|
276
|
+
|
|
277
|
+
expect(result.action).toBe('activated');
|
|
278
|
+
const statusReads = runner.calls.filter((call) => call.args[0] === 'deployments').length;
|
|
279
|
+
expect(statusReads).toBe(5);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('fails with what it expected and what it saw when the version never becomes live', async () => {
|
|
283
|
+
const root = await fixtureRoot();
|
|
284
|
+
const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
|
|
285
|
+
versions: [{ version_id: 'version-2', percentage: 100 }],
|
|
286
|
+
});
|
|
287
|
+
runner.propagationPolls = Number.POSITIVE_INFINITY;
|
|
288
|
+
|
|
289
|
+
await expect(deployStage(root, { stage: 'pr123' }, dependencies(runner, new FakeCloudflare()))).rejects.toThrow(
|
|
290
|
+
`fixture-worker-pr123: deployed version nx-${HASH} was not serving traffic after 10s; live is an untagged version (version-2)`,
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('leaves the cache holding the tag it made live, so the next query needs no API call', async () => {
|
|
295
|
+
const root = await fixtureRoot();
|
|
296
|
+
const cacheDirectory = await mkdtemp(join(tmpdir(), 'smoo-live-version-'));
|
|
297
|
+
roots.push(cacheDirectory);
|
|
298
|
+
const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
|
|
299
|
+
versions: [{ version_id: 'version-2', percentage: 100 }],
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
await deployStage(
|
|
303
|
+
root,
|
|
304
|
+
{ stage: 'pr123' },
|
|
305
|
+
{ ...dependencies(runner, new FakeCloudflare()), liveVersionCacheDirectory: cacheDirectory },
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const cached = await readCachedLiveVersion(
|
|
309
|
+
cacheDirectory,
|
|
310
|
+
{ accountId: 'account-1', workerName: 'fixture-worker-pr123', stage: 'pr123' },
|
|
311
|
+
LIVE_VERSION_CACHE_TTL_MS,
|
|
312
|
+
);
|
|
313
|
+
expect(cached).toMatchObject({ versionTag: `nx-${HASH}`, versionId: 'version-1' });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('is not done until a declared version endpoint answers with the new tag', async () => {
|
|
317
|
+
const root = await fixtureRoot();
|
|
318
|
+
const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
|
|
319
|
+
versions: [{ version_id: 'version-2', percentage: 100 }],
|
|
320
|
+
});
|
|
321
|
+
const bodies = [`nx-${'0'.repeat(20)}`, `nx-${'0'.repeat(20)}`, `nx-${HASH}`];
|
|
322
|
+
const requested: string[] = [];
|
|
323
|
+
const stubFetch: FetchLike = async (input) => {
|
|
324
|
+
requested.push(String(input));
|
|
325
|
+
return new Response(bodies.shift() ?? `nx-${HASH}`);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const result = await deployStage(
|
|
329
|
+
root,
|
|
330
|
+
{ stage: 'pr123', versionEndpoint: 'https://fixture.example.test/__version' },
|
|
331
|
+
{
|
|
332
|
+
...dependencies(runner, new FakeCloudflare()),
|
|
333
|
+
wait: { ...fakeWait(), fetch: stubFetch },
|
|
334
|
+
},
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
expect(result.action).toBe('activated');
|
|
338
|
+
expect(requested).toEqual([
|
|
339
|
+
'https://fixture.example.test/__version',
|
|
340
|
+
'https://fixture.example.test/__version',
|
|
341
|
+
'https://fixture.example.test/__version',
|
|
342
|
+
]);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('fails naming the endpoint answer when the edge never reports the new tag', async () => {
|
|
346
|
+
const root = await fixtureRoot();
|
|
347
|
+
const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
|
|
348
|
+
versions: [{ version_id: 'version-2', percentage: 100 }],
|
|
349
|
+
});
|
|
350
|
+
const stubFetch: FetchLike = async () => new Response('nx-previous');
|
|
351
|
+
|
|
352
|
+
await expect(
|
|
353
|
+
deployStage(
|
|
354
|
+
root,
|
|
355
|
+
{ stage: 'pr123', versionEndpoint: 'https://fixture.example.test/__version' },
|
|
356
|
+
{ ...dependencies(runner, new FakeCloudflare()), wait: { ...fakeWait(), fetch: stubFetch } },
|
|
357
|
+
),
|
|
358
|
+
).rejects.toThrow(
|
|
359
|
+
`fixture-worker-pr123: https://fixture.example.test/__version did not report version nx-${HASH} after 10s; it reported nx-previous`,
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
|
|
212
363
|
it('uploads a missing tag with a temporary config and secure secrets file, then removes both', async () => {
|
|
213
364
|
const root = await fixtureRoot();
|
|
214
365
|
await writeFile(join(root, '.dev.vars.example'), 'FIXTURE_SECRET=""\nFIXTURE_TOKEN=""\n');
|
|
@@ -231,9 +382,12 @@ describe('deploy-stage remote version fallback', () => {
|
|
|
231
382
|
);
|
|
232
383
|
|
|
233
384
|
expect(result.action).toBe('deployed');
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
385
|
+
const upload = requiredTestValue(
|
|
386
|
+
runner.calls.find((call) => call.args[0] === 'deploy'),
|
|
387
|
+
'upload call',
|
|
388
|
+
);
|
|
389
|
+
expect(upload.args).toContain('--tag');
|
|
390
|
+
expect(upload.args).toContain(`nx-${HASH}`);
|
|
237
391
|
expect(runner.secretsMode).toBe(0o600);
|
|
238
392
|
expect(JSON.parse(requiredTestValue(runner.secretsJson, 'secrets JSON'))).toEqual({
|
|
239
393
|
FIXTURE_SECRET: 'shared-secret',
|
|
@@ -615,6 +769,10 @@ describe('deployStage with a flat JSON config', () => {
|
|
|
615
769
|
['deployments', 'status'],
|
|
616
770
|
['d1', 'migrations'],
|
|
617
771
|
['versions', 'deploy'],
|
|
772
|
+
// The traffic shift is not the end of the deploy: the step re-reads live state and only
|
|
773
|
+
// returns once the tag it activated is the one being served.
|
|
774
|
+
['deployments', 'status'],
|
|
775
|
+
['versions', 'list'],
|
|
618
776
|
]);
|
|
619
777
|
});
|
|
620
778
|
|
|
@@ -736,10 +894,30 @@ async function fixtureRoot(toml = FIXTURE): Promise<string> {
|
|
|
736
894
|
return root;
|
|
737
895
|
}
|
|
738
896
|
|
|
897
|
+
/**
|
|
898
|
+
* A wait whose clock only advances when the code under test sleeps: real bounds, no real seconds,
|
|
899
|
+
* and a deterministic poll count a test can assert on.
|
|
900
|
+
*/
|
|
901
|
+
function fakeWait(): { budgetMs: number; intervalMs: number; now: () => number; sleep: (ms: number) => Promise<void> } {
|
|
902
|
+
let clock = 0;
|
|
903
|
+
return {
|
|
904
|
+
budgetMs: 10_000,
|
|
905
|
+
intervalMs: 1_000,
|
|
906
|
+
now: () => clock,
|
|
907
|
+
sleep: async (ms: number) => {
|
|
908
|
+
clock += ms;
|
|
909
|
+
},
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
739
913
|
function dependencies(runner: ProcessRunner, cloudflare: CloudflareClient) {
|
|
914
|
+
const cacheDirectory = mkdtempSync(join(tmpdir(), 'smoo-live-version-'));
|
|
915
|
+
roots.push(cacheDirectory);
|
|
740
916
|
return {
|
|
741
917
|
runner,
|
|
742
918
|
cloudflare,
|
|
919
|
+
wait: fakeWait(),
|
|
920
|
+
liveVersionCacheDirectory: cacheDirectory,
|
|
743
921
|
processEnv: {
|
|
744
922
|
CLOUDFLARE_ACCOUNT_ID: 'account-1',
|
|
745
923
|
CLOUDFLARE_API_TOKEN: 'token',
|
|
@@ -2,11 +2,20 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
|
-
import typia from 'typia';
|
|
6
5
|
import { parseJsonFileText } from '../lib/json.js';
|
|
7
6
|
import { mergeEnv, printCommandOutput } from '../lib/run.js';
|
|
8
7
|
import { type CloudflareClient, CloudflareRestClient, type D1DatabaseRecord } from './cloudflare.js';
|
|
9
8
|
import { type FlatWranglerConfig, parseFlatWranglerConfig, planFlatStageResources } from './flat-config.js';
|
|
9
|
+
import {
|
|
10
|
+
awaitLiveVersion,
|
|
11
|
+
awaitVersionEndpoint,
|
|
12
|
+
currentDeploymentVersionId,
|
|
13
|
+
findVersionIdByTag,
|
|
14
|
+
type LiveVersionProbe,
|
|
15
|
+
liveVersionCacheDirectory,
|
|
16
|
+
type VersionEndpointWaitOptions,
|
|
17
|
+
writeCachedLiveVersion,
|
|
18
|
+
} from './live-version.js';
|
|
10
19
|
import { parseDevVarsExample } from './prepare-env.js';
|
|
11
20
|
import {
|
|
12
21
|
type ConfiguredStageResourcePlan,
|
|
@@ -69,6 +78,10 @@ export interface WranglerCommandDependencies {
|
|
|
69
78
|
runner?: ProcessRunner;
|
|
70
79
|
cloudflare?: CloudflareClient;
|
|
71
80
|
processEnv?: NodeJS.ProcessEnv;
|
|
81
|
+
/** Bounds and clock for the post-deploy wait; tests drive it without sleeping. */
|
|
82
|
+
wait?: VersionEndpointWaitOptions;
|
|
83
|
+
/** Where a successful deploy records the tag it made live, for `smoo wrangler deployed-version`. */
|
|
84
|
+
liveVersionCacheDirectory?: string;
|
|
72
85
|
}
|
|
73
86
|
|
|
74
87
|
export interface DeployStageResult {
|
|
@@ -78,13 +91,17 @@ export interface DeployStageResult {
|
|
|
78
91
|
versionTag?: string;
|
|
79
92
|
}
|
|
80
93
|
|
|
81
|
-
const isUnknownRecord = typia.createIs<Record<string, unknown>>();
|
|
82
|
-
|
|
83
94
|
export interface DeployStageOptions {
|
|
84
95
|
/** `staging`, `production`, or `prN`. */
|
|
85
96
|
stage: string;
|
|
86
97
|
/** A build-generated flat wrangler.json to deploy instead of `./wrangler.toml` (see `prepareFlatConfig`). */
|
|
87
98
|
config?: string;
|
|
99
|
+
/**
|
|
100
|
+
* A URL, served by this worker, whose trimmed body is the running version tag. When given, the
|
|
101
|
+
* deploy is not done until that URL answers with the tag it just made live — the control plane
|
|
102
|
+
* accepting a traffic shift is not the edge serving it.
|
|
103
|
+
*/
|
|
104
|
+
versionEndpoint?: string;
|
|
88
105
|
}
|
|
89
106
|
|
|
90
107
|
export async function deployStage(
|
|
@@ -124,16 +141,24 @@ export async function deployStage(
|
|
|
124
141
|
// `reconcileStageResources` nor `versions list` looks at.
|
|
125
142
|
const run: ProcessRunOptions = prepared.envFlag ? { cwd } : { cwd, unsetEnv: ['CLOUDFLARE_ENV'] };
|
|
126
143
|
const workerName = prepared.plan.workerName;
|
|
144
|
+
const probe: LiveVersionProbe = {
|
|
145
|
+
deployments: () => wranglerJson(runner, ['deployments', 'status', '--name', workerName, '--json'], run),
|
|
146
|
+
versions: () => wranglerJson(runner, ['versions', 'list', '--name', workerName, '--json'], run),
|
|
147
|
+
};
|
|
127
148
|
|
|
128
149
|
// The tagged-version lookup runs before the migrations: a cache hit means this exact build is
|
|
129
150
|
// already live, so its migrations ran with it and re-applying them would touch the remote
|
|
130
151
|
// database for nothing. The activation and upload paths below still migrate first.
|
|
152
|
+
//
|
|
153
|
+
// This reads Cloudflare every time and never the local cache. A cache hit here would mean
|
|
154
|
+
// "we believe this hash is live" — exactly the belief a rollback falsifies — and believing it
|
|
155
|
+
// would skip the deploy that repairs the rollback. Reading live state is what makes a
|
|
156
|
+
// redundant deploy a proven no-op instead of an assumed one, which is in turn what makes a
|
|
157
|
+
// cross-project `dependsOn: ["<other>:deploy"]` edge cheap enough to be the ordering mechanism.
|
|
131
158
|
let taggedVersionId: string | null = null;
|
|
132
159
|
if (versionTag && workerExists) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
taggedVersionId = findVersionIdByTag(versions, versionTag);
|
|
136
|
-
if (taggedVersionId && isFullCurrentDeployment(deployments, taggedVersionId)) {
|
|
160
|
+
taggedVersionId = findVersionIdByTag(await probe.versions(), versionTag);
|
|
161
|
+
if (taggedVersionId && currentDeploymentVersionId(await probe.deployments()) === taggedVersionId) {
|
|
137
162
|
return { stage, workerName, action: 'remote-cache-hit', versionTag };
|
|
138
163
|
}
|
|
139
164
|
}
|
|
@@ -163,6 +188,7 @@ export async function deployStage(
|
|
|
163
188
|
],
|
|
164
189
|
run,
|
|
165
190
|
);
|
|
191
|
+
await confirmVersionIsLive(probe, versionTag, { stage, workerName, accountId }, options, dependencies, cwd);
|
|
166
192
|
return { stage, workerName, action: 'activated', versionTag };
|
|
167
193
|
}
|
|
168
194
|
|
|
@@ -174,6 +200,9 @@ export async function deployStage(
|
|
|
174
200
|
deployArgs.push('--secrets-file', temporarySecretsPath);
|
|
175
201
|
}
|
|
176
202
|
await wrangler(runner, deployArgs, run);
|
|
203
|
+
if (versionTag) {
|
|
204
|
+
await confirmVersionIsLive(probe, versionTag, { stage, workerName, accountId }, options, dependencies, cwd);
|
|
205
|
+
}
|
|
177
206
|
return { stage, workerName, action: 'deployed', ...(versionTag ? { versionTag } : {}) };
|
|
178
207
|
} finally {
|
|
179
208
|
if (temporaryConfigPath) {
|
|
@@ -578,49 +607,47 @@ export function nxTaskVersionTag(environment: NodeJS.ProcessEnv): string | undef
|
|
|
578
607
|
throw new Error('NX_TASK_HASH must be canonical decimal digits or at least 32 hexadecimal characters.');
|
|
579
608
|
}
|
|
580
609
|
|
|
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;
|
|
610
|
+
/** What a freshly activated version has to be true of, and where the fact gets recorded. */
|
|
611
|
+
interface DeployedVersionIdentity {
|
|
612
|
+
stage: DeploymentStage;
|
|
613
|
+
workerName: string;
|
|
614
|
+
accountId: string;
|
|
604
615
|
}
|
|
605
616
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
617
|
+
/**
|
|
618
|
+
* Turns "Cloudflare accepted the traffic shift" into "the new version answers".
|
|
619
|
+
*
|
|
620
|
+
* Without this the deploy step goes green while the edge still serves the previous version — the
|
|
621
|
+
* measured gap was about 20 s — so anything ordered after this deploy could call into code that
|
|
622
|
+
* has not shipped yet. The wait is what a `dependsOn: ["<other>:deploy"]` edge actually buys; an
|
|
623
|
+
* edge onto a step that returns early orders nothing.
|
|
624
|
+
*/
|
|
625
|
+
async function confirmVersionIsLive(
|
|
626
|
+
probe: LiveVersionProbe,
|
|
627
|
+
versionTag: string,
|
|
628
|
+
identity: DeployedVersionIdentity,
|
|
629
|
+
options: DeployStageOptions,
|
|
630
|
+
dependencies: WranglerCommandDependencies,
|
|
631
|
+
cwd: string,
|
|
632
|
+
): Promise<void> {
|
|
633
|
+
const wait = dependencies.wait ?? {};
|
|
634
|
+
const live = await awaitLiveVersion(probe, versionTag, wait);
|
|
635
|
+
if (!live.ok) throw new Error(`${identity.workerName}: ${live.error.message}`);
|
|
636
|
+
if (options.versionEndpoint) {
|
|
637
|
+
const answered = await awaitVersionEndpoint(options.versionEndpoint, versionTag, wait);
|
|
638
|
+
if (!answered.ok) throw new Error(`${identity.workerName}: ${answered.error.message}`);
|
|
619
639
|
}
|
|
620
|
-
|
|
640
|
+
const processEnv = dependencies.processEnv ?? process.env;
|
|
641
|
+
const directory = dependencies.liveVersionCacheDirectory ?? liveVersionCacheDirectory(cwd, processEnv);
|
|
642
|
+
await writeCachedLiveVersion(
|
|
643
|
+
directory,
|
|
644
|
+
{ accountId: identity.accountId, workerName: identity.workerName, stage: identity.stage },
|
|
645
|
+
{ versionTag, versionId: live.value.versionId, fetchedAt: Date.now() },
|
|
646
|
+
);
|
|
621
647
|
}
|
|
622
648
|
|
|
623
|
-
|
|
649
|
+
/** One wrangler invocation whose stdout must be JSON; shared by the deploy and the read-only query. */
|
|
650
|
+
export async function wranglerJson(runner: ProcessRunner, args: string[], run: ProcessRunOptions): Promise<unknown> {
|
|
624
651
|
const result = await wrangler(runner, args, run);
|
|
625
652
|
try {
|
|
626
653
|
return JSON.parse(result.stdout);
|
|
@@ -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
|
+
});
|