hecks 2.6.0 → 2.7.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.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/google_authentication.rb +1 -1
- data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
- data/lib/hecks/adapters/driven/prism.rb +1 -1
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
- data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/world_builder.rb +42 -3
- data/lib/hecks/bluebook/hexagon.rb +15 -7
- data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
- data/lib/hecks/bluebook/model_check.rb +1 -1
- data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
- data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
- data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
- data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
- data/lib/hecks/embryonaut_bluebook.rb +29 -3
- data/lib/hecks/forms/value_object_shape.rb +1 -1
- data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
- data/lib/hecks/fuzzing/era_boundary.rb +1 -1
- data/lib/hecks/fuzzing/isolated_boot.rb +59 -5
- data/lib/hecks/language/world/world.bluebook +32 -2
- data/lib/hecks/ports/key_vault.rb +1 -1
- data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +2 -1
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
- data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
- data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
- data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
- data/lib/hecks/ports/persistence.rb +4 -0
- data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
- data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
- data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
- data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
- data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
- data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
- data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
- data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
- data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
- data/lib/hecks/projections/deploy/fargate.rb +187 -138
- data/lib/hecks/projections/deploy/lambda.rb +5 -5
- data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
- data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
- data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
- data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
- data/lib/hecks/projections/deploy/preview/script.rb +40 -0
- data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
- data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
- data/lib/hecks/projections/deploy/preview/template.rb +247 -0
- data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
- data/lib/hecks/projections/deploy/preview.rb +103 -0
- data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
- data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
- data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
- data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
- data/lib/hecks/projections/deploy/scripts.rb +171 -0
- data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
- data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
- data/lib/hecks/projections/deploy/smoke.rb +159 -0
- data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
- data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
- data/lib/hecks/projections/deploy/template_diff.rb +173 -0
- data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
- data/lib/hecks/projections.rb +2 -0
- data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
- data/lib/hecks/release/gem_pin.rb +112 -0
- data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
- data/lib/hecks/runtime/loader.rb +26 -2
- data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
- data/lib/hecks/runtime/registry/verification.rb +4 -2
- data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
- data/lib/hecks/runtime/registry.rb +14 -9
- data/lib/hecks/runtime/tenant_check.rb +1 -1
- data/lib/hecks/vendoring/git_environment.rb +35 -0
- data/lib/hecks/vendoring/git_source.rb +102 -0
- data/lib/hecks/vendoring.rb +102 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks.rb +2 -1
- metadata +45 -1
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// A client-independent smoke harness for a deployed Hecks stack.
|
|
5
|
+
//
|
|
6
|
+
// Emitted by `Hecks::Projections::Deploy::Smoke` when a domain's
|
|
7
|
+
// `deployed_to` block opts in with `smoke true`. It owns what every stack
|
|
8
|
+
// needs and nothing about any one site: the check runner and its summary,
|
|
9
|
+
// plain HTTP helpers, HMAC signed claims and session cookies, sandbox guest
|
|
10
|
+
// addresses, the expected-era check against `GET /version`, and the sweep
|
|
11
|
+
// that takes a run's own rows (and stale leftovers) back out. What a page
|
|
12
|
+
// or flow should answer stays in the client's own config module.
|
|
13
|
+
//
|
|
14
|
+
// node smoke/harness.js smoke/config.js
|
|
15
|
+
//
|
|
16
|
+
// ## The config module
|
|
17
|
+
//
|
|
18
|
+
// A CommonJS module exporting an object:
|
|
19
|
+
//
|
|
20
|
+
// siteUrl Base URL; `SMOKE_SITE_URL` overrides it. Required.
|
|
21
|
+
// cookieName Session cookie the site reads. Required.
|
|
22
|
+
// secretEnv Env var holding the cookie-signing secret (default `SESSION_SECRET`).
|
|
23
|
+
// linkSecretEnv Optional env var for a separate signing secret for emailed links;
|
|
24
|
+
// when it is set, links are signed with it as-is instead of with
|
|
25
|
+
// the per-purpose key `<purpose>:<secret>`.
|
|
26
|
+
// adminEmail Address minted into session cookies; `SMOKE_ADMIN_EMAIL` overrides.
|
|
27
|
+
// preflightPath Path that must answer 200 before anything runs (default `/`).
|
|
28
|
+
// era `{ path, file, authenticated }` turns on the era check. `path` is
|
|
29
|
+
// where the site relays the domain's `GET /version` (default
|
|
30
|
+
// `/version`); `file` is the allow-list (see below); `authenticated`
|
|
31
|
+
// sends a session cookie for `adminEmail`.
|
|
32
|
+
// sandbox `{ localPart, domain, patterns }` shapes the safe-mode guest
|
|
33
|
+
// addresses; `patterns` are extra regexps that also count as smoke rows.
|
|
34
|
+
// baseDir Directory `era.file` resolves against (the CLI sets it to the
|
|
35
|
+
// config module's own directory).
|
|
36
|
+
// before(ctx) Optional; runs first, for example to launch a browser.
|
|
37
|
+
// checks Array of `async (ctx) => {}`; each calls `ctx.check(label, fn)`.
|
|
38
|
+
// sweeps Array of `{ label, run(ctx) }`; run after every check, even when
|
|
39
|
+
// checks failed, so a run cleans up after itself. `run` may return
|
|
40
|
+
// `{ skipped }`.
|
|
41
|
+
// after(ctx) Optional; runs last, for example to close a browser.
|
|
42
|
+
//
|
|
43
|
+
// Every check runs even when an earlier one fails, so one run surfaces
|
|
44
|
+
// everything that is wrong. The process exits 1 when any check or sweep failed.
|
|
45
|
+
//
|
|
46
|
+
// ## SMOKE_MODE
|
|
47
|
+
//
|
|
48
|
+
// `safe` (the default) tags every guest address with a sandbox mailbox that
|
|
49
|
+
// accepts mail and never reaches an inbox, so a run against production is
|
|
50
|
+
// recognisable and never mails a real person. `full` is for a throwaway
|
|
51
|
+
// database and uses plain example.com addresses.
|
|
52
|
+
//
|
|
53
|
+
// ## The era allow-list
|
|
54
|
+
//
|
|
55
|
+
// `GET /version` reports the era the domain booted on. `era.file` lists the
|
|
56
|
+
// eras allowed to be live, one id per line; blank lines and lines starting
|
|
57
|
+
// with `#` are ignored. `SMOKE_EXPECT_ERA` (comma-separated) replaces the
|
|
58
|
+
// file. With no eras listed the check only requires that an era is reported.
|
|
59
|
+
// A rollback or an unplanned roll fails here.
|
|
60
|
+
|
|
61
|
+
const crypto = require("crypto");
|
|
62
|
+
const fs = require("fs");
|
|
63
|
+
const http = require("http");
|
|
64
|
+
const https = require("https");
|
|
65
|
+
const path = require("path");
|
|
66
|
+
|
|
67
|
+
const REQUEST_TIMEOUT_MS = 8000;
|
|
68
|
+
const STALE_MS = 30 * 60 * 1000;
|
|
69
|
+
const SWEEP_LIMIT = 300;
|
|
70
|
+
const REFUSAL_STATUSES = [301, 302, 303, 307, 308, 401, 403];
|
|
71
|
+
|
|
72
|
+
// One request, resolved with the status, headers and body text; redirects are
|
|
73
|
+
// reported, never followed. The module is picked from the URL's scheme, because
|
|
74
|
+
// node's `http` throws on an `https://` URL.
|
|
75
|
+
function request(method, url, { headers = {}, body = null, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
|
|
76
|
+
const client = url.startsWith("https://") ? https : http;
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const req = client.request(url, { method, headers }, (res) => {
|
|
79
|
+
const chunks = [];
|
|
80
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
81
|
+
res.on("end", () =>
|
|
82
|
+
resolve({
|
|
83
|
+
status: res.statusCode,
|
|
84
|
+
location: res.headers.location || "",
|
|
85
|
+
headers: res.headers,
|
|
86
|
+
body: Buffer.concat(chunks).toString("utf8"),
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
req.on("error", reject);
|
|
91
|
+
req.setTimeout(timeoutMs, () => req.destroy(new Error(`timeout on ${method} ${url}`)));
|
|
92
|
+
req.end(body);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const httpGet = (url, headers = {}, options = {}) => request("GET", url, { headers, ...options });
|
|
97
|
+
const httpRequest = (method, url, headers = {}, body = null, options = {}) =>
|
|
98
|
+
request(method, url, { headers, body, ...options });
|
|
99
|
+
|
|
100
|
+
// A claim signed the way a Hecks host signs its own tokens: base64url JSON,
|
|
101
|
+
// then the hex HMAC-SHA256 of that string, joined with a dot.
|
|
102
|
+
function signedClaim(secret, claims, ttlSecs) {
|
|
103
|
+
const payload = { ...claims, exp: Math.floor(Date.now() / 1000) + ttlSecs };
|
|
104
|
+
const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
105
|
+
const signature = crypto.createHmac("sha256", secret).update(encoded).digest("hex");
|
|
106
|
+
return `${encoded}.${signature}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const accountToken = (secret, email, ttlSecs = 3600) => signedClaim(secret, { email }, ttlSecs);
|
|
110
|
+
|
|
111
|
+
// The same token with its last signature digit changed, for proving a host
|
|
112
|
+
// verifies the signature and not just the token's shape.
|
|
113
|
+
function alteredSignature(token) {
|
|
114
|
+
const [encoded, signature] = token.split(".");
|
|
115
|
+
const last = signature.endsWith("0") ? "1" : "0";
|
|
116
|
+
return `${encoded}.${signature.slice(0, -1)}${last}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
120
|
+
|
|
121
|
+
function parseEraList(text) {
|
|
122
|
+
return text
|
|
123
|
+
.split("\n")
|
|
124
|
+
.map((line) => line.trim())
|
|
125
|
+
.filter((line) => line && !line.startsWith("#"));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The eras the domain may be running: `SMOKE_EXPECT_ERA` when set, otherwise the
|
|
129
|
+
// file's ids. An unreadable or absent file means "do not compare".
|
|
130
|
+
function expectedEras(env, file) {
|
|
131
|
+
if (env.SMOKE_EXPECT_ERA !== undefined) {
|
|
132
|
+
return env.SMOKE_EXPECT_ERA.split(",").map((era) => era.trim()).filter(Boolean);
|
|
133
|
+
}
|
|
134
|
+
if (!file) return [];
|
|
135
|
+
try {
|
|
136
|
+
return parseEraList(fs.readFileSync(file, "utf8"));
|
|
137
|
+
} catch {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The start time a run id carries (`smoke-<pid>-<ms>`), or null for any other form.
|
|
143
|
+
function runStartedAt(text) {
|
|
144
|
+
const match = /smoke-\d+-(\d{10,})/.exec(text || "");
|
|
145
|
+
return match ? Number(match[1]) : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A run id with no readable start time counts as stale: it cannot belong to a
|
|
149
|
+
// run that is still going.
|
|
150
|
+
function isStaleRun(text, now = Date.now()) {
|
|
151
|
+
const at = runStartedAt(text);
|
|
152
|
+
return at === null || now - at > STALE_MS;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function readSettings(config, env) {
|
|
156
|
+
if (!config || typeof config !== "object") throw new Error("the smoke config must export an object");
|
|
157
|
+
const siteUrl = env.SMOKE_SITE_URL || config.siteUrl;
|
|
158
|
+
if (!siteUrl) throw new Error("no site url: set siteUrl in the config or SMOKE_SITE_URL");
|
|
159
|
+
if (!config.cookieName) throw new Error("the smoke config needs a cookieName");
|
|
160
|
+
const mode = env.SMOKE_MODE || "safe";
|
|
161
|
+
if (!["safe", "full"].includes(mode)) throw new Error(`SMOKE_MODE must be safe or full, not ${mode}`);
|
|
162
|
+
return { siteUrl: siteUrl.replace(/\/+$/, ""), mode };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function createHarness(config, { env = process.env, out = (text) => process.stdout.write(text) } = {}) {
|
|
166
|
+
const { siteUrl, mode } = readSettings(config, env);
|
|
167
|
+
const secret = env[config.secretEnv || "SESSION_SECRET"] || "";
|
|
168
|
+
const linkSecret = (config.linkSecretEnv && env[config.linkSecretEnv]) || secret;
|
|
169
|
+
const adminEmail = env.SMOKE_ADMIN_EMAIL || config.adminEmail || "admin@example.com";
|
|
170
|
+
// The default mailbox is an email service's sandbox address: it reports the mail as
|
|
171
|
+
// delivered and never reaches an inbox, so it does not count against a sending domain.
|
|
172
|
+
const sandbox = { localPart: "delivered", domain: "resend.dev", patterns: [], ...(config.sandbox || {}) };
|
|
173
|
+
const baseDir = config.baseDir || process.cwd();
|
|
174
|
+
const runId = `smoke-${process.pid}-${Date.now()}`;
|
|
175
|
+
const failures = [];
|
|
176
|
+
|
|
177
|
+
// Runs one named check. A throw fails the check; `{ skipped }` reports a check
|
|
178
|
+
// that does not apply to this deployment yet.
|
|
179
|
+
async function check(label, fn) {
|
|
180
|
+
out(` ${label}... `);
|
|
181
|
+
try {
|
|
182
|
+
const result = await fn();
|
|
183
|
+
out(result && result.skipped ? `skipped (${result.skipped})\n` : "ok\n");
|
|
184
|
+
} catch (err) {
|
|
185
|
+
out(`FAILED: ${err.message}\n`);
|
|
186
|
+
failures.push(label);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const guestEmail = (tag) =>
|
|
191
|
+
mode === "safe"
|
|
192
|
+
? `${sandbox.localPart}+${runId}-${tag}@${sandbox.domain}`
|
|
193
|
+
: `${runId}-${tag}@example.com`;
|
|
194
|
+
|
|
195
|
+
const smokeEmailPatterns = () => [
|
|
196
|
+
new RegExp(`^${escapeRegExp(sandbox.localPart)}\\+smoke-.+@${escapeRegExp(sandbox.domain)}$`, "i"),
|
|
197
|
+
...sandbox.patterns,
|
|
198
|
+
];
|
|
199
|
+
const isSmokeEmail = (email) => smokeEmailPatterns().some((pattern) => pattern.test(email || ""));
|
|
200
|
+
|
|
201
|
+
const sessionToken = (email = adminEmail, ttlSecs = 3600) => accountToken(secret, email, ttlSecs);
|
|
202
|
+
const cookieHeader = (value) => `${config.cookieName}=${value}`;
|
|
203
|
+
const sessionCookie = (email = adminEmail, ttlSecs = 3600) => cookieHeader(sessionToken(email, ttlSecs));
|
|
204
|
+
|
|
205
|
+
// A link's signing key is per purpose, so a link minted for one flow cannot be
|
|
206
|
+
// replayed for another, unless a separate link secret was configured.
|
|
207
|
+
const linkKey = (purpose) => (config.linkSecretEnv && env[config.linkSecretEnv] ? linkSecret : `${purpose}:${linkSecret}`);
|
|
208
|
+
const signedLink = (purpose, claims, ttlSecs = 600) => signedClaim(linkKey(purpose), { purpose, ...claims }, ttlSecs);
|
|
209
|
+
|
|
210
|
+
const url = (pathname) => `${siteUrl}${pathname}`;
|
|
211
|
+
|
|
212
|
+
// Reads the era off the site's relay of `GET /version` and compares it with
|
|
213
|
+
// the allow-list; two reads a moment apart must agree, since differing answers
|
|
214
|
+
// mean two hosts.
|
|
215
|
+
async function checkEra() {
|
|
216
|
+
const era = config.era || {};
|
|
217
|
+
const target = url(era.path || "/version");
|
|
218
|
+
let headers = {};
|
|
219
|
+
if (era.authenticated) {
|
|
220
|
+
if (!secret) return { skipped: "no signing secret to authenticate with" };
|
|
221
|
+
headers = { Cookie: sessionCookie() };
|
|
222
|
+
}
|
|
223
|
+
const res = await httpGet(target, headers);
|
|
224
|
+
if (res.status === 404) return { skipped: "the deployment has no /version route yet" };
|
|
225
|
+
if (res.status !== 200) throw new Error(`HTTP ${res.status}: ${res.body.slice(0, 200)}`);
|
|
226
|
+
const version = JSON.parse(res.body);
|
|
227
|
+
if (typeof version.era !== "string" || !version.era) throw new Error(`no era in ${res.body.slice(0, 200)}`);
|
|
228
|
+
const allowed = expectedEras(env, era.file && path.resolve(baseDir, era.file));
|
|
229
|
+
if (allowed.length && !allowed.includes(version.era)) {
|
|
230
|
+
throw new Error(`the domain is on era ${version.era} (build ${version.build}); expected one of ${allowed.join(", ")}`);
|
|
231
|
+
}
|
|
232
|
+
const again = JSON.parse((await httpGet(target, headers)).body);
|
|
233
|
+
if (again.era !== version.era) throw new Error(`the era changed between two reads: ${version.era} then ${again.era}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Proves a protected page refuses cookies it must not honour: signed with the
|
|
237
|
+
// wrong secret, not a token at all, expired, and correctly signed but altered.
|
|
238
|
+
// The last two need the real secret and are skipped without one.
|
|
239
|
+
async function forgedCookieChecks({ path: pathname, refused = (res) => REFUSAL_STATUSES.includes(res.status), what = "open the page" }) {
|
|
240
|
+
const cases = [
|
|
241
|
+
["a cookie signed with the wrong secret", () => accountToken(`not-the-secret-${runId}`, adminEmail)],
|
|
242
|
+
["a cookie that is not a token at all", () => "garbage.value"],
|
|
243
|
+
["an expired session cookie", () => secret && accountToken(secret, adminEmail, -60)],
|
|
244
|
+
["a session cookie whose signature was altered", () => secret && alteredSignature(sessionToken())],
|
|
245
|
+
];
|
|
246
|
+
for (const [label, mint] of cases) {
|
|
247
|
+
await check(`${label} does not ${what}`, async () => {
|
|
248
|
+
const value = mint();
|
|
249
|
+
if (!value) return { skipped: "no signing secret" };
|
|
250
|
+
const res = await httpGet(url(pathname), { Cookie: cookieHeader(value) });
|
|
251
|
+
if (!refused(res)) throw new Error(`answered HTTP ${res.status} ${JSON.stringify(res.location)}`);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Takes a run's own rows, and in safe mode stale leftovers from a run that died
|
|
257
|
+
// before cleaning up, back out through the caller's own controls. Own rows go
|
|
258
|
+
// first so a run that stops at the limit has still cleaned up after itself. A
|
|
259
|
+
// leftover must be older than STALE_MS so two overlapping runs never remove
|
|
260
|
+
// each other's rows mid-check. Rows carry an `id`; `list` answers only rows still present.
|
|
261
|
+
async function sweepRows({ list, remove, isOwn, isLeftover = () => false, limit = SWEEP_LIMIT, describe = (row) => row.label || row.id }) {
|
|
262
|
+
const wanted = (row) => isOwn(row) || (mode === "safe" && isLeftover(row));
|
|
263
|
+
let rows = await list();
|
|
264
|
+
let removed = 0;
|
|
265
|
+
for (;;) {
|
|
266
|
+
const candidates = rows.filter(wanted);
|
|
267
|
+
const next = candidates.find(isOwn) || candidates[0];
|
|
268
|
+
if (!next) break;
|
|
269
|
+
const result = await remove(next);
|
|
270
|
+
if (result && result.skipped) return { removed, skipped: result.skipped };
|
|
271
|
+
removed += 1;
|
|
272
|
+
rows = await list();
|
|
273
|
+
if (rows.some((row) => row.id === next.id)) {
|
|
274
|
+
throw new Error(`removed ${describe(next)} but a fresh listing still has it`);
|
|
275
|
+
}
|
|
276
|
+
if (removed >= limit) break;
|
|
277
|
+
}
|
|
278
|
+
const left = rows.filter(isOwn).map(describe);
|
|
279
|
+
if (left.length) throw new Error(`this run's rows are still present after the sweep: ${left.join(", ")}`);
|
|
280
|
+
return { removed };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const context = {
|
|
284
|
+
siteUrl, mode, runId, secret, adminEmail, failures, check, url,
|
|
285
|
+
httpGet, httpRequest, signedClaim, accountToken, alteredSignature,
|
|
286
|
+
guestEmail, isSmokeEmail, isStaleRun,
|
|
287
|
+
sessionToken, sessionCookie, cookieHeader, signedLink, linkKey,
|
|
288
|
+
checkEra, forgedCookieChecks, sweepRows,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
async function runStep(label, fn) {
|
|
292
|
+
try {
|
|
293
|
+
await fn(context);
|
|
294
|
+
} catch (err) {
|
|
295
|
+
out(` ${label}... FAILED: ${err.message}\n`);
|
|
296
|
+
failures.push(label);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function preflight() {
|
|
301
|
+
const res = await httpGet(url(config.preflightPath || "/"));
|
|
302
|
+
if (res.status !== 200) throw new Error(`site not reachable at ${siteUrl} (HTTP ${res.status})`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Runs everything and answers the exit code: 0 when every check and sweep
|
|
306
|
+
// passed, 1 otherwise. A site that cannot be reached at all is one failure,
|
|
307
|
+
// not a screenful.
|
|
308
|
+
async function run() {
|
|
309
|
+
out(`== ${config.title || "smoke"} against ${siteUrl} (${mode}) ==\n\n`);
|
|
310
|
+
try {
|
|
311
|
+
await preflight();
|
|
312
|
+
} catch (err) {
|
|
313
|
+
out(`${err.message}\n`);
|
|
314
|
+
return 1;
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
if (config.before) await runStep("before", config.before);
|
|
318
|
+
for (const [index, checks] of (config.checks || []).entries()) await runStep(`checks[${index}]`, checks);
|
|
319
|
+
if (config.era) {
|
|
320
|
+
out("\n== Domain version ==\n");
|
|
321
|
+
await check("the domain reports the era it is running, and it is an expected one", checkEra);
|
|
322
|
+
}
|
|
323
|
+
} finally {
|
|
324
|
+
if ((config.sweeps || []).length) out("\n== Cleanup ==\n");
|
|
325
|
+
for (const sweep of config.sweeps || []) {
|
|
326
|
+
await check(sweep.label, () => sweep.run(context));
|
|
327
|
+
}
|
|
328
|
+
if (config.after) await runStep("after", config.after);
|
|
329
|
+
}
|
|
330
|
+
return summarize();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function summarize() {
|
|
334
|
+
if (failures.length === 0) {
|
|
335
|
+
out("\nSMOKE TEST PASSED\n");
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
out(`\nSMOKE TEST FAILED (${failures.length}):\n`);
|
|
339
|
+
for (const failure of failures) out(` - ${failure}\n`);
|
|
340
|
+
return 1;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return { ...context, run };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
module.exports = {
|
|
347
|
+
createHarness, request, httpGet, httpRequest, signedClaim, accountToken, alteredSignature,
|
|
348
|
+
parseEraList, expectedEras, runStartedAt, isStaleRun, STALE_MS, SWEEP_LIMIT,
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
if (require.main === module) {
|
|
352
|
+
const configPath = process.argv[2] || process.env.SMOKE_CONFIG;
|
|
353
|
+
if (!configPath) {
|
|
354
|
+
console.error("usage: node harness.js <config module> (or set SMOKE_CONFIG)");
|
|
355
|
+
process.exit(2);
|
|
356
|
+
}
|
|
357
|
+
const resolved = path.resolve(configPath);
|
|
358
|
+
const config = { baseDir: path.dirname(resolved), ...require(resolved) };
|
|
359
|
+
createHarness(config)
|
|
360
|
+
.run()
|
|
361
|
+
.then((code) => process.exit(code))
|
|
362
|
+
.catch((err) => {
|
|
363
|
+
console.error(err);
|
|
364
|
+
process.exit(1);
|
|
365
|
+
});
|
|
366
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Runs the smoke harness against the live site on a schedule and on demand.
|
|
2
|
+
#
|
|
3
|
+
# Emitted by `Hecks::Projections::Deploy::Smoke`; copy it to `.github/workflows/`.
|
|
4
|
+
# The run is safe on production by design: the harness's `SMOKE_MODE=safe` tags every
|
|
5
|
+
# guest address with a sandbox mailbox, so no inbox is ever read or mailed.
|
|
6
|
+
#
|
|
7
|
+
# AWS access is by OIDC, with no long-lived keys. GitHub's own token is exchanged for
|
|
8
|
+
# temporary credentials for a role whose trust policy names only this repository, and
|
|
9
|
+
# whose one permission is `secretsmanager:GetSecretValue` on the single secret below.
|
|
10
|
+
# The role is created once, by hand, outside this stack.
|
|
11
|
+
name: '@@name@@'
|
|
12
|
+
|
|
13
|
+
on:
|
|
14
|
+
workflow_dispatch: {}
|
|
15
|
+
schedule:
|
|
16
|
+
- cron: '@@schedule@@'
|
|
17
|
+
|
|
18
|
+
permissions:
|
|
19
|
+
id-token: write # the OIDC token exchange below
|
|
20
|
+
contents: read
|
|
21
|
+
|
|
22
|
+
jobs:
|
|
23
|
+
smoke:
|
|
24
|
+
runs-on: ubuntu-latest
|
|
25
|
+
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v4
|
|
28
|
+
|
|
29
|
+
- uses: actions/setup-node@v4
|
|
30
|
+
with:
|
|
31
|
+
node-version: '22'
|
|
32
|
+
|
|
33
|
+
@@setup@@
|
|
34
|
+
- name: Configure AWS credentials (OIDC, no long-lived keys)
|
|
35
|
+
uses: aws-actions/configure-aws-credentials@v4
|
|
36
|
+
with:
|
|
37
|
+
role-to-assume: '@@role_arn@@'
|
|
38
|
+
aws-region: '@@region@@'
|
|
39
|
+
|
|
40
|
+
- name: Fetch the signing secret
|
|
41
|
+
id: secret
|
|
42
|
+
run: |
|
|
43
|
+
SECRET=$(aws secretsmanager get-secret-value \
|
|
44
|
+
--secret-id '@@secret_id@@' \
|
|
45
|
+
--query SecretString --output text@@secret_field_pipe@@)
|
|
46
|
+
# Masked in every later log line; the narrow IAM policy is what keeps
|
|
47
|
+
# the secret contained, this only keeps it out of the logs.
|
|
48
|
+
echo "::add-mask::$SECRET"
|
|
49
|
+
echo "value=$SECRET" >> "$GITHUB_OUTPUT"
|
|
50
|
+
|
|
51
|
+
- name: Run the smoke test
|
|
52
|
+
env:
|
|
53
|
+
SMOKE_MODE: safe
|
|
54
|
+
SMOKE_SITE_URL: '@@site_url@@'
|
|
55
|
+
@@secret_env@@: ${{ steps.secret.outputs.value }}
|
|
56
|
+
run: node @@harness@@ @@config@@
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Projections
|
|
3
|
+
module Deploy
|
|
4
|
+
# The smoke-test files a deployed stack can opt in to: a client-independent
|
|
5
|
+
# JavaScript harness and a GitHub Actions workflow that runs it on a schedule.
|
|
6
|
+
#
|
|
7
|
+
# Not a registered `Projector::Target` of its own. It adds files to whatever a
|
|
8
|
+
# deploy target already produced, so `bin/project_deploy` merges what `files`
|
|
9
|
+
# returns into the artifact, and a stack that has not opted in gets nothing.
|
|
10
|
+
#
|
|
11
|
+
# ## Opting in
|
|
12
|
+
#
|
|
13
|
+
# In the domain's `.world`, inside its `deployed_to` block:
|
|
14
|
+
#
|
|
15
|
+
# deployed_to("AwsFargate") do
|
|
16
|
+
# smoke true
|
|
17
|
+
# smoke_role_arn "arn:aws:iam::123456789012:role/example-smoke"
|
|
18
|
+
# smoke_secret_id "example-stack/session-secret"
|
|
19
|
+
# smoke_site_url "https://example.org"
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# `smoke_secret_field` names a JSON field when the secret is a JSON document,
|
|
23
|
+
# `smoke_secret_env` names the variable the harness reads it from (default
|
|
24
|
+
# `SESSION_SECRET`), `smoke_schedule` is a cron expression (default every 15
|
|
25
|
+
# minutes), `smoke_harness` and `smoke_config` are the repository-relative paths
|
|
26
|
+
# the workflow runs (defaults `smoke/harness.js` and `smoke/config.js`), and
|
|
27
|
+
# `smoke_setup` is one shell command run before the AWS steps, such as an
|
|
28
|
+
# install step the client's checks need.
|
|
29
|
+
#
|
|
30
|
+
# ## What stays with the client
|
|
31
|
+
#
|
|
32
|
+
# The harness knows nothing about any site. The pages and flows to assert, the
|
|
33
|
+
# cookie name, and the expected-era file are the client's own `config.js`, which
|
|
34
|
+
# this never writes.
|
|
35
|
+
module Smoke
|
|
36
|
+
TEMPLATES = File.expand_path("smoke", __dir__).freeze
|
|
37
|
+
DEFAULTS = {
|
|
38
|
+
secret_env: "SESSION_SECRET",
|
|
39
|
+
schedule: "*/15 * * * *",
|
|
40
|
+
harness: "smoke/harness.js",
|
|
41
|
+
config: "smoke/config.js"
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
# Each value is checked against the shape it is spliced into, so a setting
|
|
45
|
+
# can never break out of the YAML or shell line it lands on.
|
|
46
|
+
PATTERNS = {
|
|
47
|
+
role_arn: %r{\Aarn:aws:iam::\d{12}:role/[\w+=,.@/-]+\z},
|
|
48
|
+
secret_id: %r{\A[\w/+=.@-]+\z},
|
|
49
|
+
secret_field: /\A\w+\z/,
|
|
50
|
+
secret_env: /\A[A-Z_][A-Z0-9_]*\z/,
|
|
51
|
+
site_url: %r{\Ahttps?://[\w.-]+(:\d+)?(/[\w./-]*)?\z},
|
|
52
|
+
schedule: /\A(\S+ ){4}\S+\z/,
|
|
53
|
+
harness: %r{\A[\w./-]+\z},
|
|
54
|
+
config: %r{\A[\w./-]+\z},
|
|
55
|
+
region: /\A[a-z]{2}(-[a-z]+)+-\d\z/,
|
|
56
|
+
setup: /\A[^\r\n]+\z/
|
|
57
|
+
}.freeze
|
|
58
|
+
REQUIRED = %i[role_arn secret_id site_url region].freeze
|
|
59
|
+
|
|
60
|
+
module_function
|
|
61
|
+
|
|
62
|
+
# Whether the domain's `deployed_to` settings ask for the smoke files.
|
|
63
|
+
#
|
|
64
|
+
# @param deploy_settings [Hash{Symbol => Object}] the `deployed_to` block's settings
|
|
65
|
+
# @return [Boolean] true only when `smoke true` was declared
|
|
66
|
+
def enabled?(deploy_settings)
|
|
67
|
+
deploy_settings[:smoke] == true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The files to add to a deploy artifact: the harness, and the workflow rendered
|
|
71
|
+
# from the domain's settings. Empty unless the domain opted in.
|
|
72
|
+
#
|
|
73
|
+
# @param deploy_settings [Hash{Symbol => Object}] the `deployed_to` block's settings;
|
|
74
|
+
# `:region` and the `smoke_*` settings are read
|
|
75
|
+
# @param stack_name [String] names the workflow
|
|
76
|
+
# @return [Hash{String => String}] `"smoke/harness.js"` and `"smoke/workflow.yml"`,
|
|
77
|
+
# or `{}` when the domain did not opt in
|
|
78
|
+
# @raise [ArgumentError] if a required setting is missing or a value is not in the
|
|
79
|
+
# shape its place in the workflow allows
|
|
80
|
+
def files(deploy_settings, stack_name:)
|
|
81
|
+
return {} unless enabled?(deploy_settings)
|
|
82
|
+
|
|
83
|
+
values = resolve(deploy_settings).merge(name: "#{stack_name} smoke test")
|
|
84
|
+
{
|
|
85
|
+
"smoke/harness.js" => File.read(File.join(TEMPLATES, "harness.js")),
|
|
86
|
+
"smoke/workflow.yml" => render(File.read(File.join(TEMPLATES, "workflow.yml.tmpl")), values)
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Applies the defaults and validates every value.
|
|
91
|
+
#
|
|
92
|
+
# @param deploy_settings [Hash{Symbol => Object}] the `deployed_to` block's settings
|
|
93
|
+
# @return [Hash{Symbol => String, nil}] the template values, keyed by placeholder name
|
|
94
|
+
# @raise [ArgumentError] if a required setting is missing or a value does not match its
|
|
95
|
+
# pattern
|
|
96
|
+
def resolve(deploy_settings)
|
|
97
|
+
missing = REQUIRED.reject { |name| deploy_settings[setting_key(name)] }
|
|
98
|
+
unless missing.empty?
|
|
99
|
+
raise ArgumentError, "smoke true needs #{missing.map { |name| setting_key(name) }.join(', ')} " \
|
|
100
|
+
"in the deployed_to block; the workflow cannot be rendered without them"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
picked = PATTERNS.keys.to_h { |name| [name, deploy_settings.fetch(setting_key(name), DEFAULTS[name])] }
|
|
104
|
+
PATTERNS.each do |name, pattern|
|
|
105
|
+
next if picked[name].nil? || picked[name].to_s.match?(pattern)
|
|
106
|
+
|
|
107
|
+
raise ArgumentError, "#{setting_key(name)} #{picked[name].inspect} is not a value the smoke workflow can carry"
|
|
108
|
+
end
|
|
109
|
+
picked
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The `deployed_to` setting that supplies a template value. The region is the
|
|
113
|
+
# deploy target's own; every other value has a `smoke_` prefix.
|
|
114
|
+
#
|
|
115
|
+
# @param name [Symbol] a template value name, such as `:role_arn`
|
|
116
|
+
# @return [Symbol] the setting key, such as `:smoke_role_arn`
|
|
117
|
+
def setting_key(name)
|
|
118
|
+
name == :region ? :region : :"smoke_#{name}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Fills the workflow template. A placeholder line that resolves to nothing
|
|
122
|
+
# disappears whole rather than leaving a blank.
|
|
123
|
+
#
|
|
124
|
+
# @param template [String] the workflow template text
|
|
125
|
+
# @param values [Hash{Symbol => String, nil}] placeholder values, plus `:name`
|
|
126
|
+
# @return [String] the rendered workflow
|
|
127
|
+
# @raise [ArgumentError] if a placeholder is left unfilled
|
|
128
|
+
def render(template, values)
|
|
129
|
+
filled = template.gsub(/^@@setup@@\n/) { setup_step(values[:setup]) }
|
|
130
|
+
filled = filled.gsub("@@secret_field_pipe@@") { secret_field_pipe(values[:secret_field]) }
|
|
131
|
+
values.except(:setup, :secret_field).each { |key, value| filled = filled.gsub("@@#{key}@@") { value.to_s } }
|
|
132
|
+
raise ArgumentError, "unfilled placeholder in the smoke workflow" if filled.include?("@@")
|
|
133
|
+
|
|
134
|
+
filled
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Renders the optional setup step, quoting the command for YAML.
|
|
138
|
+
#
|
|
139
|
+
# @param command [String, nil] the shell command from `smoke_setup`
|
|
140
|
+
# @return [String] a workflow step running it, or nothing
|
|
141
|
+
def setup_step(command)
|
|
142
|
+
return "" if command.nil?
|
|
143
|
+
|
|
144
|
+
" - name: Set up the smoke run\n run: '#{command.gsub("'", "''")}'\n\n"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Builds the pipe that pulls one field out of a JSON secret document.
|
|
148
|
+
#
|
|
149
|
+
# @param field [String, nil] the JSON field of the secret document
|
|
150
|
+
# @return [String] the pipe that extracts it, or nothing when the secret is plain text
|
|
151
|
+
def secret_field_pipe(field)
|
|
152
|
+
return "" if field.nil?
|
|
153
|
+
|
|
154
|
+
" | node -e 'process.stdout.write(JSON.parse(require(\"fs\").readFileSync(0)).#{field})'"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|