@bensandee/tooling 0.66.0 → 0.68.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/README.md +10 -0
- package/dist/bin.mjs +1357 -290
- package/dist/index.d.mts +145 -1
- package/package.json +2 -6
- package/tooling.schema.json +6 -1
- package/dist/docker-check/index.d.mts +0 -299
- package/dist/docker-check/index.mjs +0 -2
- package/dist/project-name-CNaAmafB.mjs +0 -612
- package/dist/types-C6cLSf_a.d.mts +0 -146
|
@@ -1,612 +0,0 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
|
-
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
-
import { execFileSync, spawnSync } from "node:child_process";
|
|
4
|
-
import { z } from "zod";
|
|
5
|
-
import { parse } from "yaml";
|
|
6
|
-
//#region src/utils/debug.ts
|
|
7
|
-
const REDACT_PATTERNS = [/x-access-token:[^\s@]+/g, /Authorization:\s*(?:token|Bearer)\s+\S+/gi];
|
|
8
|
-
const debugOut = (msg) => console.error(msg);
|
|
9
|
-
/** Redact secrets (tokens, credentials) from text before logging. */
|
|
10
|
-
function redact(text) {
|
|
11
|
-
let result = text;
|
|
12
|
-
for (const pattern of REDACT_PATTERNS) result = result.replace(pattern, "[REDACTED]");
|
|
13
|
-
return result;
|
|
14
|
-
}
|
|
15
|
-
/** Check if verbose/debug mode is enabled via environment variables. */
|
|
16
|
-
function isEnvVerbose() {
|
|
17
|
-
return process.env["TOOLING_DEBUG"] === "true";
|
|
18
|
-
}
|
|
19
|
-
/** Log a debug message when verbose mode is enabled. */
|
|
20
|
-
function debug(config, message) {
|
|
21
|
-
if (config.verbose) debugOut(`[debug] ${message}`);
|
|
22
|
-
}
|
|
23
|
-
/** Log the result of an exec call when verbose mode is enabled. */
|
|
24
|
-
function debugExec(config, label, result) {
|
|
25
|
-
if (!config.verbose) return;
|
|
26
|
-
const lines = [`[debug] ${redact(label)} (exit code ${String(result.exitCode)})`];
|
|
27
|
-
if (result.stdout.trim()) lines.push(` stdout: ${redact(result.stdout.trim())}`);
|
|
28
|
-
if (result.stderr.trim()) lines.push(` stderr: ${redact(result.stderr.trim())}`);
|
|
29
|
-
debugOut(lines.join("\n"));
|
|
30
|
-
}
|
|
31
|
-
//#endregion
|
|
32
|
-
//#region src/docker-check/compose-yaml.ts
|
|
33
|
-
/**
|
|
34
|
-
* Parse a Docker Compose file as plain data.
|
|
35
|
-
*
|
|
36
|
-
* Compose defines its own merge tags — `!reset` and `!override` — which control how
|
|
37
|
-
* values combine across layered `-f` files, and they may tag a scalar, a map or a
|
|
38
|
-
* sequence (`ipam: !reset null`, `ports: !override [...]`). `yaml` doesn't know them,
|
|
39
|
-
* and for an unknown tag it does not throw: it calls `process.emitWarning()` with code
|
|
40
|
-
* `TAG_RESOLVE_FAILED` and then falls back to the default schema. That warning reached
|
|
41
|
-
* every `bst repo:sync --check` and `bst docker:check` run in any repo whose overlay
|
|
42
|
-
* used a merge tag — noise in every CI log, from a file bst went looking for itself.
|
|
43
|
-
*
|
|
44
|
-
* `logLevel: "silent"` suppresses those non-fatal warnings. It does not weaken parsing:
|
|
45
|
-
* genuine syntax errors still throw, and a tagged value still resolves to its underlying
|
|
46
|
-
* data. Callers here only read compose as data (service names, images, ports,
|
|
47
|
-
* healthchecks, the top-level `name:`) and never re-serialize it, so the tag semantics
|
|
48
|
-
* are irrelevant to them — which is why silencing beats registering the tags, whose
|
|
49
|
-
* scalar/map/seq forms would need a six-entry matrix to model correctly.
|
|
50
|
-
*
|
|
51
|
-
* Do not drop the option; `test/docker-check/detect.test.ts` and `compose-lint.test.ts`
|
|
52
|
-
* pin it by asserting no process warning is emitted.
|
|
53
|
-
*/
|
|
54
|
-
function parseComposeYaml(content) {
|
|
55
|
-
return parse(content, { logLevel: "silent" });
|
|
56
|
-
}
|
|
57
|
-
//#endregion
|
|
58
|
-
//#region src/docker-check/compose-lint.ts
|
|
59
|
-
const ComposeFileShape = z.looseObject({ name: z.unknown().optional() });
|
|
60
|
-
/**
|
|
61
|
-
* Compose files live by convention in `./` or `docker/`. The lint walks those
|
|
62
|
-
* by default; if `.tooling.json#dockerCheck.composeFiles` overrides the
|
|
63
|
-
* location, each listed file (and its parent dir) is also linted.
|
|
64
|
-
*/
|
|
65
|
-
const COMPOSE_DIR_CANDIDATES = [".", "docker"];
|
|
66
|
-
/** Base compose file names whose project namespace defaults to dir basename. */
|
|
67
|
-
const BASE_COMPOSE_FILES = /* @__PURE__ */ new Set([
|
|
68
|
-
"docker-compose.yml",
|
|
69
|
-
"docker-compose.yaml",
|
|
70
|
-
"compose.yml",
|
|
71
|
-
"compose.yaml"
|
|
72
|
-
]);
|
|
73
|
-
const COMPOSE_FILENAME_PATTERN = /^(?:docker-compose|compose).*\.(?:yml|yaml)$/;
|
|
74
|
-
/**
|
|
75
|
-
* Walk compose files in `targetDir` and report lint violations:
|
|
76
|
-
*
|
|
77
|
-
* - Rule A (`missing-name`): every override compose file must declare a
|
|
78
|
-
* top-level `name:` directive. Base files (docker-compose.yml etc.) are
|
|
79
|
-
* exempt — their default of dir-basename is fine — but overrides without
|
|
80
|
-
* `name:` silently inherit the base's project namespace, which is how a
|
|
81
|
-
* `compose down -v` against the check overlay wiped the prod-volume project.
|
|
82
|
-
* - Rule B (`duplicate-name`): no two compose files in a single dir may
|
|
83
|
-
* declare the same `name:`. Base files participate in collision detection.
|
|
84
|
-
*/
|
|
85
|
-
function lintComposeFiles(targetDir, config) {
|
|
86
|
-
const issues = [];
|
|
87
|
-
const visitedDirs = /* @__PURE__ */ new Set();
|
|
88
|
-
const standaloneFiles = [];
|
|
89
|
-
const dockerCheck = config?.dockerCheck;
|
|
90
|
-
const isObjectConfig = dockerCheck !== void 0 && dockerCheck !== false;
|
|
91
|
-
for (const candidate of COMPOSE_DIR_CANDIDATES) {
|
|
92
|
-
const absDir = path.resolve(targetDir, candidate);
|
|
93
|
-
if (existsSync(absDir)) visitDir(targetDir, absDir, visitedDirs, issues);
|
|
94
|
-
}
|
|
95
|
-
if (isObjectConfig && dockerCheck.composeFiles) for (const rel of dockerCheck.composeFiles) {
|
|
96
|
-
const absFile = path.resolve(targetDir, rel);
|
|
97
|
-
if (!existsSync(absFile)) continue;
|
|
98
|
-
const dir = path.dirname(absFile);
|
|
99
|
-
if (visitedDirs.has(dir)) continue;
|
|
100
|
-
const basename = path.basename(absFile);
|
|
101
|
-
standaloneFiles.push({
|
|
102
|
-
absPath: absFile,
|
|
103
|
-
relPath: path.relative(targetDir, absFile),
|
|
104
|
-
basename
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
const standaloneByDir = /* @__PURE__ */ new Map();
|
|
108
|
-
for (const file of standaloneFiles) {
|
|
109
|
-
const dir = path.dirname(file.absPath);
|
|
110
|
-
const list = standaloneByDir.get(dir) ?? [];
|
|
111
|
-
list.push(file);
|
|
112
|
-
standaloneByDir.set(dir, list);
|
|
113
|
-
}
|
|
114
|
-
for (const files of standaloneByDir.values()) {
|
|
115
|
-
files.sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
116
|
-
lintDirFiles(files, issues);
|
|
117
|
-
}
|
|
118
|
-
return issues;
|
|
119
|
-
}
|
|
120
|
-
function visitDir(targetDir, absDir, visitedDirs, issues) {
|
|
121
|
-
if (visitedDirs.has(absDir)) return;
|
|
122
|
-
visitedDirs.add(absDir);
|
|
123
|
-
let entries;
|
|
124
|
-
try {
|
|
125
|
-
entries = readdirSync(absDir);
|
|
126
|
-
} catch (_error) {
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
const files = entries.filter((name) => COMPOSE_FILENAME_PATTERN.test(name)).toSorted().map((name) => {
|
|
130
|
-
const absPath = path.join(absDir, name);
|
|
131
|
-
return {
|
|
132
|
-
absPath,
|
|
133
|
-
relPath: path.relative(targetDir, absPath),
|
|
134
|
-
basename: name
|
|
135
|
-
};
|
|
136
|
-
});
|
|
137
|
-
if (files.length === 0) return;
|
|
138
|
-
lintDirFiles(files, issues);
|
|
139
|
-
}
|
|
140
|
-
function lintDirFiles(files, issues) {
|
|
141
|
-
const namesByFile = /* @__PURE__ */ new Map();
|
|
142
|
-
for (const file of files) {
|
|
143
|
-
const declaredName = readTopLevelName(file.absPath);
|
|
144
|
-
namesByFile.set(file.relPath, declaredName);
|
|
145
|
-
if (!BASE_COMPOSE_FILES.has(file.basename) && (declaredName === void 0 || declaredName === "")) {
|
|
146
|
-
const suggested = suggestProjectName(file);
|
|
147
|
-
issues.push({
|
|
148
|
-
kind: "missing-name",
|
|
149
|
-
files: [file.relPath],
|
|
150
|
-
message: `${file.relPath}: override compose file is missing required top-level 'name:' directive.\n Add: name: ${suggested}\n Why: without an explicit name, this override inherits the base file's project namespace.\n A 'docker compose down -v' against this override would target the base project's volumes\n — which has caused production volume loss in the past.`
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
const filesByName = /* @__PURE__ */ new Map();
|
|
155
|
-
for (const [relPath, declaredName] of namesByFile) {
|
|
156
|
-
if (declaredName === void 0 || declaredName === "") continue;
|
|
157
|
-
const list = filesByName.get(declaredName) ?? [];
|
|
158
|
-
list.push(relPath);
|
|
159
|
-
filesByName.set(declaredName, list);
|
|
160
|
-
}
|
|
161
|
-
for (const [declaredName, dupFiles] of filesByName) {
|
|
162
|
-
if (dupFiles.length < 2) continue;
|
|
163
|
-
dupFiles.sort();
|
|
164
|
-
const [first, second, ...rest] = dupFiles;
|
|
165
|
-
const ext = rest.length > 0 ? `, ${rest.join(", ")}` : "";
|
|
166
|
-
issues.push({
|
|
167
|
-
kind: "duplicate-name",
|
|
168
|
-
files: dupFiles,
|
|
169
|
-
message: `${String(first)} and ${String(second)}${ext} both declare 'name: ${declaredName}'.\n Each compose file in the same directory must declare a unique 'name:'.\n Suggested fix: rename one (e.g. 'name: ${declaredName}-test' in one of them).`
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
/** Suggest a default name for an override file by stripping the variant from the filename. */
|
|
174
|
-
function suggestProjectName(file) {
|
|
175
|
-
const parts = file.basename.replace(/\.(yml|yaml)$/, "").split(".");
|
|
176
|
-
const variant = parts[parts.length - 1];
|
|
177
|
-
if (parts.length > 1 && variant) return `<project>-${variant}`;
|
|
178
|
-
return "<project>-check";
|
|
179
|
-
}
|
|
180
|
-
function readTopLevelName(absPath) {
|
|
181
|
-
let raw;
|
|
182
|
-
try {
|
|
183
|
-
raw = readFileSync(absPath, "utf-8");
|
|
184
|
-
} catch (_error) {
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
let yamlValue;
|
|
188
|
-
try {
|
|
189
|
-
yamlValue = parseComposeYaml(raw);
|
|
190
|
-
} catch (_error) {
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
const parsed = ComposeFileShape.safeParse(yamlValue);
|
|
194
|
-
if (!parsed.success) return void 0;
|
|
195
|
-
const value = parsed.data.name;
|
|
196
|
-
if (typeof value !== "string") return void 0;
|
|
197
|
-
return value;
|
|
198
|
-
}
|
|
199
|
-
//#endregion
|
|
200
|
-
//#region src/docker-check/executor.ts
|
|
201
|
-
/** Create an executor that runs real commands, fetches, and manages process signals. */
|
|
202
|
-
function createRealExecutor() {
|
|
203
|
-
return {
|
|
204
|
-
exec(file, args, options) {
|
|
205
|
-
const result = spawnSync(file, args, {
|
|
206
|
-
cwd: options?.cwd,
|
|
207
|
-
env: options?.env ? {
|
|
208
|
-
...process.env,
|
|
209
|
-
...options.env
|
|
210
|
-
} : void 0,
|
|
211
|
-
encoding: "utf-8",
|
|
212
|
-
stdio: [
|
|
213
|
-
"pipe",
|
|
214
|
-
"pipe",
|
|
215
|
-
"pipe"
|
|
216
|
-
]
|
|
217
|
-
});
|
|
218
|
-
return {
|
|
219
|
-
stdout: result.stdout ?? "",
|
|
220
|
-
stderr: result.stderr ?? "",
|
|
221
|
-
exitCode: result.status ?? 1
|
|
222
|
-
};
|
|
223
|
-
},
|
|
224
|
-
execInherit(file, args, options) {
|
|
225
|
-
execFileSync(file, args, {
|
|
226
|
-
cwd: options?.cwd,
|
|
227
|
-
env: options?.env ? {
|
|
228
|
-
...process.env,
|
|
229
|
-
...options.env
|
|
230
|
-
} : void 0,
|
|
231
|
-
stdio: "inherit"
|
|
232
|
-
});
|
|
233
|
-
},
|
|
234
|
-
fetch: globalThis.fetch,
|
|
235
|
-
now: () => Date.now(),
|
|
236
|
-
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
237
|
-
onSignal(signal, handler) {
|
|
238
|
-
process.on(signal, handler);
|
|
239
|
-
return () => {
|
|
240
|
-
process.removeListener(signal, handler);
|
|
241
|
-
};
|
|
242
|
-
},
|
|
243
|
-
log: (msg) => console.log(msg),
|
|
244
|
-
logError: (msg) => console.error(msg)
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
//#endregion
|
|
248
|
-
//#region src/docker-check/compose.ts
|
|
249
|
-
/** Zod schema for a single container entry from `docker compose ps --format json`. */
|
|
250
|
-
const ContainerInfoSchema = z.object({
|
|
251
|
-
Service: z.string(),
|
|
252
|
-
Health: z.string()
|
|
253
|
-
});
|
|
254
|
-
/** Build the `docker compose` base args from config. */
|
|
255
|
-
function composeArgs(config) {
|
|
256
|
-
const args = [
|
|
257
|
-
"compose",
|
|
258
|
-
"--project-name",
|
|
259
|
-
config.projectName
|
|
260
|
-
];
|
|
261
|
-
for (const f of config.composeFiles) args.push("-f", f);
|
|
262
|
-
if (config.envFile) args.push("--env-file", config.envFile);
|
|
263
|
-
return args;
|
|
264
|
-
}
|
|
265
|
-
/**
|
|
266
|
-
* Compose's unset-interpolation warning. The variable name is quoted, and the
|
|
267
|
-
* quotes arrive backslash-escaped when stderr is a pipe (which it always is
|
|
268
|
-
* here) but bare when it is a TTY — so both forms are accepted.
|
|
269
|
-
*/
|
|
270
|
-
const UNSET_VARIABLE_WARNING = /The\s+\\?"([^"\\]+)\\?"\s+variable is not set/g;
|
|
271
|
-
/** Names of the interpolation variables compose warned were unset, in order, de-duplicated. */
|
|
272
|
-
function parseUnsetVariables(stderr) {
|
|
273
|
-
const names = /* @__PURE__ */ new Set();
|
|
274
|
-
for (const match of stderr.matchAll(UNSET_VARIABLE_WARNING)) {
|
|
275
|
-
const name = match[1];
|
|
276
|
-
if (name) names.add(name);
|
|
277
|
-
}
|
|
278
|
-
return [...names];
|
|
279
|
-
}
|
|
280
|
-
/**
|
|
281
|
-
* Ask compose which interpolation variables are unset, and map each to an empty
|
|
282
|
-
* string so it can be injected into the child env of every later compose call.
|
|
283
|
-
*
|
|
284
|
-
* Compose warns `The "FOO" variable is not set` once per file per invocation —
|
|
285
|
-
* including on `down` — for a `${FOO}` with no default. Nothing expressible in
|
|
286
|
-
* the check overlay silences that: interpolation happens per-file, before the
|
|
287
|
-
* merge. Setting the variable does silence it, and setting it to the *empty
|
|
288
|
-
* string* is what compose already substitutes, so the value each container sees
|
|
289
|
-
* is unchanged. A non-empty placeholder would not be: a service that dials
|
|
290
|
-
* `${LYRION_HOST}` would start resolving a hostname where today it gets "".
|
|
291
|
-
*
|
|
292
|
-
* Detection is delegated to compose rather than re-implementing its grammar:
|
|
293
|
-
* `config` has already consulted `--env-file` and the process env, so it reports
|
|
294
|
-
* exactly the variables that still need filling, in every position (`image:`,
|
|
295
|
-
* `ports:`, `volumes:`, …) and every form, with `$$` escapes and `${FOO:-def}`
|
|
296
|
-
* defaults already accounted for. If the wording ever changes we fill nothing
|
|
297
|
-
* and the warnings simply come back — a `${FOO:?required}` error is never masked.
|
|
298
|
-
*
|
|
299
|
-
* Variables already present in `config.env` are left alone: the free-port logic
|
|
300
|
-
* owns PORT, and an empty string there would unbind the published port.
|
|
301
|
-
*/
|
|
302
|
-
function fillUnsetVariables(executor, config) {
|
|
303
|
-
const result = executor.exec("docker", [...composeArgs(config), "config"], {
|
|
304
|
-
cwd: config.cwd,
|
|
305
|
-
env: config.env
|
|
306
|
-
});
|
|
307
|
-
const filled = {};
|
|
308
|
-
for (const name of parseUnsetVariables(result.stderr)) {
|
|
309
|
-
if (config.env && name in config.env) continue;
|
|
310
|
-
filled[name] = "";
|
|
311
|
-
}
|
|
312
|
-
return filled;
|
|
313
|
-
}
|
|
314
|
-
/** Run the build command if configured. */
|
|
315
|
-
function buildImages(executor, config) {
|
|
316
|
-
if (!config.buildCommand) return;
|
|
317
|
-
const [file, ...args] = config.buildCommand;
|
|
318
|
-
if (!file) return;
|
|
319
|
-
executor.execInherit(file, args, { cwd: config.buildCwd ?? config.compose.cwd });
|
|
320
|
-
}
|
|
321
|
-
/** Run the smoketest command against the running stack. */
|
|
322
|
-
function runSmoketest(executor, config) {
|
|
323
|
-
if (!config.smoketest) return;
|
|
324
|
-
const [file, ...args] = config.smoketest;
|
|
325
|
-
if (!file) return;
|
|
326
|
-
executor.execInherit(file, args, {
|
|
327
|
-
cwd: config.smoketestCwd ?? config.compose.cwd,
|
|
328
|
-
env: config.compose.env
|
|
329
|
-
});
|
|
330
|
-
}
|
|
331
|
-
/** Start the compose stack in detached mode. */
|
|
332
|
-
function composeUp(executor, config) {
|
|
333
|
-
executor.execInherit("docker", [
|
|
334
|
-
...composeArgs(config),
|
|
335
|
-
"up",
|
|
336
|
-
"-d"
|
|
337
|
-
], {
|
|
338
|
-
cwd: config.cwd,
|
|
339
|
-
env: config.env
|
|
340
|
-
});
|
|
341
|
-
}
|
|
342
|
-
/** Tear down the compose stack, removing volumes and orphans. Swallows errors. */
|
|
343
|
-
function composeDown(executor, config) {
|
|
344
|
-
try {
|
|
345
|
-
executor.execInherit("docker", [
|
|
346
|
-
...composeArgs(config),
|
|
347
|
-
"down",
|
|
348
|
-
"-v",
|
|
349
|
-
"--remove-orphans"
|
|
350
|
-
], {
|
|
351
|
-
cwd: config.cwd,
|
|
352
|
-
env: config.env
|
|
353
|
-
});
|
|
354
|
-
} catch (_error) {}
|
|
355
|
-
}
|
|
356
|
-
/** Show logs for a specific service (or all services if not specified). Swallows errors. */
|
|
357
|
-
function composeLogs(executor, config, service) {
|
|
358
|
-
try {
|
|
359
|
-
const args = [...composeArgs(config), "logs"];
|
|
360
|
-
if (service) args.push(service);
|
|
361
|
-
executor.execInherit("docker", args, {
|
|
362
|
-
cwd: config.cwd,
|
|
363
|
-
env: config.env
|
|
364
|
-
});
|
|
365
|
-
} catch (_error) {}
|
|
366
|
-
}
|
|
367
|
-
/**
|
|
368
|
-
* Query container status via `docker compose ps --format json`.
|
|
369
|
-
* Handles both JSON array and newline-delimited JSON (varies by docker compose version).
|
|
370
|
-
*/
|
|
371
|
-
function composePs(executor, config) {
|
|
372
|
-
const output = executor.exec("docker", [
|
|
373
|
-
...composeArgs(config),
|
|
374
|
-
"ps",
|
|
375
|
-
"--format",
|
|
376
|
-
"json"
|
|
377
|
-
], {
|
|
378
|
-
cwd: config.cwd,
|
|
379
|
-
env: config.env
|
|
380
|
-
}).stdout.trim();
|
|
381
|
-
if (!output) return [];
|
|
382
|
-
const ArraySchema = z.array(ContainerInfoSchema);
|
|
383
|
-
try {
|
|
384
|
-
const direct = ArraySchema.safeParse(JSON.parse(output));
|
|
385
|
-
if (direct.success) return direct.data;
|
|
386
|
-
const single = ContainerInfoSchema.safeParse(JSON.parse(output));
|
|
387
|
-
if (single.success) return [single.data];
|
|
388
|
-
} catch (_error) {}
|
|
389
|
-
try {
|
|
390
|
-
const joined = `[${output.split("\n").join(",")}]`;
|
|
391
|
-
const delimited = ArraySchema.safeParse(JSON.parse(joined));
|
|
392
|
-
return delimited.success ? delimited.data : [];
|
|
393
|
-
} catch (_error) {
|
|
394
|
-
return [];
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
//#endregion
|
|
398
|
-
//#region src/docker-check/health.ts
|
|
399
|
-
/** Look up the health status of a specific service from container info. */
|
|
400
|
-
function getContainerHealth(containers, serviceName) {
|
|
401
|
-
return containers.find((c) => c.Service === serviceName)?.Health ?? "unknown";
|
|
402
|
-
}
|
|
403
|
-
/** Run a single HTTP health check, returning true if the validator passes. */
|
|
404
|
-
async function checkHttpHealth(executor, check) {
|
|
405
|
-
try {
|
|
406
|
-
const response = await executor.fetch(check.url);
|
|
407
|
-
return await check.validate(response);
|
|
408
|
-
} catch (_error) {
|
|
409
|
-
return false;
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
//#endregion
|
|
413
|
-
//#region src/docker-check/check.ts
|
|
414
|
-
const DEFAULT_TIMEOUT_MS = 12e4;
|
|
415
|
-
const DEFAULT_POLL_INTERVAL_MS = 5e3;
|
|
416
|
-
/**
|
|
417
|
-
* Fill any compose interpolation variable that is still unset with an empty
|
|
418
|
-
* string, so `${FOO}` with no default stops warning on every compose call —
|
|
419
|
-
* `up` and, after the check has already passed, teardown. See
|
|
420
|
-
* `fillUnsetVariables` for why empty (not a placeholder) and why compose itself
|
|
421
|
-
* decides which variables need it.
|
|
422
|
-
*
|
|
423
|
-
* This runs here rather than in the `docker:check` command so the env reaches
|
|
424
|
-
* every compose invocation *and* the smoketest through one `CheckConfig`, and so
|
|
425
|
-
* programmatic consumers of the `docker-check` export get it too.
|
|
426
|
-
*/
|
|
427
|
-
function withFilledVariables(executor, config) {
|
|
428
|
-
const filled = fillUnsetVariables(executor, config.compose);
|
|
429
|
-
const names = Object.keys(filled);
|
|
430
|
-
if (names.length === 0) return config;
|
|
431
|
-
executor.log(`Unset compose variables set to an empty string: ${names.join(", ")}. To supply real values, put them in ${config.compose.envFile ?? "docker-compose.check.env"} beside the compose file.`);
|
|
432
|
-
return {
|
|
433
|
-
...config,
|
|
434
|
-
compose: {
|
|
435
|
-
...config.compose,
|
|
436
|
-
env: {
|
|
437
|
-
...config.compose.env,
|
|
438
|
-
...filled
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
};
|
|
442
|
-
}
|
|
443
|
-
/** Probe any not-yet-healthy HTTP endpoints, flipping them to healthy in `healthStatus`. */
|
|
444
|
-
async function updateHttpHealth(executor, healthChecks, healthStatus) {
|
|
445
|
-
for (const check of healthChecks) {
|
|
446
|
-
if (healthStatus.get(check.name)) continue;
|
|
447
|
-
if (await checkHttpHealth(executor, check)) {
|
|
448
|
-
healthStatus.set(check.name, true);
|
|
449
|
-
executor.log(`${check.name} is healthy!`);
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
/**
|
|
454
|
-
* Run the configured smoketest (if any). Returns a failure `CheckResult` when
|
|
455
|
-
* the smoketest fails (after dumping logs and cleaning up), or `null` on success
|
|
456
|
-
* or when no smoketest is configured.
|
|
457
|
-
*/
|
|
458
|
-
function runSmoketestStep(executor, config, cleanup, startTime) {
|
|
459
|
-
if (!config.smoketest) return null;
|
|
460
|
-
const { compose } = config;
|
|
461
|
-
executor.log(`Running smoketest: ${config.smoketest.join(" ")}`);
|
|
462
|
-
try {
|
|
463
|
-
runSmoketest(executor, config);
|
|
464
|
-
executor.log("Smoketest passed!");
|
|
465
|
-
return null;
|
|
466
|
-
} catch (error) {
|
|
467
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
468
|
-
executor.logError(`Smoketest failed: ${message}`);
|
|
469
|
-
for (const service of compose.services) composeLogs(executor, compose, service);
|
|
470
|
-
cleanup();
|
|
471
|
-
return {
|
|
472
|
-
success: false,
|
|
473
|
-
reason: "smoketest-failed",
|
|
474
|
-
message,
|
|
475
|
-
elapsedMs: executor.now() - startTime
|
|
476
|
-
};
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
/** Run the full Docker check lifecycle. */
|
|
480
|
-
async function runDockerCheck(executor, rawConfig) {
|
|
481
|
-
const config = withFilledVariables(executor, rawConfig);
|
|
482
|
-
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
483
|
-
const pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
484
|
-
const { compose } = config;
|
|
485
|
-
const vc = { verbose: config.verbose ?? false };
|
|
486
|
-
debug(vc, `Compose files: ${compose.composeFiles.join(", ")}`);
|
|
487
|
-
debug(vc, `Services: ${compose.services.join(", ")}`);
|
|
488
|
-
debug(vc, `Health checks: ${config.healthChecks.map((c) => c.name).join(", ") || "(none)"}`);
|
|
489
|
-
debug(vc, `Timeout: ${String(timeoutMs)}ms, poll interval: ${String(pollIntervalMs)}ms`);
|
|
490
|
-
const cleanup = () => composeDown(executor, compose);
|
|
491
|
-
let cleaningUp = false;
|
|
492
|
-
const gracefulShutdown = () => {
|
|
493
|
-
if (cleaningUp) {
|
|
494
|
-
executor.log("Cleanup in progress, please wait...");
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
cleaningUp = true;
|
|
498
|
-
executor.log("Interrupted — shutting down compose stack...");
|
|
499
|
-
cleanup();
|
|
500
|
-
process.exit(1);
|
|
501
|
-
};
|
|
502
|
-
const disposeInt = executor.onSignal("SIGINT", gracefulShutdown);
|
|
503
|
-
const disposeTerm = executor.onSignal("SIGTERM", gracefulShutdown);
|
|
504
|
-
try {
|
|
505
|
-
if (config.buildCommand) {
|
|
506
|
-
executor.log("Building images...");
|
|
507
|
-
buildImages(executor, config);
|
|
508
|
-
}
|
|
509
|
-
executor.log("Starting compose stack...");
|
|
510
|
-
const startTime = executor.now();
|
|
511
|
-
try {
|
|
512
|
-
composeUp(executor, compose);
|
|
513
|
-
} catch (error) {
|
|
514
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
515
|
-
executor.logError(`Compose stack failed to start — dumping logs`);
|
|
516
|
-
for (const service of compose.services) composeLogs(executor, compose, service);
|
|
517
|
-
cleanup();
|
|
518
|
-
return {
|
|
519
|
-
success: false,
|
|
520
|
-
reason: "startup-failed",
|
|
521
|
-
message,
|
|
522
|
-
elapsedMs: executor.now() - startTime
|
|
523
|
-
};
|
|
524
|
-
}
|
|
525
|
-
executor.log(`Waiting for stack to be healthy (max ${timeoutMs / 1e3}s)...`);
|
|
526
|
-
let lastStatusLogTime = startTime;
|
|
527
|
-
const healthStatus = new Map(config.healthChecks.map((c) => [c.name, false]));
|
|
528
|
-
while (executor.now() - startTime < timeoutMs) {
|
|
529
|
-
const containers = composePs(executor, compose);
|
|
530
|
-
debug(vc, `compose ps: ${containers.length} container(s)`);
|
|
531
|
-
let allContainersHealthy = true;
|
|
532
|
-
for (const service of compose.services) {
|
|
533
|
-
const status = getContainerHealth(containers, service);
|
|
534
|
-
debug(vc, `Container ${service}: ${status || "(no healthcheck)"}`);
|
|
535
|
-
if (status === "unhealthy") {
|
|
536
|
-
executor.logError(`Container ${service} is unhealthy`);
|
|
537
|
-
composeLogs(executor, compose, service);
|
|
538
|
-
cleanup();
|
|
539
|
-
return {
|
|
540
|
-
success: false,
|
|
541
|
-
reason: "unhealthy-container",
|
|
542
|
-
message: service,
|
|
543
|
-
elapsedMs: executor.now() - startTime
|
|
544
|
-
};
|
|
545
|
-
}
|
|
546
|
-
if (status === "starting") allContainersHealthy = false;
|
|
547
|
-
}
|
|
548
|
-
await updateHttpHealth(executor, config.healthChecks, healthStatus);
|
|
549
|
-
if (allContainersHealthy && [...healthStatus.values()].every(Boolean)) {
|
|
550
|
-
const smoketestFailure = runSmoketestStep(executor, config, cleanup, startTime);
|
|
551
|
-
if (smoketestFailure) return smoketestFailure;
|
|
552
|
-
executor.log("Check successful! All systems operational.");
|
|
553
|
-
cleanup();
|
|
554
|
-
return {
|
|
555
|
-
success: true,
|
|
556
|
-
elapsedMs: executor.now() - startTime
|
|
557
|
-
};
|
|
558
|
-
}
|
|
559
|
-
const now = executor.now();
|
|
560
|
-
if (now - lastStatusLogTime >= 5e3) {
|
|
561
|
-
lastStatusLogTime = now;
|
|
562
|
-
const elapsed = Math.floor((now - startTime) / 1e3);
|
|
563
|
-
const parts = [compose.services.map((s) => `${s}=${getContainerHealth(containers, s)}`).join(", "), [...healthStatus.entries()].map(([name, ok]) => `${name}=${ok ? "OK" : "Pending"}`).join(", ")].filter(Boolean).join(" | ");
|
|
564
|
-
executor.log(`Waiting... (${elapsed}s elapsed). ${parts}`);
|
|
565
|
-
}
|
|
566
|
-
await executor.sleep(pollIntervalMs);
|
|
567
|
-
}
|
|
568
|
-
executor.logError("Timeout waiting for stack to become healthy");
|
|
569
|
-
for (const service of compose.services) composeLogs(executor, compose, service);
|
|
570
|
-
cleanup();
|
|
571
|
-
return {
|
|
572
|
-
success: false,
|
|
573
|
-
reason: "timeout",
|
|
574
|
-
message: "Exceeded timeout",
|
|
575
|
-
elapsedMs: executor.now() - startTime
|
|
576
|
-
};
|
|
577
|
-
} catch (error) {
|
|
578
|
-
cleanup();
|
|
579
|
-
return {
|
|
580
|
-
success: false,
|
|
581
|
-
reason: "error",
|
|
582
|
-
message: error instanceof Error ? error.message : String(error),
|
|
583
|
-
elapsedMs: 0
|
|
584
|
-
};
|
|
585
|
-
} finally {
|
|
586
|
-
disposeInt();
|
|
587
|
-
disposeTerm();
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
//#endregion
|
|
591
|
-
//#region src/docker-check/project-name.ts
|
|
592
|
-
/**
|
|
593
|
-
* Derive the `docker compose --project-name` value for a check run.
|
|
594
|
-
*
|
|
595
|
-
* Always returns a unique, non-colliding namespace so a stray `compose down -v`
|
|
596
|
-
* during the check cannot target volumes belonging to a long-running stack
|
|
597
|
-
* sharing the same docker daemon (the failure mode that wiped prod volumes
|
|
598
|
-
* when a CI runner bind-mounted /var/run/docker.sock and the check overlay
|
|
599
|
-
* inherited the base file's `name:`).
|
|
600
|
-
*
|
|
601
|
-
* Format: `<sanitized-repo>-check-<suffix>` where suffix prefers a CI run id
|
|
602
|
-
* for traceability and falls back to `process.pid` locally so two concurrent
|
|
603
|
-
* `bst docker:check` invocations on the same machine don't collide.
|
|
604
|
-
*/
|
|
605
|
-
function deriveProjectName(repoName, env = process.env) {
|
|
606
|
-
return `${sanitize(repoName)}-check-${sanitize(env["GITHUB_RUN_ID"] ?? env["CI_RUN_ID"] ?? String(process.pid))}`;
|
|
607
|
-
}
|
|
608
|
-
function sanitize(name) {
|
|
609
|
-
return name.replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "repo";
|
|
610
|
-
}
|
|
611
|
-
//#endregion
|
|
612
|
-
export { composeArgs as a, composePs as c, lintComposeFiles as d, parseComposeYaml as f, redact as g, isEnvVerbose as h, getContainerHealth as i, composeUp as l, debugExec as m, runDockerCheck as n, composeDown as o, debug as p, checkHttpHealth as r, composeLogs as s, deriveProjectName as t, createRealExecutor as u };
|