@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,150 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import type { ProcessResult, ProcessRunner, ProcessRunOptions } from './deploy-stage.js';
|
|
7
|
+
import { deployedVersion } from './deployed-version.js';
|
|
8
|
+
import { LIVE_VERSION_CACHE_TTL_MS, liveVersionCachePath, writeCachedLiveVersion } from './live-version.js';
|
|
9
|
+
|
|
10
|
+
const HASH = '16577780061662788004';
|
|
11
|
+
const FIXTURE = `[env.staging]
|
|
12
|
+
name = "fixture-worker-staging"
|
|
13
|
+
workers_dev = false
|
|
14
|
+
|
|
15
|
+
[env.staging.vars]
|
|
16
|
+
ENVIRONMENT = "staging"
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
const roots: string[] = [];
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
class RecordingRunner implements ProcessRunner {
|
|
26
|
+
readonly calls: string[][] = [];
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
private readonly versions: unknown = [{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }],
|
|
30
|
+
private readonly deployment: unknown = { versions: [{ version_id: 'version-1', percentage: 100 }] },
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
async run(_command: string, args: string[], _options: ProcessRunOptions): Promise<ProcessResult> {
|
|
34
|
+
this.calls.push(args);
|
|
35
|
+
const value = args[0] === 'versions' && args[1] === 'list' ? this.versions : this.deployment;
|
|
36
|
+
return { exitCode: 0, stdout: JSON.stringify(value), stderr: '' };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function fixture(): Promise<{ root: string; cacheDirectory: string }> {
|
|
41
|
+
const root = await mkdtemp(join(tmpdir(), 'smoo-deployed-version-'));
|
|
42
|
+
roots.push(root);
|
|
43
|
+
await writeFile(join(root, 'wrangler.toml'), FIXTURE);
|
|
44
|
+
return { root, cacheDirectory: join(root, 'cache') };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const credentials = { CLOUDFLARE_ACCOUNT_ID: 'account-1', CLOUDFLARE_API_TOKEN: 'token' };
|
|
48
|
+
|
|
49
|
+
describe('smoo wrangler deployed-version', () => {
|
|
50
|
+
it('reports the tag serving all traffic and remembers it', async () => {
|
|
51
|
+
const { root, cacheDirectory } = await fixture();
|
|
52
|
+
const runner = new RecordingRunner();
|
|
53
|
+
|
|
54
|
+
const report = await deployedVersion(
|
|
55
|
+
root,
|
|
56
|
+
{ stage: 'staging' },
|
|
57
|
+
{ runner, processEnv: credentials, cacheDirectory },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(report).toMatchObject({
|
|
61
|
+
workerName: 'fixture-worker-staging',
|
|
62
|
+
versionTag: `nx-${HASH}`,
|
|
63
|
+
source: 'cloudflare',
|
|
64
|
+
});
|
|
65
|
+
expect(runner.calls.map((args) => args.slice(0, 2))).toEqual([
|
|
66
|
+
['deployments', 'status'],
|
|
67
|
+
['versions', 'list'],
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('answers a second query from the cache without calling wrangler at all', async () => {
|
|
72
|
+
const { root, cacheDirectory } = await fixture();
|
|
73
|
+
const first = new RecordingRunner();
|
|
74
|
+
await deployedVersion(root, { stage: 'staging' }, { runner: first, processEnv: credentials, cacheDirectory });
|
|
75
|
+
|
|
76
|
+
const second = new RecordingRunner();
|
|
77
|
+
const report = await deployedVersion(
|
|
78
|
+
root,
|
|
79
|
+
{ stage: 'staging' },
|
|
80
|
+
{ runner: second, processEnv: credentials, cacheDirectory },
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(report).toMatchObject({ versionTag: `nx-${HASH}`, source: 'cache' });
|
|
84
|
+
expect(second.calls).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('asks Cloudflare again once the entry is older than the TTL', async () => {
|
|
88
|
+
const { root, cacheDirectory } = await fixture();
|
|
89
|
+
await writeCachedLiveVersion(
|
|
90
|
+
cacheDirectory,
|
|
91
|
+
{ accountId: 'account-1', workerName: 'fixture-worker-staging', stage: 'staging' },
|
|
92
|
+
{ versionTag: 'nx-stale', versionId: 'version-0', fetchedAt: 0 },
|
|
93
|
+
);
|
|
94
|
+
const runner = new RecordingRunner();
|
|
95
|
+
|
|
96
|
+
const report = await deployedVersion(
|
|
97
|
+
root,
|
|
98
|
+
{ stage: 'staging' },
|
|
99
|
+
{ runner, processEnv: credentials, cacheDirectory, now: () => LIVE_VERSION_CACHE_TTL_MS + 1 },
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
expect(report).toMatchObject({ versionTag: `nx-${HASH}`, source: 'cloudflare' });
|
|
103
|
+
expect(runner.calls.length).toBe(2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('refuses without a credential and writes no placeholder for a later run to trust', async () => {
|
|
107
|
+
const { root, cacheDirectory } = await fixture();
|
|
108
|
+
|
|
109
|
+
await expect(
|
|
110
|
+
deployedVersion(
|
|
111
|
+
root,
|
|
112
|
+
{ stage: 'staging' },
|
|
113
|
+
{ runner: new RecordingRunner(), processEnv: { CLOUDFLARE_ACCOUNT_ID: 'account-1' }, cacheDirectory },
|
|
114
|
+
),
|
|
115
|
+
).rejects.toThrow('CLOUDFLARE_API_TOKEN is required.');
|
|
116
|
+
expect(
|
|
117
|
+
existsSync(
|
|
118
|
+
liveVersionCachePath(cacheDirectory, {
|
|
119
|
+
accountId: 'account-1',
|
|
120
|
+
workerName: 'fixture-worker-staging',
|
|
121
|
+
stage: 'staging',
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
124
|
+
).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('refuses to name a live version while traffic is split', async () => {
|
|
128
|
+
const { root, cacheDirectory } = await fixture();
|
|
129
|
+
const runner = new RecordingRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
|
|
130
|
+
versions: [
|
|
131
|
+
{ version_id: 'version-1', percentage: 50 },
|
|
132
|
+
{ version_id: 'version-2', percentage: 50 },
|
|
133
|
+
],
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await expect(
|
|
137
|
+
deployedVersion(root, { stage: 'staging' }, { runner, processEnv: credentials, cacheDirectory }),
|
|
138
|
+
).rejects.toThrow('fixture-worker-staging (staging): no single worker version is serving 100% of traffic');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('names the pull-request worker the deploy would target, without provisioning anything', async () => {
|
|
142
|
+
const { root, cacheDirectory } = await fixture();
|
|
143
|
+
const runner = new RecordingRunner();
|
|
144
|
+
|
|
145
|
+
const report = await deployedVersion(root, { stage: 'pr123' }, { runner, processEnv: credentials, cacheDirectory });
|
|
146
|
+
|
|
147
|
+
expect(report.workerName).toBe('fixture-worker-pr123');
|
|
148
|
+
expect(runner.calls.every((args) => args[0] === 'deployments' || args[0] === 'versions')).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// `smoo wrangler deployed-version --stage <stage>` — what is serving this project's worker right now.
|
|
2
|
+
//
|
|
3
|
+
// Read-only by construction: it resolves the worker name from the committed config and asks
|
|
4
|
+
// Cloudflare. It never provisions, never derives a pull-request stage's resources, and never
|
|
5
|
+
// writes anything except its own cache entry.
|
|
6
|
+
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { readFile } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { parseJsonFileText } from '../lib/json.js';
|
|
11
|
+
import { BunProcessRunner, type ProcessRunner, type ProcessRunOptions, wranglerJson } from './deploy-stage.js';
|
|
12
|
+
import { parseFlatWranglerConfig, planFlatStageResources } from './flat-config.js';
|
|
13
|
+
import {
|
|
14
|
+
LIVE_VERSION_CACHE_TTL_MS,
|
|
15
|
+
type LiveVersionProbe,
|
|
16
|
+
liveVersionCacheDirectory,
|
|
17
|
+
readCachedLiveVersion,
|
|
18
|
+
readLiveVersion,
|
|
19
|
+
writeCachedLiveVersion,
|
|
20
|
+
} from './live-version.js';
|
|
21
|
+
import {
|
|
22
|
+
type DeploymentStage,
|
|
23
|
+
isPullRequestStage,
|
|
24
|
+
parseDeploymentStage,
|
|
25
|
+
planConfiguredStageResources,
|
|
26
|
+
stageResourceName,
|
|
27
|
+
stagingWorkerBaseName,
|
|
28
|
+
} from './stage.js';
|
|
29
|
+
|
|
30
|
+
export interface DeployedVersionOptions {
|
|
31
|
+
/** `staging`, `production`, or `prN`. */
|
|
32
|
+
stage: string;
|
|
33
|
+
/** The build-generated flat wrangler.json the deploy would use, when the project deploys one. */
|
|
34
|
+
config?: string;
|
|
35
|
+
/** Ask Cloudflare even when a fresh cache entry exists. */
|
|
36
|
+
refresh?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DeployedVersionDependencies {
|
|
40
|
+
runner?: ProcessRunner;
|
|
41
|
+
processEnv?: NodeJS.ProcessEnv;
|
|
42
|
+
cacheDirectory?: string;
|
|
43
|
+
ttlMs?: number;
|
|
44
|
+
now?: () => number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface DeployedVersionReport {
|
|
48
|
+
workerName: string;
|
|
49
|
+
stage: DeploymentStage;
|
|
50
|
+
versionTag: string | null;
|
|
51
|
+
versionId: string;
|
|
52
|
+
source: 'cloudflare' | 'cache';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The worker `stage` deploys to, from committed configuration alone.
|
|
57
|
+
*
|
|
58
|
+
* A pull-request stage's worker name is derived from the staging name the same way the deploy
|
|
59
|
+
* derives it, but WITHOUT the account listing the deploy needs for its KV/D1/R2 isolation: a
|
|
60
|
+
* question about what is live must not be able to create anything.
|
|
61
|
+
*/
|
|
62
|
+
export async function stageWorkerName(cwd: string, stage: DeploymentStage, configPath?: string): Promise<string> {
|
|
63
|
+
if (configPath) {
|
|
64
|
+
const flat = parseJsonFileText(configPath, await readFile(configPath, 'utf8'), parseFlatWranglerConfig);
|
|
65
|
+
return isPullRequestStage(stage)
|
|
66
|
+
? stageResourceName(stagingWorkerBaseName(flat.name), stage)
|
|
67
|
+
: planFlatStageResources(flat, stage).workerName;
|
|
68
|
+
}
|
|
69
|
+
const tomlPath = join(cwd, 'wrangler.toml');
|
|
70
|
+
if (!existsSync(tomlPath)) {
|
|
71
|
+
throw new Error(`${tomlPath} does not exist; pass --config for a build-generated flat configuration.`);
|
|
72
|
+
}
|
|
73
|
+
const toml = await readFile(tomlPath, 'utf8');
|
|
74
|
+
return isPullRequestStage(stage)
|
|
75
|
+
? stageResourceName(stagingWorkerBaseName(planConfiguredStageResources(toml, 'staging').workerName), stage)
|
|
76
|
+
: planConfiguredStageResources(toml, stage).workerName;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Answers with the tag serving 100% of this worker's traffic, or refuses.
|
|
81
|
+
*
|
|
82
|
+
* Refusing is the whole contract. A missing credential, an API error, or a traffic split has no
|
|
83
|
+
* honest answer, and returning a placeholder such as `unknown` would be worse than the error: a
|
|
84
|
+
* later run comparing against that placeholder would read it as a match and conclude the desired
|
|
85
|
+
* version is already live. For the same reason nothing here is ever written to the cache except a
|
|
86
|
+
* real observation, and the answer never contains a timestamp or anything else that varies per
|
|
87
|
+
* invocation.
|
|
88
|
+
*/
|
|
89
|
+
export async function deployedVersion(
|
|
90
|
+
cwd: string,
|
|
91
|
+
options: DeployedVersionOptions,
|
|
92
|
+
dependencies: DeployedVersionDependencies = {},
|
|
93
|
+
): Promise<DeployedVersionReport> {
|
|
94
|
+
const stage = parseDeploymentStage(options.stage);
|
|
95
|
+
const processEnv = dependencies.processEnv ?? process.env;
|
|
96
|
+
const accountId = processEnv.CLOUDFLARE_ACCOUNT_ID;
|
|
97
|
+
if (!accountId) throw new Error('CLOUDFLARE_ACCOUNT_ID is required.');
|
|
98
|
+
if (!processEnv.CLOUDFLARE_API_TOKEN) throw new Error('CLOUDFLARE_API_TOKEN is required.');
|
|
99
|
+
const workerName = await stageWorkerName(cwd, stage, options.config);
|
|
100
|
+
const cacheDirectory = dependencies.cacheDirectory ?? liveVersionCacheDirectory(cwd, processEnv);
|
|
101
|
+
const key = { accountId, workerName, stage };
|
|
102
|
+
const ttlMs = dependencies.ttlMs ?? LIVE_VERSION_CACHE_TTL_MS;
|
|
103
|
+
const now = dependencies.now ?? Date.now;
|
|
104
|
+
if (options.refresh !== true) {
|
|
105
|
+
const cached = await readCachedLiveVersion(cacheDirectory, key, ttlMs, now);
|
|
106
|
+
if (cached) {
|
|
107
|
+
return { workerName, stage, versionTag: cached.versionTag, versionId: cached.versionId, source: 'cache' };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const runner = dependencies.runner ?? new BunProcessRunner();
|
|
111
|
+
const run: ProcessRunOptions = { cwd, unsetEnv: ['CLOUDFLARE_ENV'] };
|
|
112
|
+
const probe: LiveVersionProbe = {
|
|
113
|
+
deployments: () => wranglerJson(runner, ['deployments', 'status', '--name', workerName, '--json'], run),
|
|
114
|
+
versions: () => wranglerJson(runner, ['versions', 'list', '--name', workerName, '--json'], run),
|
|
115
|
+
};
|
|
116
|
+
const live = await readLiveVersion(probe);
|
|
117
|
+
if (!live.ok) throw new Error(`${workerName} (${stage}): ${live.error.message}`);
|
|
118
|
+
await writeCachedLiveVersion(cacheDirectory, key, {
|
|
119
|
+
versionTag: live.value.tag,
|
|
120
|
+
versionId: live.value.versionId,
|
|
121
|
+
fetchedAt: now(),
|
|
122
|
+
});
|
|
123
|
+
return {
|
|
124
|
+
workerName,
|
|
125
|
+
stage,
|
|
126
|
+
versionTag: live.value.tag,
|
|
127
|
+
versionId: live.value.versionId,
|
|
128
|
+
source: 'cloudflare',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
// What is live right now, as opposed to what we last uploaded.
|
|
2
|
+
//
|
|
3
|
+
// `wrangler versions deploy` returns when Cloudflare ACCEPTS the traffic shift, not when the edge
|
|
4
|
+
// serves it. A deploy that returns is therefore not yet a deploy that answers: the site that signs
|
|
5
|
+
// in to its stage's backend measured the previous backend version's response about 20 s after the
|
|
6
|
+
// backend's deploy step had already gone green. Everything here exists to turn "we asked" into
|
|
7
|
+
// "we observed".
|
|
8
|
+
|
|
9
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import typia from 'typia';
|
|
12
|
+
|
|
13
|
+
/** Same shape as the release tooling's Result: a known operational failure is a value, not a throw. */
|
|
14
|
+
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
|
|
15
|
+
|
|
16
|
+
/** The version Cloudflare is serving to 100% of traffic for one worker. */
|
|
17
|
+
export interface LiveVersion {
|
|
18
|
+
versionId: string;
|
|
19
|
+
/** `wrangler versions deploy --version-tag` matches on this; a version deployed outside Nx has none. */
|
|
20
|
+
tag: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type LiveVersionFailure =
|
|
24
|
+
| { kind: 'never-deployed'; message: string }
|
|
25
|
+
| { kind: 'split-traffic'; message: string };
|
|
26
|
+
|
|
27
|
+
/** Reads the two `wrangler` JSON payloads the live version is derived from. */
|
|
28
|
+
export interface LiveVersionProbe {
|
|
29
|
+
/** `wrangler deployments status --name <worker> --json` */
|
|
30
|
+
deployments(): Promise<unknown>;
|
|
31
|
+
/** `wrangler versions list --name <worker> --json` */
|
|
32
|
+
versions(): Promise<unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const isUnknownRecord = typia.createIs<Record<string, unknown>>();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Every `{ id -> tag }` pair the payload carries, in document order.
|
|
39
|
+
*
|
|
40
|
+
* Wrangler nests versions differently per subcommand and per version of itself, spells the tag
|
|
41
|
+
* three ways (`annotations['workers/tag']`, a bare `tag`, `metadata.tag`) and the id two (`id`,
|
|
42
|
+
* `version_id`). One walk collects the relation once; both directions of the lookup are then a Map
|
|
43
|
+
* read rather than a second recursive scan that could disagree with the first.
|
|
44
|
+
*/
|
|
45
|
+
export function collectVersionTags(value: unknown): Map<string, string> {
|
|
46
|
+
const pairs = new Map<string, string>();
|
|
47
|
+
collectInto(value, pairs);
|
|
48
|
+
return pairs;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function collectInto(value: unknown, pairs: Map<string, string>): void {
|
|
52
|
+
if (Array.isArray(value)) {
|
|
53
|
+
for (const entry of value) collectInto(entry, pairs);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!isUnknownRecord(value)) return;
|
|
57
|
+
const annotations = isUnknownRecord(value.annotations) ? value.annotations : undefined;
|
|
58
|
+
const metadata = isUnknownRecord(value.metadata) ? value.metadata : undefined;
|
|
59
|
+
const annotationTag = annotations?.['workers/tag'];
|
|
60
|
+
const candidateTag =
|
|
61
|
+
typeof annotationTag === 'string' ? annotationTag : typeof value.tag === 'string' ? value.tag : metadata?.tag;
|
|
62
|
+
const candidateId =
|
|
63
|
+
typeof value.id === 'string' ? value.id : typeof value.version_id === 'string' ? value.version_id : undefined;
|
|
64
|
+
if (typeof candidateTag === 'string' && candidateId !== undefined && !pairs.has(candidateId)) {
|
|
65
|
+
pairs.set(candidateId, candidateTag);
|
|
66
|
+
}
|
|
67
|
+
for (const nested of Object.values(value)) collectInto(nested, pairs);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The version id whose tag is `tag`, or null. */
|
|
71
|
+
export function findVersionIdByTag(value: unknown, tag: string): string | null {
|
|
72
|
+
for (const [id, candidate] of collectVersionTags(value)) {
|
|
73
|
+
if (candidate === tag) return id;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One version's share of the current deployment's traffic. */
|
|
79
|
+
export interface DeployedVersionShare {
|
|
80
|
+
versionId: string | null;
|
|
81
|
+
percentage: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The current deployment's traffic split, or null when the payload names no deployment at all.
|
|
86
|
+
*
|
|
87
|
+
* "Nothing is deployed" and "two versions share the traffic" are different facts with different
|
|
88
|
+
* remedies, and a caller that collapses them cannot tell an operator which one it hit — so the
|
|
89
|
+
* split is returned whole and both questions are answered from it.
|
|
90
|
+
*/
|
|
91
|
+
export function currentDeploymentVersions(value: unknown): DeployedVersionShare[] | null {
|
|
92
|
+
if (Array.isArray(value)) {
|
|
93
|
+
for (const entry of value) {
|
|
94
|
+
const found = currentDeploymentVersions(entry);
|
|
95
|
+
if (found) return found;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
if (!isUnknownRecord(value)) return null;
|
|
100
|
+
if (Array.isArray(value.versions)) {
|
|
101
|
+
return value.versions.map((version) => {
|
|
102
|
+
if (!isUnknownRecord(version)) return { versionId: null, percentage: 0 };
|
|
103
|
+
const id = version.version_id ?? version.id;
|
|
104
|
+
return { versionId: typeof id === 'string' ? id : null, percentage: Number(version.percentage) };
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
for (const nested of Object.values(value)) {
|
|
108
|
+
const found = currentDeploymentVersions(nested);
|
|
109
|
+
if (found) return found;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The one version serving 100% of traffic, or null when there is none or the deployment is split.
|
|
116
|
+
*
|
|
117
|
+
* A split is not "close enough": with two versions live, "is our version live" has no single
|
|
118
|
+
* answer, and answering it optimistically is how a deploy reports success while part of the
|
|
119
|
+
* traffic still reaches the old code.
|
|
120
|
+
*/
|
|
121
|
+
export function currentDeploymentVersionId(value: unknown): string | null {
|
|
122
|
+
const shares = currentDeploymentVersions(value);
|
|
123
|
+
if (shares?.length !== 1) return null;
|
|
124
|
+
const [only] = shares;
|
|
125
|
+
return only.percentage === 100 ? only.versionId : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The version serving all traffic for this worker, with the tag it was uploaded under. */
|
|
129
|
+
export async function readLiveVersion(probe: LiveVersionProbe): Promise<Result<LiveVersion, LiveVersionFailure>> {
|
|
130
|
+
const deployments = await probe.deployments();
|
|
131
|
+
const shares = currentDeploymentVersions(deployments);
|
|
132
|
+
const versionId = currentDeploymentVersionId(deployments);
|
|
133
|
+
if (!versionId) {
|
|
134
|
+
return shares && shares.length > 0
|
|
135
|
+
? {
|
|
136
|
+
ok: false,
|
|
137
|
+
error: {
|
|
138
|
+
kind: 'split-traffic',
|
|
139
|
+
message: 'no single worker version is serving 100% of traffic; refusing to name one as live',
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
: { ok: false, error: { kind: 'never-deployed', message: 'this worker has no active deployment' } };
|
|
143
|
+
}
|
|
144
|
+
const tag = collectVersionTags(await probe.versions()).get(versionId) ?? null;
|
|
145
|
+
return { ok: true, value: { versionId, tag } };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* How long a deploy waits for the version it just activated to become the one being served.
|
|
150
|
+
*
|
|
151
|
+
* The bound is the point: an unbounded loop turns a stuck propagation into a hung CI job nobody
|
|
152
|
+
* reads, and a fixed sleep turns it into a green job that lied. Both are worse than a red job
|
|
153
|
+
* naming what it expected and what it saw.
|
|
154
|
+
*/
|
|
155
|
+
export const LIVE_VERSION_WAIT_BUDGET_MS = 120_000;
|
|
156
|
+
/** Cloudflare's control plane converges in seconds; polling faster than this only burns API quota. */
|
|
157
|
+
export const LIVE_VERSION_POLL_INTERVAL_MS = 2_000;
|
|
158
|
+
|
|
159
|
+
export interface LiveVersionWaitOptions {
|
|
160
|
+
budgetMs?: number;
|
|
161
|
+
intervalMs?: number;
|
|
162
|
+
now?: () => number;
|
|
163
|
+
sleep?: (ms: number) => Promise<void>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface LiveVersionNotObserved {
|
|
167
|
+
kind: 'not-observed';
|
|
168
|
+
message: string;
|
|
169
|
+
expected: string;
|
|
170
|
+
observed: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** One poll's verdict: the value that ends the wait, or how the world looked this time round. */
|
|
174
|
+
type Attempt<T> = { done: true; value: T } | { done: false; observed: string };
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Runs `attempt` until it succeeds or the budget expires, and reports the LAST thing it saw.
|
|
178
|
+
*
|
|
179
|
+
* Both waits below need the same deadline arithmetic and the same "what did you see instead"
|
|
180
|
+
* record; sharing it keeps the two from drifting into disagreeing about when a wait is over.
|
|
181
|
+
*/
|
|
182
|
+
async function pollWithinBudget<T>(
|
|
183
|
+
attempt: () => Promise<Attempt<T>>,
|
|
184
|
+
options: LiveVersionWaitOptions,
|
|
185
|
+
): Promise<Result<T, { observed: string; waitedMs: number }>> {
|
|
186
|
+
const budgetMs = options.budgetMs ?? LIVE_VERSION_WAIT_BUDGET_MS;
|
|
187
|
+
const intervalMs = options.intervalMs ?? LIVE_VERSION_POLL_INTERVAL_MS;
|
|
188
|
+
const now = options.now ?? Date.now;
|
|
189
|
+
const sleep = options.sleep ?? sleepFor;
|
|
190
|
+
const deadline = now() + budgetMs;
|
|
191
|
+
for (;;) {
|
|
192
|
+
const result = await attempt();
|
|
193
|
+
if (result.done) return { ok: true, value: result.value };
|
|
194
|
+
if (now() >= deadline) return { ok: false, error: { observed: result.observed, waitedMs: budgetMs } };
|
|
195
|
+
await sleep(intervalMs);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function sleepFor(ms: number): Promise<void> {
|
|
200
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
201
|
+
setTimeout(resolve, ms);
|
|
202
|
+
return promise;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Polls until the worker's live version carries `expectedTag`, or the budget runs out.
|
|
207
|
+
*
|
|
208
|
+
* The tag is the match, not the version id: the tag IS the desired-state identity (`nx-<task
|
|
209
|
+
* hash>`), it is what both `wrangler deploy --tag` and `wrangler versions deploy --version-tag`
|
|
210
|
+
* were told to make live, and it is the one name the caller knows before the upload has produced
|
|
211
|
+
* a version id.
|
|
212
|
+
*
|
|
213
|
+
* Returns the failure rather than throwing it: a deploy that cannot be observed is an operational
|
|
214
|
+
* outcome the caller has to report, not a broken invariant.
|
|
215
|
+
*/
|
|
216
|
+
export async function awaitLiveVersion(
|
|
217
|
+
probe: LiveVersionProbe,
|
|
218
|
+
expectedTag: string,
|
|
219
|
+
options: LiveVersionWaitOptions = {},
|
|
220
|
+
): Promise<Result<LiveVersion, LiveVersionNotObserved>> {
|
|
221
|
+
const polled = await pollWithinBudget<LiveVersion>(async () => {
|
|
222
|
+
const live = await readLiveVersion(probe);
|
|
223
|
+
if (!live.ok) return { done: false, observed: live.error.message };
|
|
224
|
+
const { tag, versionId } = live.value;
|
|
225
|
+
if (tag === expectedTag) return { done: true, value: live.value };
|
|
226
|
+
return { done: false, observed: tag ? `${tag} (${versionId})` : `an untagged version (${versionId})` };
|
|
227
|
+
}, options);
|
|
228
|
+
if (polled.ok) return polled;
|
|
229
|
+
return {
|
|
230
|
+
ok: false,
|
|
231
|
+
error: {
|
|
232
|
+
kind: 'not-observed',
|
|
233
|
+
expected: expectedTag,
|
|
234
|
+
observed: polled.error.observed,
|
|
235
|
+
message:
|
|
236
|
+
`deployed version ${expectedTag} was not serving traffic after ` +
|
|
237
|
+
`${Math.round(polled.error.waitedMs / 1000)}s; live is ${polled.error.observed}`,
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Exactly the call the wait makes. Demanding all of `typeof fetch` would force every caller — the
|
|
244
|
+
* tests included — to also supply `preconnect`, which nothing here uses.
|
|
245
|
+
*/
|
|
246
|
+
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
247
|
+
|
|
248
|
+
export interface VersionEndpointWaitOptions extends LiveVersionWaitOptions {
|
|
249
|
+
fetch?: FetchLike;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Polls a worker-served endpoint until it answers with `expectedTag`.
|
|
254
|
+
*
|
|
255
|
+
* The control plane agreeing is necessary and not sufficient — that gap is exactly what this
|
|
256
|
+
* change's predecessor measured — so a project that can prove the edge serves the new code says so
|
|
257
|
+
* with an endpoint echoing its own version tag (Workers read it from the version-metadata
|
|
258
|
+
* binding). The contract is deliberately one shape: the trimmed response body IS the tag.
|
|
259
|
+
*/
|
|
260
|
+
export async function awaitVersionEndpoint(
|
|
261
|
+
url: string,
|
|
262
|
+
expectedTag: string,
|
|
263
|
+
options: VersionEndpointWaitOptions = {},
|
|
264
|
+
): Promise<Result<void, LiveVersionNotObserved>> {
|
|
265
|
+
const request = options.fetch ?? fetch;
|
|
266
|
+
const polled = await pollWithinBudget<void>(async () => {
|
|
267
|
+
const observed = await probeVersionEndpoint(request, url);
|
|
268
|
+
return observed === expectedTag ? { done: true, value: undefined } : { done: false, observed };
|
|
269
|
+
}, options);
|
|
270
|
+
if (polled.ok) return polled;
|
|
271
|
+
return {
|
|
272
|
+
ok: false,
|
|
273
|
+
error: {
|
|
274
|
+
kind: 'not-observed',
|
|
275
|
+
expected: expectedTag,
|
|
276
|
+
observed: polled.error.observed,
|
|
277
|
+
message:
|
|
278
|
+
`${url} did not report version ${expectedTag} after ` +
|
|
279
|
+
`${Math.round(polled.error.waitedMs / 1000)}s; it reported ${polled.error.observed}`,
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function probeVersionEndpoint(request: FetchLike, url: string): Promise<string> {
|
|
285
|
+
try {
|
|
286
|
+
const response = await request(url, { headers: { accept: 'text/plain' } });
|
|
287
|
+
const body = (await response.text()).trim();
|
|
288
|
+
return response.ok ? body : `HTTP ${response.status} ${body}`.trim();
|
|
289
|
+
} catch (error) {
|
|
290
|
+
// A worker mid-rollout refuses connections; that is a poll result, not a reason to abandon the
|
|
291
|
+
// wait. It only becomes the failure when it is still the answer at the deadline.
|
|
292
|
+
return error instanceof Error ? error.message : String(error);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* How long `smoo wrangler deployed-version` trusts its own last answer.
|
|
298
|
+
*
|
|
299
|
+
* This TTL is a convenience for operators and repeated local queries, NOT a correctness mechanism:
|
|
300
|
+
* nothing that decides whether to deploy may read it. The deploy's own liveness check always calls
|
|
301
|
+
* Cloudflare, because a cached "live already equals the desired tag" is precisely the belief a
|
|
302
|
+
* rollback falsifies, and acting on it would skip the deploy that repairs the rollback.
|
|
303
|
+
*/
|
|
304
|
+
export const LIVE_VERSION_CACHE_TTL_MS = 45_000;
|
|
305
|
+
|
|
306
|
+
export interface LiveVersionCacheKey {
|
|
307
|
+
accountId: string;
|
|
308
|
+
workerName: string;
|
|
309
|
+
stage: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export interface CachedLiveVersion {
|
|
313
|
+
versionTag: string | null;
|
|
314
|
+
versionId: string;
|
|
315
|
+
fetchedAt: number;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const parseCachedLiveVersion = typia.json.createValidateParse<CachedLiveVersion>();
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* One file per key, so two deploys running in parallel cannot clobber each other's answer; a
|
|
322
|
+
* shared map would need a lock to say the same thing.
|
|
323
|
+
*/
|
|
324
|
+
export function liveVersionCachePath(cacheDirectory: string, key: LiveVersionCacheKey): string {
|
|
325
|
+
const name = [key.accountId, key.workerName, key.stage].map(encodeURIComponent).join('_');
|
|
326
|
+
return join(cacheDirectory, 'smoo-live-version', `${name}.json`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function readCachedLiveVersion(
|
|
330
|
+
cacheDirectory: string,
|
|
331
|
+
key: LiveVersionCacheKey,
|
|
332
|
+
ttlMs: number,
|
|
333
|
+
now: () => number = Date.now,
|
|
334
|
+
): Promise<CachedLiveVersion | null> {
|
|
335
|
+
let text: string;
|
|
336
|
+
try {
|
|
337
|
+
text = await readFile(liveVersionCachePath(cacheDirectory, key), 'utf8');
|
|
338
|
+
} catch {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
const parsed = parseCachedLiveVersion(text);
|
|
342
|
+
if (!parsed.success) return null;
|
|
343
|
+
return now() - parsed.data.fetchedAt < ttlMs ? parsed.data : null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function writeCachedLiveVersion(
|
|
347
|
+
cacheDirectory: string,
|
|
348
|
+
key: LiveVersionCacheKey,
|
|
349
|
+
entry: CachedLiveVersion,
|
|
350
|
+
): Promise<void> {
|
|
351
|
+
const path = liveVersionCachePath(cacheDirectory, key);
|
|
352
|
+
await mkdir(dirname(path), { recursive: true });
|
|
353
|
+
await writeFile(path, `${JSON.stringify(entry)}\n`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Where the cache lives. Nx's workspace data directory already exists, is already gitignored, and
|
|
358
|
+
* is already wiped when the workspace is reset — three properties a hand-rolled directory would
|
|
359
|
+
* each have to re-earn, and one ($HOME) that would leak one checkout's answers into another's.
|
|
360
|
+
*/
|
|
361
|
+
export function liveVersionCacheDirectory(workspaceRoot: string, environment: NodeJS.ProcessEnv): string {
|
|
362
|
+
return environment.NX_WORKSPACE_DATA_DIRECTORY ?? join(workspaceRoot, '.nx', 'workspace-data');
|
|
363
|
+
}
|