serverless-ircd 0.9.0 → 0.10.0
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/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +305 -0
- package/README.md +134 -41
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +177 -52
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +267 -92
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +12 -9
- package/apps/cf-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +21 -17
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +3 -6
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
- package/packages/aws-adapter/tests/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +266 -109
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +95 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +16 -19
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +122 -11
- package/packages/irc-core/src/commands/registration.ts +2 -2
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +237 -1
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +140 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +159 -178
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +0 -14
- package/packages/irc-server/tests/actor.test.ts +19 -47
- package/packages/irc-test-support/package.json +1 -1
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/load-test/package.json +1 -1
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `scripts/deploy-web-aws.mjs` — the AWS web-client deploy pipeline helper.
|
|
4
|
+
*
|
|
5
|
+
* Two-phase deploy that runs AFTER `cdk deploy` applies the `IrcAwsStack`
|
|
6
|
+
* CloudFormation stack:
|
|
7
|
+
*
|
|
8
|
+
* 1. `aws cloudformation describe-stacks --stack-name IrcAwsStack` reads
|
|
9
|
+
* the stack's outputs (`ConnectUrl`, `WebsiteBucketName`,
|
|
10
|
+
* `WebsiteDistributionId`).
|
|
11
|
+
* 2. `pnpm --filter @serverless-ircd/web run build:prod-aws -- --api-url <url>`
|
|
12
|
+
* bakes the SPA with the real API Gateway WebSocket URL (read from
|
|
13
|
+
* `ConnectUrl` in step 1). The build script overrides the templated
|
|
14
|
+
* `{{API_ID}}`/`{{REGION}}`/`{{STAGE}}` `server` value in
|
|
15
|
+
* `apps/web/static/config.prod-aws.json` and rejects any leftover
|
|
16
|
+
* `{{...}}` placeholders, so no hardcoded URLs ship.
|
|
17
|
+
* 3. `aws s3 sync apps/web/dist s3://<bucket> --delete` uploads the
|
|
18
|
+
* build. The CLI's `sync` compares etags and SKIPS unchanged objects,
|
|
19
|
+
* so a re-deploy with no SPA changes uploads nothing (the
|
|
20
|
+
* invalidation in step 4 still fires — CloudFront has no
|
|
21
|
+
* unchanged-skip equivalent).
|
|
22
|
+
* 4. `aws cloudfront create-invalidation --distribution-id <id> --paths /*`
|
|
23
|
+
* busts the edge cache so the new build is served immediately.
|
|
24
|
+
*
|
|
25
|
+
* Why a script instead of CDK `BucketDeployment`: `BucketDeployment`
|
|
26
|
+
* re-synths on every deploy (slower), cannot bake the post-deploy API URL
|
|
27
|
+
* into the baked config (it runs before the stack outputs exist), and
|
|
28
|
+
* pulls in a custom-resource Lambda for a one-shot copy. A stand-alone
|
|
29
|
+
* script reads the deployed outputs directly.
|
|
30
|
+
*
|
|
31
|
+
* Why the AWS CLI instead of `@aws-sdk/client-*`: `aws s3 sync`'s etag
|
|
32
|
+
* comparison and unchanged-skip are non-trivial to reimplement on top of
|
|
33
|
+
* the SDK; the CLI is already a deploy-time dependency (the workflow uses
|
|
34
|
+
* it for `describe-stacks`). One-off SDK calls would multiply surface
|
|
35
|
+
* area without buying anything.
|
|
36
|
+
*
|
|
37
|
+
* @see docs/AWS-Deployment.md for the full deploy runbook.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { spawnSync } from 'node:child_process';
|
|
41
|
+
import path from 'node:path';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fixed CloudFormation stack name (post-staging/prod-collapse: a single
|
|
45
|
+
* target per platform, no env suffix). Mirrors the value the deploy
|
|
46
|
+
* workflow's `describe-stacks` query already uses.
|
|
47
|
+
*/
|
|
48
|
+
export const STACK_NAME = 'IrcAwsStack';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Outputs the script consumes. Each MUST be present on the deployed
|
|
52
|
+
* stack — a missing output aborts the deploy with a clear message so
|
|
53
|
+
* the operator knows to `cdk deploy` (or check the `webSite` prop) before
|
|
54
|
+
* re-running.
|
|
55
|
+
*/
|
|
56
|
+
export const REQUIRED_OUTPUTS = ['ConnectUrl', 'WebsiteBucketName', 'WebsiteDistributionId'];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Pattern matching the `{{...}}` placeholders baked into
|
|
60
|
+
* `apps/web/static/config.prod-aws.json`. The stack output's `ConnectUrl`
|
|
61
|
+
* should be a fully-resolved wss URL; if it isn't, the build script would
|
|
62
|
+
* bake a broken SPA. This guard fails the deploy at the script layer
|
|
63
|
+
* (defence in depth) instead of relying on `build.mjs` alone.
|
|
64
|
+
*/
|
|
65
|
+
const PLACEHOLDER_PATTERN = /\{\{[^}]+\}\}/u;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Builds the default runner that shells out to the `aws` CLI and `pnpm`
|
|
69
|
+
* via `spawnSync`. IO is captured (pipe) on the AWS path so the script can
|
|
70
|
+
* surface stderr in its own error messages; stdio for the `pnpm build`
|
|
71
|
+
* call is inherited so the (verbose) Kiwi build log streams live to the
|
|
72
|
+
* operator.
|
|
73
|
+
*
|
|
74
|
+
* @param {{ cwd?: string }} [opts]
|
|
75
|
+
* @returns {{ aws: (args: string[]) => CliResult; pnpm: (args: string[]) => CliResult }}
|
|
76
|
+
*/
|
|
77
|
+
export function makeDefaultRunner({ cwd = process.cwd() } = {}) {
|
|
78
|
+
return {
|
|
79
|
+
/* v8 ignore next -- 1-line wrapper over the tested runCli(); the literal binary name is the only thing that differs and it cannot be exercised without `aws` on the test PATH. */
|
|
80
|
+
aws(args) {
|
|
81
|
+
return runCli('aws', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
82
|
+
},
|
|
83
|
+
/* v8 ignore next -- 1-line wrapper over the tested runCli(); same reason as aws() above. */
|
|
84
|
+
pnpm(args) {
|
|
85
|
+
// Inherit stdio for pnpm so the build log streams live.
|
|
86
|
+
return runCli('pnpm', args, { cwd, stdio: 'inherit' });
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Spawns a child process and returns the captured IO + exit status. Pure
|
|
93
|
+
* plumbing — no throwing on non-zero exits (the caller decides whether a
|
|
94
|
+
* non-zero status is an error). Exported so the test-suite can exercise
|
|
95
|
+
* the spawnSync shape with `node` as the binary instead of `aws`/`pnpm`
|
|
96
|
+
* (neither of which is guaranteed to be on PATH in the test environment).
|
|
97
|
+
*
|
|
98
|
+
* @param {string} command
|
|
99
|
+
* @param {string[]} args
|
|
100
|
+
* @param {{ cwd: string; stdio: 'inherit' | Array<'ignore' | 'pipe' | 'inherit'> }} opts
|
|
101
|
+
* @returns {CliResult}
|
|
102
|
+
*/
|
|
103
|
+
export function runCli(command, args, opts) {
|
|
104
|
+
const result = spawnSync(command, args, {
|
|
105
|
+
cwd: opts.cwd,
|
|
106
|
+
stdio: opts.stdio,
|
|
107
|
+
encoding: 'utf8',
|
|
108
|
+
});
|
|
109
|
+
const status = result.status ?? -1;
|
|
110
|
+
const stdout = typeof result.stdout === 'string' ? result.stdout : '';
|
|
111
|
+
const stderr = typeof result.stderr === 'string' ? result.stderr : '';
|
|
112
|
+
return { status, stdout, stderr };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Parses the JSON body returned by
|
|
117
|
+
* `aws cloudformation describe-stacks --query 'Stacks[0].Outputs' --output json`
|
|
118
|
+
* into a plain `OutputKey → OutputValue` lookup. Empty / malformed bodies
|
|
119
|
+
* surface as thrown Errors so the caller (the deploy function) can fail
|
|
120
|
+
* loudly with context.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} jsonBody
|
|
123
|
+
* @returns {Record<string, string>}
|
|
124
|
+
*/
|
|
125
|
+
export function parseStackOutputs(jsonBody) {
|
|
126
|
+
const parsed = JSON.parse(jsonBody);
|
|
127
|
+
if (!Array.isArray(parsed)) {
|
|
128
|
+
return {};
|
|
129
|
+
}
|
|
130
|
+
const map = {};
|
|
131
|
+
for (const entry of parsed) {
|
|
132
|
+
if (
|
|
133
|
+
typeof entry === 'object' &&
|
|
134
|
+
entry !== null &&
|
|
135
|
+
typeof entry.OutputKey === 'string' &&
|
|
136
|
+
typeof entry.OutputValue === 'string'
|
|
137
|
+
) {
|
|
138
|
+
map[entry.OutputKey] = entry.OutputValue;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return map;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Runs the two-phase AWS web deploy. Throws on any failure (missing
|
|
146
|
+
* output, CLI non-zero exit, leftover placeholder) — the CLI entrypoint
|
|
147
|
+
* catches and exits non-zero.
|
|
148
|
+
*
|
|
149
|
+
* Implemented as an `async` function so the entrypoint and tests can
|
|
150
|
+
* consume it uniformly via `.catch()` / `rejects.toThrowError`; the
|
|
151
|
+
* underlying `spawnSync` calls are themselves synchronous, but wrapping
|
|
152
|
+
* the orchestration in a Promise keeps the contract stable if a future
|
|
153
|
+
* revision swaps in the AWS SDK or a streaming upload.
|
|
154
|
+
*
|
|
155
|
+
* @param {Object} [opts]
|
|
156
|
+
* @param {{ aws: (args: string[]) => CliResult; pnpm: (args: string[]) => CliResult }} [opts.runner]
|
|
157
|
+
* @param {string} [opts.stackName]
|
|
158
|
+
* @param {string} [opts.distDir]
|
|
159
|
+
* @param {(message: string) => void} [opts.log]
|
|
160
|
+
* @returns {Promise<{ connectUrl: string; bucket: string; distributionId: string }>}
|
|
161
|
+
*/
|
|
162
|
+
export async function deployWebAws({
|
|
163
|
+
runner = makeDefaultRunner(),
|
|
164
|
+
stackName = STACK_NAME,
|
|
165
|
+
distDir = path.join('apps', 'web', 'dist'),
|
|
166
|
+
log = (m) => console.log(m),
|
|
167
|
+
} = {}) {
|
|
168
|
+
// 1. Read the deployed stack's outputs.
|
|
169
|
+
const describeResult = runner.aws([
|
|
170
|
+
'cloudformation',
|
|
171
|
+
'describe-stacks',
|
|
172
|
+
'--stack-name',
|
|
173
|
+
stackName,
|
|
174
|
+
'--query',
|
|
175
|
+
'Stacks[0].Outputs',
|
|
176
|
+
'--output',
|
|
177
|
+
'json',
|
|
178
|
+
]);
|
|
179
|
+
if (describeResult.status !== 0) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`aws cloudformation describe-stacks --stack-name ${stackName} exited with ${describeResult.status}: ${describeResult.stderr}`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
const outputs = parseStackOutputs(describeResult.stdout);
|
|
185
|
+
|
|
186
|
+
// 2. Guard: every required output MUST be present. A missing output means
|
|
187
|
+
// the stack hasn't been deployed (or the `webSite` prop was omitted on
|
|
188
|
+
// the last synth), and proceeding would either bake a broken config
|
|
189
|
+
// or sync to the wrong bucket.
|
|
190
|
+
const connectUrl = outputs.ConnectUrl;
|
|
191
|
+
const bucket = outputs.WebsiteBucketName;
|
|
192
|
+
const distributionId = outputs.WebsiteDistributionId;
|
|
193
|
+
for (const key of REQUIRED_OUTPUTS) {
|
|
194
|
+
const value = outputs[key];
|
|
195
|
+
if (value === undefined || value.length === 0) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Missing required CloudFormation output '${key}' on stack '${stackName}'. ` +
|
|
198
|
+
`Has the stack been deployed (cdk deploy --all)? The web deploy pipeline requires: ${REQUIRED_OUTPUTS.join(', ')}.`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Defence in depth: the stack output itself should never carry an
|
|
204
|
+
// unsubstituted placeholder, but if it did the build would bake a
|
|
205
|
+
// broken SPA. Fail loudly here so the operator sees the script's
|
|
206
|
+
// diagnostic before the build's.
|
|
207
|
+
if (PLACEHOLDER_PATTERN.test(connectUrl)) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`Stack output ConnectUrl carries an unsubstituted placeholder: '${connectUrl}'. The deployed stage URL must be a fully-resolved wss:// URL, not a template.`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 3. Bake the SPA with the real API URL. The build script overrides the
|
|
214
|
+
// templated `server` field in config.prod-aws.json with this value
|
|
215
|
+
// and rejects any remaining `{{...}}` placeholders.
|
|
216
|
+
log(`Baking SPA with API URL ${connectUrl}`);
|
|
217
|
+
const buildResult = runner.pnpm([
|
|
218
|
+
'--filter',
|
|
219
|
+
'@serverless-ircd/web',
|
|
220
|
+
'run',
|
|
221
|
+
'build:prod-aws',
|
|
222
|
+
'--',
|
|
223
|
+
'--env',
|
|
224
|
+
'prod-aws',
|
|
225
|
+
'--api-url',
|
|
226
|
+
connectUrl,
|
|
227
|
+
]);
|
|
228
|
+
if (buildResult.status !== 0) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`pnpm build:prod-aws exited with ${buildResult.status}. The web SPA build failed; see the build log above.`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 4. s3 sync. The CLI's `sync` skips unchanged objects via etag
|
|
235
|
+
// comparison, so a re-deploy with no SPA changes uploads nothing.
|
|
236
|
+
// `--delete` removes objects that no longer exist in the dist tree
|
|
237
|
+
// (e.g. hashed assets from the previous build) so the bucket does
|
|
238
|
+
// not accumulate stale entries.
|
|
239
|
+
log(`Syncing ${distDir} → s3://${bucket}`);
|
|
240
|
+
const syncResult = runner.aws(['s3', 'sync', distDir, `s3://${bucket}`, '--delete']);
|
|
241
|
+
if (syncResult.status !== 0) {
|
|
242
|
+
throw new Error(`aws s3 sync exited with ${syncResult.status}: ${syncResult.stderr}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 5. CloudFront invalidation. Always fires — CloudFront has no
|
|
246
|
+
// unchanged-skip equivalent, so even a no-op sync requires this to
|
|
247
|
+
// guarantee the edge serves the latest origin state.
|
|
248
|
+
log(`Invalidating CloudFront distribution ${distributionId}`);
|
|
249
|
+
const invalidationResult = runner.aws([
|
|
250
|
+
'cloudfront',
|
|
251
|
+
'create-invalidation',
|
|
252
|
+
'--distribution-id',
|
|
253
|
+
distributionId,
|
|
254
|
+
'--paths',
|
|
255
|
+
'/*',
|
|
256
|
+
]);
|
|
257
|
+
if (invalidationResult.status !== 0) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`aws cloudfront create-invalidation exited with ${invalidationResult.status}: ${invalidationResult.stderr}`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
log(`Deployed web client → s3://${bucket} (CloudFront ${distributionId}, invalidated /*)`);
|
|
264
|
+
return { connectUrl, bucket, distributionId };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// --- CLI entrypoint --------------------------------------------------------
|
|
268
|
+
//
|
|
269
|
+
// Excluded from coverage: the entrypoint is exercised by the deploy
|
|
270
|
+
// pipeline / a human operator, not by the unit tests (which call
|
|
271
|
+
// `deployWebAws` directly with an injected runner). The branch under test
|
|
272
|
+
// is the `if (invokedDirectly)` body, which only fires when this file is
|
|
273
|
+
// the process entry point — exactly the case vitest's import does NOT
|
|
274
|
+
// produce. v8's ignore markers keep the coverage threshold meaningful
|
|
275
|
+
// for the testable surface.
|
|
276
|
+
|
|
277
|
+
/* v8 ignore start */
|
|
278
|
+
const invokedDirectly =
|
|
279
|
+
process.argv[1] !== undefined &&
|
|
280
|
+
path.resolve(process.argv[1]) ===
|
|
281
|
+
path.resolve(new URL('./deploy-web-aws.mjs', import.meta.url).pathname);
|
|
282
|
+
|
|
283
|
+
if (invokedDirectly) {
|
|
284
|
+
deployWebAws().catch((err) => {
|
|
285
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
286
|
+
console.error(`deploy-web-aws: ${message}`);
|
|
287
|
+
process.exit(1);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
/* v8 ignore stop */
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@serverless-ircd/deploy-scripts",
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Repo-level deploy helpers (AWS web client s3 sync + CloudFront invalidation pipeline).",
|
|
6
|
+
"license": "BSD-3-Clause",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"deploy:web-aws": "node ./deploy-web-aws.mjs",
|
|
10
|
+
"typecheck": "tsc -p tsconfig.test.json --noEmit",
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"test:watch": "vitest",
|
|
13
|
+
"coverage": "vitest run --coverage",
|
|
14
|
+
"clean": "rimraf coverage .turbo"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/node": "^26.1.1",
|
|
18
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
19
|
+
"rimraf": "^6.0.0",
|
|
20
|
+
"typescript": "^5.6.0",
|
|
21
|
+
"vitest": "^4.1.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"noEmit": true,
|
|
5
|
+
"composite": false,
|
|
6
|
+
"types": ["node"],
|
|
7
|
+
"allowJs": true,
|
|
8
|
+
"checkJs": false
|
|
9
|
+
},
|
|
10
|
+
"include": ["**/*.ts", "**/*.mjs", "**/*.d.mts"],
|
|
11
|
+
"exclude": ["node_modules", "coverage"]
|
|
12
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
environment: 'node',
|
|
6
|
+
include: ['__tests__/**/*.test.ts'],
|
|
7
|
+
coverage: {
|
|
8
|
+
provider: 'v8',
|
|
9
|
+
include: ['deploy-web-aws.mjs'],
|
|
10
|
+
reporter: ['text', 'html', 'json-summary'],
|
|
11
|
+
thresholds: {
|
|
12
|
+
lines: 90,
|
|
13
|
+
functions: 90,
|
|
14
|
+
branches: 90,
|
|
15
|
+
statements: 90,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
});
|
|
@@ -37,9 +37,66 @@ interface WorkflowJob {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
interface ParsedWorkflow {
|
|
40
|
+
permissions?: Record<string, string> | string;
|
|
40
41
|
jobs?: Record<string, WorkflowJob>;
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Parse a GitHub Actions YAML file and return the top-level
|
|
46
|
+
* `permissions:` block as a `Record<string, string>` (e.g.
|
|
47
|
+
* `{ 'id-token': 'write', contents: 'read' }`).
|
|
48
|
+
*
|
|
49
|
+
* Throws if the workflow has no top-level `permissions:` block.
|
|
50
|
+
* The literal `'read-all'` / `'write-all'` shapes are returned
|
|
51
|
+
* verbatim as a single key so callers can flag them.
|
|
52
|
+
*
|
|
53
|
+
* @param yamlText raw YAML source of a workflow file.
|
|
54
|
+
*/
|
|
55
|
+
export function readWorkflowPermissions(yamlText: string): Record<string, string> {
|
|
56
|
+
const parsed = load(yamlText) as ParsedWorkflow;
|
|
57
|
+
if (parsed.permissions === undefined || parsed.permissions === null) {
|
|
58
|
+
throw new Error('Workflow has no top-level permissions: block');
|
|
59
|
+
}
|
|
60
|
+
if (typeof parsed.permissions === 'string') {
|
|
61
|
+
return { [parsed.permissions]: parsed.permissions };
|
|
62
|
+
}
|
|
63
|
+
return parsed.permissions;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Return every step in the workflow that uses `configure-aws-credentials`,
|
|
68
|
+
* keyed by `"<job>.<index>"`. Each value is the step's `with:` mapping
|
|
69
|
+
* (an empty object if the step has no `with:`).
|
|
70
|
+
*
|
|
71
|
+
* Used by the OIDC-only lint to assert no static-key inputs survive.
|
|
72
|
+
*
|
|
73
|
+
* @param yamlText raw YAML source of a workflow file.
|
|
74
|
+
*/
|
|
75
|
+
export function findAwsCredentialSteps(
|
|
76
|
+
yamlText: string,
|
|
77
|
+
): Array<{ job: string; index: number; with: Record<string, unknown> }> {
|
|
78
|
+
const parsed = load(yamlText) as ParsedWorkflow;
|
|
79
|
+
const out: Array<{ job: string; index: number; with: Record<string, unknown> }> = [];
|
|
80
|
+
for (const [jobName, job] of Object.entries(parsed.jobs ?? {})) {
|
|
81
|
+
const steps = job?.steps ?? [];
|
|
82
|
+
steps.forEach((step, index) => {
|
|
83
|
+
const uses = step.uses;
|
|
84
|
+
if (typeof uses === 'string' && uses.includes('configure-aws-credentials')) {
|
|
85
|
+
const withBlock = step.with;
|
|
86
|
+
out.push({
|
|
87
|
+
job: jobName,
|
|
88
|
+
index,
|
|
89
|
+
with:
|
|
90
|
+
withBlock && typeof withBlock === 'object'
|
|
91
|
+
? (withBlock as Record<string, unknown>)
|
|
92
|
+
: {},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
43
100
|
/**
|
|
44
101
|
* Extract the `coverage.thresholds` block from a vitest config file.
|
|
45
102
|
*
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC-only AWS deploy enforcement.
|
|
3
|
+
*
|
|
4
|
+
* The deploy workflow (`.github/workflows/deploy-aws.yml`) must NOT
|
|
5
|
+
* accept long-lived access keys. It must require an OIDC web-identity
|
|
6
|
+
* role (`AWS_DEPLOY_ROLE_ARN`), grant `id-token: write`, fail fast when
|
|
7
|
+
* the role ARN is missing, and assert that `aws sts get-caller-identity`
|
|
8
|
+
* returns the configured role before any `cdk deploy` / `cdk synth` —
|
|
9
|
+
* so a silent fallback to ambient static credentials cannot ship a
|
|
10
|
+
* deploy.
|
|
11
|
+
*
|
|
12
|
+
* These assertions run as part of the CI-hardening suite on every PR,
|
|
13
|
+
* the same way the coverage-gate and mutation-testing contracts do.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { resolve } from 'node:path';
|
|
18
|
+
import { describe, expect, it } from 'vitest';
|
|
19
|
+
import {
|
|
20
|
+
findAwsCredentialSteps,
|
|
21
|
+
readWorkflowJob,
|
|
22
|
+
readWorkflowPermissions,
|
|
23
|
+
} from '../src/validate.js';
|
|
24
|
+
|
|
25
|
+
const REPO_ROOT = resolve(import.meta.dirname, '..', '..', '..');
|
|
26
|
+
const WORKFLOW_PATH = resolve(REPO_ROOT, '.github/workflows/deploy-aws.yml');
|
|
27
|
+
const WORKFLOW = readFileSync(WORKFLOW_PATH, 'utf8');
|
|
28
|
+
|
|
29
|
+
describe('deploy-aws.yml: OIDC is the only accepted AWS credential source', () => {
|
|
30
|
+
it('does NOT pass aws-access-key-id / aws-secret-access-key to configure-aws-credentials', () => {
|
|
31
|
+
const steps = findAwsCredentialSteps(WORKFLOW);
|
|
32
|
+
expect(steps.length).toBeGreaterThan(0);
|
|
33
|
+
for (const step of steps) {
|
|
34
|
+
expect(step.with).not.toHaveProperty('aws-access-key-id');
|
|
35
|
+
expect(step.with).not.toHaveProperty('aws-secret-access-key');
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('configures a role-to-assume on every configure-aws-credentials step', () => {
|
|
40
|
+
const steps = findAwsCredentialSteps(WORKFLOW);
|
|
41
|
+
expect(steps.length).toBeGreaterThan(0);
|
|
42
|
+
for (const step of steps) {
|
|
43
|
+
expect(step.with['role-to-assume']).toBeDefined();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('grants id-token: write at the top level (OIDC minting requires it)', () => {
|
|
48
|
+
const permissions = readWorkflowPermissions(WORKFLOW);
|
|
49
|
+
expect(permissions['id-token']).toBe('write');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('keeps the permissions block least-privilege (only id-token + contents)', () => {
|
|
53
|
+
const permissions = readWorkflowPermissions(WORKFLOW);
|
|
54
|
+
expect(permissions.contents).toBe('read');
|
|
55
|
+
const keys = Object.keys(permissions).sort();
|
|
56
|
+
expect(keys).toEqual(['contents', 'id-token']);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('fails fast when AWS_DEPLOY_ROLE_ARN is empty (guard step exists in every AWS-touching job)', () => {
|
|
60
|
+
// The "deploy" job runs `pnpm deploy:aws` (cdk deploy) and the
|
|
61
|
+
// "deploy-web" job runs `node scripts/deploy-web-aws.mjs`. Both
|
|
62
|
+
// must abort before any AWS call if the role ARN secret is unset.
|
|
63
|
+
for (const jobName of ['deploy', 'deploy-web']) {
|
|
64
|
+
const job = readWorkflowJob(WORKFLOW, jobName);
|
|
65
|
+
const hasGuard = job.steps.some((step) => {
|
|
66
|
+
const ifExpr = typeof step.if === 'string' ? step.if : '';
|
|
67
|
+
const env = step.env as Record<string, string> | undefined;
|
|
68
|
+
return (
|
|
69
|
+
ifExpr.includes('AWS_DEPLOY_ROLE_ARN') &&
|
|
70
|
+
ifExpr.includes("== ''") &&
|
|
71
|
+
env !== undefined &&
|
|
72
|
+
typeof env.AWS_DEPLOY_ROLE_ARN === 'string' &&
|
|
73
|
+
typeof step.run === 'string' &&
|
|
74
|
+
step.run.includes('exit 1')
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
expect(hasGuard).toBe(true);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('asserts the sts get-caller-identity ARN matches the configured role before any CDK call', () => {
|
|
82
|
+
// The "deploy" job gates `cdk deploy`. It must, after
|
|
83
|
+
// configure-aws-credentials and before `pnpm deploy:aws`, run
|
|
84
|
+
// `aws sts get-caller-identity` and reject anything that is not
|
|
85
|
+
// the configured role ARN. This catches silent fallbacks to
|
|
86
|
+
// ambient static credentials.
|
|
87
|
+
const job = readWorkflowJob(WORKFLOW, 'deploy');
|
|
88
|
+
const runSteps = job.steps
|
|
89
|
+
.filter((s) => typeof s.run === 'string')
|
|
90
|
+
.map((s) => (s.run as string).trim());
|
|
91
|
+
const hasStsGuard = runSteps.some(
|
|
92
|
+
(r) => r.includes('aws sts get-caller-identity') && r.includes('AWS_DEPLOY_ROLE_ARN'),
|
|
93
|
+
);
|
|
94
|
+
expect(hasStsGuard).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -3,9 +3,11 @@ import { resolve } from 'node:path';
|
|
|
3
3
|
import { describe, expect, it } from 'vitest';
|
|
4
4
|
import {
|
|
5
5
|
MUTATION_SCORE_THRESHOLD,
|
|
6
|
+
findAwsCredentialSteps,
|
|
6
7
|
readCoverageThresholds,
|
|
7
8
|
readStrykerConfig,
|
|
8
9
|
readWorkflowJob,
|
|
10
|
+
readWorkflowPermissions,
|
|
9
11
|
} from '../src/validate.js';
|
|
10
12
|
|
|
11
13
|
const REPO_ROOT = resolve(import.meta.dirname, '..', '..', '..');
|
|
@@ -174,4 +176,44 @@ describe('validate.ts error paths', () => {
|
|
|
174
176
|
/no job named "verify"/,
|
|
175
177
|
);
|
|
176
178
|
});
|
|
179
|
+
|
|
180
|
+
it('readWorkflowPermissions throws when no top-level permissions block exists', () => {
|
|
181
|
+
expect(() => readWorkflowPermissions('on: push\njobs:\n build: {}\n')).toThrow(
|
|
182
|
+
/no top-level permissions/,
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('readWorkflowPermissions returns the literal string form as a single key', () => {
|
|
187
|
+
// `permissions: read-all` is a valid GitHub Actions shorthand.
|
|
188
|
+
const result = readWorkflowPermissions('on: push\npermissions: read-all\njobs:\n b: {}\n');
|
|
189
|
+
expect(result).toEqual({ 'read-all': 'read-all' });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('findAwsCredentialSteps returns [] when the workflow has no jobs', () => {
|
|
193
|
+
expect(findAwsCredentialSteps('on: push\n')).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('findAwsCredentialSteps returns an empty with-block when the step has no with:', () => {
|
|
197
|
+
// Job has a `configure-aws-credentials` step with no `with:` and a
|
|
198
|
+
// sibling `run:` step (which has a non-string `uses` and must be
|
|
199
|
+
// skipped silently).
|
|
200
|
+
const yaml = [
|
|
201
|
+
'on: push',
|
|
202
|
+
'jobs:',
|
|
203
|
+
' build:',
|
|
204
|
+
' steps:',
|
|
205
|
+
' - run: echo hi',
|
|
206
|
+
' - uses: aws-actions/configure-aws-credentials@v4',
|
|
207
|
+
'',
|
|
208
|
+
].join('\n');
|
|
209
|
+
const result = findAwsCredentialSteps(yaml);
|
|
210
|
+
expect(result).toEqual([{ job: 'build', index: 1, with: {} }]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('findAwsCredentialSteps tolerates a job with no steps key', () => {
|
|
214
|
+
// A job literal `{}` (no `steps:`) must not throw — it just yields
|
|
215
|
+
// no credential steps.
|
|
216
|
+
const yaml = 'on: push\njobs:\n empty: {}\n';
|
|
217
|
+
expect(findAwsCredentialSteps(yaml)).toEqual([]);
|
|
218
|
+
});
|
|
177
219
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/load-test",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Synthetic WebSocket IRC client farm for load testing ServerlessIRCd (concurrency, latency p50/p95/p99, drop rate)",
|
|
6
6
|
"license": "BSD-3-Clause",
|