@smoothbricks/cli 0.11.16 → 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/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/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/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/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/secret-names.ts +70 -0
- package/src/monorepo/__tests__/ci-workflow.test.ts +27 -2
- package/src/monorepo/__tests__/publish-workflow.test.ts +33 -15
- package/src/monorepo/ci-workflow.ts +17 -6
- package/src/monorepo/managed-files.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__/private-npm-status.test.ts +2 -2
- 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
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconciles the three places a repository's secrets are described, all of
|
|
3
|
+
* which smoo already owns:
|
|
4
|
+
*
|
|
5
|
+
* 1. **Workers declare names.** `.dev.vars.example` per wrangler project — the
|
|
6
|
+
* file `wrangler types --env-file` reads and `prepare-env` prompts from.
|
|
7
|
+
* 2. **Workflows declare where values come from.** `smoo.github.deploySecrets`
|
|
8
|
+
* and `e2eSecrets` map an env name to a repository secret, and the managed
|
|
9
|
+
* workflows render exactly those into the job environment.
|
|
10
|
+
* 3. **A developer shell declares how to fetch one locally.** `smoo.secrets`
|
|
11
|
+
* names a command whose stdout is the value.
|
|
12
|
+
*
|
|
13
|
+
* Nothing joined them, so a Worker could declare a secret no workflow supplies
|
|
14
|
+
* and no repository holds. That fails at deploy time, on a stage, with a
|
|
15
|
+
* fail-closed message about the value rather than about the missing
|
|
16
|
+
* declaration — the shape that costs an afternoon: a Worker declares
|
|
17
|
+
* `STRIPE_PUBLISHABLE_KEY`, `ci.yml` passes `secrets.STRIPE_PUBLISHABLE_KEY`,
|
|
18
|
+
* the repository holds no such secret, and the empty value surfaces as the
|
|
19
|
+
* payment library's own `publishable_key_mismatch` refusal.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawnSync } from 'node:child_process';
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { repositoryOwnerFromUrl, repositorySecretMapping } from '../lib/secret-names.js';
|
|
26
|
+
import { readPackageJsonObject, repositoryInfo } from '../lib/workspace.js';
|
|
27
|
+
import { parseDevVarsExample } from '../wrangler/prepare-env.js';
|
|
28
|
+
|
|
29
|
+
/** Where an env name is described, which repository secret carries it, and whether that exists. */
|
|
30
|
+
export interface SecretRow {
|
|
31
|
+
name: string;
|
|
32
|
+
/** The repository secret this env name reads from, by convention or declaration. */
|
|
33
|
+
repositorySecret: string;
|
|
34
|
+
/** Wrangler projects whose `.dev.vars.example` declares it. */
|
|
35
|
+
declaredByWorkers: string[];
|
|
36
|
+
/** True when a managed workflow renders `secrets.<name>` into a job. */
|
|
37
|
+
suppliedByWorkflow: boolean;
|
|
38
|
+
/** True when `smoo.secrets` can fetch it for a developer shell. */
|
|
39
|
+
fetchableLocally: boolean;
|
|
40
|
+
/** True when the repository holds a secret of this name. */
|
|
41
|
+
onRepository: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SecretSources {
|
|
45
|
+
/** Worker label -> env names it declares. */
|
|
46
|
+
workerSecrets: Record<string, readonly string[]>;
|
|
47
|
+
/** Env names a managed workflow passes into a job. */
|
|
48
|
+
workflowSecrets: readonly string[];
|
|
49
|
+
/** Env name -> repository secret, by convention with declared exceptions. */
|
|
50
|
+
secretNames: Readonly<Record<string, string>>;
|
|
51
|
+
/** Env names `smoo.secrets` can fetch locally. */
|
|
52
|
+
localCommands: readonly string[];
|
|
53
|
+
/** Repository secret names GitHub currently holds. */
|
|
54
|
+
repositorySecrets: readonly string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One row per name known to any source, sorted, so a reader sees the whole
|
|
59
|
+
* picture rather than one source's view of it.
|
|
60
|
+
*/
|
|
61
|
+
export function reconcileSecrets(sources: SecretSources): SecretRow[] {
|
|
62
|
+
const declaredByAnyWorker: string[] = Object.values(sources.workerSecrets).flatMap((names) => [...names]);
|
|
63
|
+
const names = new Set<string>([...declaredByAnyWorker, ...sources.workflowSecrets, ...sources.localCommands]);
|
|
64
|
+
// A repository secret that already carries a known env name is that name's
|
|
65
|
+
// row, not a row of its own: listing ACME_GITHUB_CLIENT_SECRET beside
|
|
66
|
+
// GITHUB_CLIENT_SECRET would report one value as two secrets, one of them
|
|
67
|
+
// permanently "declared by nothing".
|
|
68
|
+
const carriesKnownEnvName = new Set(
|
|
69
|
+
names.size > 0 ? [...names].map((name) => sources.secretNames[name] ?? name) : [],
|
|
70
|
+
);
|
|
71
|
+
for (const secret of sources.repositorySecrets) {
|
|
72
|
+
if (!carriesKnownEnvName.has(secret)) names.add(secret);
|
|
73
|
+
}
|
|
74
|
+
return [...names]
|
|
75
|
+
.sort((left, right) => left.localeCompare(right))
|
|
76
|
+
.map((name) => ({
|
|
77
|
+
name,
|
|
78
|
+
repositorySecret: sources.secretNames[name] ?? name,
|
|
79
|
+
declaredByWorkers: Object.entries(sources.workerSecrets)
|
|
80
|
+
.filter(([, declared]) => declared.includes(name))
|
|
81
|
+
.map(([label]) => label)
|
|
82
|
+
.sort((left, right) => left.localeCompare(right)),
|
|
83
|
+
suppliedByWorkflow: sources.workflowSecrets.includes(name),
|
|
84
|
+
fetchableLocally: sources.localCommands.includes(name),
|
|
85
|
+
onRepository: sources.repositorySecrets.includes(sources.secretNames[name] ?? name),
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The rows a CI deploy cannot satisfy: a Worker declares the name, a workflow
|
|
91
|
+
* promises to pass it, and the repository has no value to pass. Reported
|
|
92
|
+
* separately from "declared but not wired into any workflow", because the
|
|
93
|
+
* remedies differ — set a secret, versus declare it in `smoo.github`.
|
|
94
|
+
*/
|
|
95
|
+
export function unsatisfiedSecrets(rows: readonly SecretRow[]): SecretRow[] {
|
|
96
|
+
return rows.filter((row) => row.suppliedByWorkflow && !row.onRepository);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Worker-declared names no managed workflow passes: a CI deploy will run without them. */
|
|
100
|
+
export function unwiredSecrets(rows: readonly SecretRow[]): SecretRow[] {
|
|
101
|
+
return rows.filter((row) => row.declaredByWorkers.length > 0 && !row.suppliedByWorkflow);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Env names the managed workflows pass into a job, from the declarations that render them. */
|
|
105
|
+
export function workflowSecretNames(root: string): string[] {
|
|
106
|
+
const manifest = readPackageJsonObject(join(root, 'package.json'));
|
|
107
|
+
const smoo = manifest?.smoo;
|
|
108
|
+
const github = smoo?.github;
|
|
109
|
+
const names = new Set<string>([
|
|
110
|
+
...Object.keys(github?.deploySecrets ?? {}),
|
|
111
|
+
...Object.keys(github?.e2eSecrets ?? {}),
|
|
112
|
+
]);
|
|
113
|
+
if (smoo?.remoteCache?.tokenSecret) names.add(smoo.remoteCache.tokenSecret);
|
|
114
|
+
for (const origin of github?.cargoCredentials?.gitOrigins ?? []) {
|
|
115
|
+
if (origin.tokenEnv) names.add(origin.tokenEnv);
|
|
116
|
+
}
|
|
117
|
+
for (const source of github?.sourceCheckouts ?? []) {
|
|
118
|
+
if (source.tokenEnv) names.add(source.tokenEnv);
|
|
119
|
+
}
|
|
120
|
+
if (smoo?.privateNpm?.readTokenEnv) names.add(smoo.privateNpm.readTokenEnv);
|
|
121
|
+
if (smoo?.privateNpm?.publishTokenEnv) names.add(smoo.privateNpm.publishTokenEnv);
|
|
122
|
+
return [...names].sort((left, right) => left.localeCompare(right));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Env name -> repository secret for every name in play, following the naming
|
|
127
|
+
* convention and honouring the declared exceptions a repository still needs.
|
|
128
|
+
*/
|
|
129
|
+
export function secretNameMapping(root: string, envNames: readonly string[]): Record<string, string> {
|
|
130
|
+
const manifest = readPackageJsonObject(join(root, 'package.json'));
|
|
131
|
+
const declared = { ...(manifest?.smoo?.github?.deploySecrets ?? {}), ...(manifest?.smoo?.github?.e2eSecrets ?? {}) };
|
|
132
|
+
const repository = manifest ? repositoryInfo(manifest) : null;
|
|
133
|
+
const owner = repository ? (repositoryOwnerFromUrl(repository.url) ?? '') : '';
|
|
134
|
+
return repositorySecretMapping(envNames, owner, declared);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Every wrangler project's declared secret names, keyed by the project directory. */
|
|
138
|
+
export function workerSecretNames(root: string, workspaceDirs: readonly string[]): Record<string, string[]> {
|
|
139
|
+
const byWorker: Record<string, string[]> = {};
|
|
140
|
+
for (const dir of workspaceDirs) {
|
|
141
|
+
const examplePath = join(root, dir, '.dev.vars.example');
|
|
142
|
+
if (!existsSync(examplePath)) continue;
|
|
143
|
+
const names = parseDevVarsExample(readFileSync(examplePath, 'utf8'));
|
|
144
|
+
if (names.length > 0) byWorker[dir] = names;
|
|
145
|
+
}
|
|
146
|
+
return byWorker;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Names `smoo.secrets` declares a fetch command for. */
|
|
150
|
+
export function localSecretCommandNames(root: string): string[] {
|
|
151
|
+
const manifest = readPackageJsonObject(join(root, 'package.json'));
|
|
152
|
+
return Object.keys(manifest?.smoo?.secrets ?? {}).sort((left, right) => left.localeCompare(right));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Resolve one declared secret through its command. The value is returned, never
|
|
157
|
+
* logged: callers hand it to `gh secret set` over stdin.
|
|
158
|
+
*/
|
|
159
|
+
export function fetchLocalSecret(
|
|
160
|
+
root: string,
|
|
161
|
+
name: string,
|
|
162
|
+
): { ok: true; value: string } | { ok: false; reason: string } {
|
|
163
|
+
const manifest = readPackageJsonObject(join(root, 'package.json'));
|
|
164
|
+
const declared = manifest?.smoo?.secrets?.[name];
|
|
165
|
+
if (!declared) {
|
|
166
|
+
return { ok: false, reason: `smoo.secrets declares no command for ${name}` };
|
|
167
|
+
}
|
|
168
|
+
const [command, ...args] = declared.command;
|
|
169
|
+
const result = spawnSync(command, args, { encoding: 'utf8' });
|
|
170
|
+
if (result.status !== 0) {
|
|
171
|
+
// Name the command, never its output: a failing secret fetch prints
|
|
172
|
+
// credentials often enough that it is worth refusing to.
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
reason: `${name}: \`${declared.command.join(' ')}\` exited ${result.status ?? 'without status'}`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const value = result.stdout.replace(/\n$/, '');
|
|
179
|
+
if (value.length === 0) {
|
|
180
|
+
return { ok: false, reason: `${name}: \`${declared.command.join(' ')}\` produced no value` };
|
|
181
|
+
}
|
|
182
|
+
return { ok: true, value };
|
|
183
|
+
}
|
|
@@ -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);
|