@celilo/cli 1.1.0 → 1.2.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/CELILO_SUBSYSTEMS.md +12 -0
- package/package.json +2 -2
- package/src/cli/commands/hook-run.ts +5 -8
- package/src/cli/commands/system-audit.ts +2 -0
- package/src/cli/commands/system-doctor.ts +30 -1
- package/src/cli/commands/system-update.ts +2 -0
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/db/schema.ts +41 -1
- package/src/hooks/artifact-retention.test.ts +136 -0
- package/src/hooks/artifact-retention.ts +159 -0
- package/src/hooks/executor.test.ts +80 -0
- package/src/hooks/executor.ts +68 -23
- package/src/hooks/test-fixtures/artifact-writing-hook.ts +25 -0
- package/src/hooks/types.ts +20 -2
- package/src/policy/module-business-baseline.ts +404 -0
- package/src/policy/no-module-business-in-core.test.ts +504 -0
- package/src/services/alerting/keys.ts +21 -1
- package/src/services/alerting/run-monitor.ts +6 -1
- package/src/services/audit/browser-pin.test.ts +167 -0
- package/src/services/audit/browser-pin.ts +185 -0
- package/src/services/audit/index.test.ts +1 -0
- package/src/services/audit/index.ts +3 -0
- package/src/services/audit/types.ts +1 -0
- package/src/services/health-runner.ts +15 -1
- package/src/services/module-deploy.ts +4 -4
- package/src/services/update/orchestrator.test.ts +1 -0
- package/src/system/browser-provisioning.test.ts +67 -0
- package/src/system/prereqs.test.ts +73 -0
- package/src/system/prereqs.ts +89 -12
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two assertions that matter here are both about STAYING QUIET.
|
|
3
|
+
*
|
|
4
|
+
* A host with no browser provisioned is the NORMAL case — installation is
|
|
5
|
+
* opt-in — so a check that fired there would put a finding on every default
|
|
6
|
+
* host in the fleet forever. And a module that bundles no browser client is
|
|
7
|
+
* almost every module. Getting either wrong turns a guardrail into noise
|
|
8
|
+
* that trains an operator to ignore the audit.
|
|
9
|
+
*
|
|
10
|
+
* The revision is read from the bundle's own `browsers.json`, never from a
|
|
11
|
+
* version→revision table, so the reader tests use real files.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
15
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { auditBrowserPin, readBundledBrowserClient } from './browser-pin';
|
|
19
|
+
|
|
20
|
+
const PROVISIONED = { revision: '1223', playwrightVersion: '1.60.0' };
|
|
21
|
+
|
|
22
|
+
describe('auditBrowserPin', () => {
|
|
23
|
+
test('says nothing when no browser is provisioned', async () => {
|
|
24
|
+
// The opt-in default. `system doctor` reports the absence; this must not,
|
|
25
|
+
// or every host that never wanted a browser carries a permanent finding.
|
|
26
|
+
const findings = await auditBrowserPin({
|
|
27
|
+
consumers: [{ moduleId: 'lunacycle', clientVersion: '1.55.1', expectedRevision: '1193' }],
|
|
28
|
+
provisioned: null,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(findings).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('says nothing when no module bundles a browser client', async () => {
|
|
35
|
+
expect(await auditBrowserPin({ consumers: [], provisioned: PROVISIONED })).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('says nothing when the bundled client expects the installed revision', async () => {
|
|
39
|
+
const findings = await auditBrowserPin({
|
|
40
|
+
consumers: [{ moduleId: 'lunacycle', clientVersion: '1.60.0', expectedRevision: '1223' }],
|
|
41
|
+
provisioned: PROVISIONED,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(findings).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('reports drift — never blocked — when the revisions differ', async () => {
|
|
48
|
+
const findings = await auditBrowserPin({
|
|
49
|
+
consumers: [{ moduleId: 'lunacycle', clientVersion: '1.55.1', expectedRevision: '1193' }],
|
|
50
|
+
provisioned: PROVISIONED,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(findings).toHaveLength(1);
|
|
54
|
+
const finding = findings[0];
|
|
55
|
+
// Task 4.2: it warns, it never blocks. Under D2 the consumer passes an
|
|
56
|
+
// explicit executablePath, so this is a soft protocol risk, not a
|
|
57
|
+
// failure — blocking a deploy on it would be wrong.
|
|
58
|
+
expect(finding?.severity).toBe('drift');
|
|
59
|
+
expect(finding?.category).toBe('browser_pin');
|
|
60
|
+
expect(finding?.subject).toBe('lunacycle');
|
|
61
|
+
expect(finding?.actionable).toBe(false);
|
|
62
|
+
// Both revisions named, so the operator can tell which end to move.
|
|
63
|
+
expect(finding?.message).toContain('1193');
|
|
64
|
+
expect(finding?.message).toContain('1223');
|
|
65
|
+
expect(finding?.remediation).toContain('1.60.0');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('reports one finding per drifting module', async () => {
|
|
69
|
+
const findings = await auditBrowserPin({
|
|
70
|
+
consumers: [
|
|
71
|
+
{ moduleId: 'lunacycle', clientVersion: '1.55.1', expectedRevision: '1193' },
|
|
72
|
+
{ moduleId: 'aligned', clientVersion: '1.60.0', expectedRevision: '1223' },
|
|
73
|
+
{ moduleId: 'other', clientVersion: '1.50.0', expectedRevision: '1150' },
|
|
74
|
+
],
|
|
75
|
+
provisioned: PROVISIONED,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(findings.map((f) => f.subject)).toEqual(['lunacycle', 'other']);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('readBundledBrowserClient', () => {
|
|
83
|
+
let root: string;
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
root = mkdtempSync(join(tmpdir(), 'celilo-browser-pin-'));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
rmSync(root, { recursive: true, force: true });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/** Write a bundled playwright-core under `<root>/<bundleDir>/node_modules`. */
|
|
94
|
+
function bundle(bundleDir: string, version: string, revision: number | string): void {
|
|
95
|
+
const pkg = join(root, bundleDir, 'node_modules', 'playwright-core');
|
|
96
|
+
mkdirSync(pkg, { recursive: true });
|
|
97
|
+
writeFileSync(join(pkg, 'package.json'), JSON.stringify({ version }));
|
|
98
|
+
writeFileSync(
|
|
99
|
+
join(pkg, 'browsers.json'),
|
|
100
|
+
JSON.stringify({
|
|
101
|
+
browsers: [
|
|
102
|
+
{ name: 'chromium', revision: 9999 },
|
|
103
|
+
{ name: 'chromium-headless-shell', revision },
|
|
104
|
+
],
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
test('finds a bundle under the conventional scripts/ directory', () => {
|
|
110
|
+
bundle('scripts', '1.60.0', 1223);
|
|
111
|
+
|
|
112
|
+
expect(readBundledBrowserClient('m', root, ['./scripts/health-check.ts'])).toEqual({
|
|
113
|
+
moduleId: 'm',
|
|
114
|
+
clientVersion: '1.60.0',
|
|
115
|
+
expectedRevision: '1223',
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('finds a bundle beside a hook script in a non-standard layout', () => {
|
|
120
|
+
// The one real browser consumer keeps its hooks in celilo/scripts/,
|
|
121
|
+
// so a hardcoded scripts/ would have missed exactly the module this
|
|
122
|
+
// check exists for.
|
|
123
|
+
bundle('celilo/scripts', '1.55.1', 1193);
|
|
124
|
+
|
|
125
|
+
const found = readBundledBrowserClient('lunacycle', root, ['./celilo/scripts/health-check.ts']);
|
|
126
|
+
|
|
127
|
+
expect(found?.expectedRevision).toBe('1193');
|
|
128
|
+
expect(found?.clientVersion).toBe('1.55.1');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('reads the revision as a STRING even though the file holds a number', () => {
|
|
132
|
+
// browsers.json stores it unquoted; comparing a number to the
|
|
133
|
+
// descriptor's string would never match and the check would be silent.
|
|
134
|
+
bundle('scripts', '1.60.0', 1223);
|
|
135
|
+
|
|
136
|
+
expect(readBundledBrowserClient('m', root, [])?.expectedRevision).toBe('1223');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('returns null when the module bundles no browser client', () => {
|
|
140
|
+
expect(readBundledBrowserClient('m', root, ['./scripts/on_install.ts'])).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('returns null when browsers.json is unreadable rather than throwing', () => {
|
|
144
|
+
const pkg = join(root, 'scripts', 'node_modules', 'playwright-core');
|
|
145
|
+
mkdirSync(pkg, { recursive: true });
|
|
146
|
+
writeFileSync(join(pkg, 'browsers.json'), 'not json {');
|
|
147
|
+
|
|
148
|
+
expect(readBundledBrowserClient('m', root, [])).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('a bundle without a readable package.json still reports its revision', () => {
|
|
152
|
+
// The revision is the load-bearing half; a missing version should not
|
|
153
|
+
// suppress a real drift finding.
|
|
154
|
+
const pkg = join(root, 'scripts', 'node_modules', 'playwright-core');
|
|
155
|
+
mkdirSync(pkg, { recursive: true });
|
|
156
|
+
writeFileSync(
|
|
157
|
+
join(pkg, 'browsers.json'),
|
|
158
|
+
JSON.stringify({ browsers: [{ name: 'chromium-headless-shell', revision: 1223 }] }),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
expect(readBundledBrowserClient('m', root, [])).toEqual({
|
|
162
|
+
moduleId: 'm',
|
|
163
|
+
clientVersion: 'unknown',
|
|
164
|
+
expectedRevision: '1223',
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser pin-drift check (managed-browser-runtime D9).
|
|
3
|
+
*
|
|
4
|
+
* A module bundles its own `playwright-core`, and celilo provisions the
|
|
5
|
+
* browser binary. Under D2 the consumer passes an explicit
|
|
6
|
+
* `executablePath`, so a client bump can no longer move the browser out
|
|
7
|
+
* from under it — the hard failure that started that change cannot recur.
|
|
8
|
+
* What remains is softer: a client several minor versions from the binary's
|
|
9
|
+
* build drives it over a protocol the two were versioned together. Usually
|
|
10
|
+
* fine; not guaranteed.
|
|
11
|
+
*
|
|
12
|
+
* So this WARNS and never blocks (D9 / task 4.2).
|
|
13
|
+
*
|
|
14
|
+
* **It compares REVISIONS, not version strings.** The revision is what
|
|
15
|
+
* actually has to match a build, and each `playwright-core` states its own
|
|
16
|
+
* in the `browsers.json` inside the package — which is where 1193 and 1223
|
|
17
|
+
* came from. Reading it out of the bundle is what keeps this honest and
|
|
18
|
+
* means nobody maintains a version→revision table that will rot.
|
|
19
|
+
*
|
|
20
|
+
* Like `system doctor` (D4), it reads the bundle and the descriptor and
|
|
21
|
+
* never asks Playwright which executable it would use: that reports the
|
|
22
|
+
* full browser regardless of what a headless launch would open.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
26
|
+
import { dirname, join } from 'node:path';
|
|
27
|
+
import { resolveBrowser } from '@celilo/capabilities';
|
|
28
|
+
import type { ModuleManifest } from '../../manifest/schema';
|
|
29
|
+
import type { DriftFinding } from './types';
|
|
30
|
+
|
|
31
|
+
/** What a module bundles, already read off disk. */
|
|
32
|
+
export interface BundledBrowserClient {
|
|
33
|
+
moduleId: string;
|
|
34
|
+
/** The bundled `playwright-core` version, e.g. `1.60.0`. */
|
|
35
|
+
clientVersion: string;
|
|
36
|
+
/** The chromium-headless-shell revision that client expects, e.g. `1223`. */
|
|
37
|
+
expectedRevision: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface BrowserPinAuditDeps {
|
|
41
|
+
/**
|
|
42
|
+
* Modules that bundle a browser client. A module with none contributes
|
|
43
|
+
* nothing — most modules never launch a browser.
|
|
44
|
+
*/
|
|
45
|
+
consumers: BundledBrowserClient[];
|
|
46
|
+
/**
|
|
47
|
+
* The provisioned browser, or `null` when this host has none.
|
|
48
|
+
*
|
|
49
|
+
* Null means silence, not a finding: installation is opt-in, so a host
|
|
50
|
+
* without a browser is the normal case and `system doctor` is already
|
|
51
|
+
* the place that reports its absence.
|
|
52
|
+
*/
|
|
53
|
+
provisioned: { revision: string; playwrightVersion: string } | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function auditBrowserPin(deps: BrowserPinAuditDeps): Promise<DriftFinding[]> {
|
|
57
|
+
const { provisioned } = deps;
|
|
58
|
+
if (!provisioned) return [];
|
|
59
|
+
|
|
60
|
+
const findings: DriftFinding[] = [];
|
|
61
|
+
for (const consumer of deps.consumers) {
|
|
62
|
+
if (consumer.expectedRevision === provisioned.revision) continue;
|
|
63
|
+
|
|
64
|
+
// Any difference is reported. A "how far apart is too far" threshold
|
|
65
|
+
// would be a tuning knob with no evidence behind it — the revision is
|
|
66
|
+
// the thing that has to match a build, so it either does or it does not.
|
|
67
|
+
findings.push({
|
|
68
|
+
category: 'browser_pin',
|
|
69
|
+
severity: 'drift',
|
|
70
|
+
code: 'browser_client_revision_drift',
|
|
71
|
+
subject: consumer.moduleId,
|
|
72
|
+
message:
|
|
73
|
+
`${consumer.moduleId} bundles playwright-core ${consumer.clientVersion}, which expects ` +
|
|
74
|
+
`chromium revision ${consumer.expectedRevision}, but this host has revision ` +
|
|
75
|
+
`${provisioned.revision} (installed for playwright ${provisioned.playwrightVersion}).`,
|
|
76
|
+
details:
|
|
77
|
+
'Not a failure: the module passes an explicit executablePath, so it launches the ' +
|
|
78
|
+
'installed browser regardless of which revision its client expects. The risk is ' +
|
|
79
|
+
'protocol skew — a client far from its browser build drives it over a protocol the ' +
|
|
80
|
+
'two were versioned together.',
|
|
81
|
+
remediation:
|
|
82
|
+
`Align them: either republish ${consumer.moduleId} against playwright-core ` +
|
|
83
|
+
`${provisioned.playwrightVersion}, or set celilo-mgmt's playwright_version to ` +
|
|
84
|
+
`${consumer.clientVersion} and redeploy it.`,
|
|
85
|
+
actionable: false,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return findings;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Read the browser client a module bundles, or `null` when it bundles none.
|
|
93
|
+
*
|
|
94
|
+
* Searches beside each hook script rather than walking the module tree:
|
|
95
|
+
* `scripts/node_modules` is where a module's hook runtime is bundled, and
|
|
96
|
+
* the manifest's own hook list is the authoritative statement of where its
|
|
97
|
+
* hook code lives. A module laying its hooks out differently
|
|
98
|
+
* (`celilo/scripts/…`, as the one real browser consumer does) is therefore
|
|
99
|
+
* found without hardcoding either layout.
|
|
100
|
+
*/
|
|
101
|
+
export function readBundledBrowserClient(
|
|
102
|
+
moduleId: string,
|
|
103
|
+
sourcePath: string,
|
|
104
|
+
hookScripts: string[],
|
|
105
|
+
): BundledBrowserClient | null {
|
|
106
|
+
const searched = new Set<string>([join(sourcePath, 'scripts')]);
|
|
107
|
+
for (const script of hookScripts) {
|
|
108
|
+
searched.add(dirname(join(sourcePath, script)));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const dir of searched) {
|
|
112
|
+
const pkgRoot = join(dir, 'node_modules', 'playwright-core');
|
|
113
|
+
const browsers = join(pkgRoot, 'browsers.json');
|
|
114
|
+
if (!existsSync(browsers)) continue;
|
|
115
|
+
|
|
116
|
+
const expectedRevision = revisionFor(browsers, 'chromium-headless-shell');
|
|
117
|
+
if (!expectedRevision) continue;
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
moduleId,
|
|
121
|
+
clientVersion: packageVersion(join(pkgRoot, 'package.json')) ?? 'unknown',
|
|
122
|
+
expectedRevision,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The revision a `playwright-core` states for a browser, from the
|
|
130
|
+
* `browsers.json` inside the package. Never a lookup table — the package
|
|
131
|
+
* is the only thing that knows, and a table would rot silently.
|
|
132
|
+
*/
|
|
133
|
+
function revisionFor(browsersJsonPath: string, browserName: string): string | null {
|
|
134
|
+
try {
|
|
135
|
+
const parsed = JSON.parse(readFileSync(browsersJsonPath, 'utf-8')) as {
|
|
136
|
+
browsers?: Array<{ name?: string; revision?: string | number }>;
|
|
137
|
+
};
|
|
138
|
+
const entry = parsed.browsers?.find((b) => b.name === browserName);
|
|
139
|
+
return entry?.revision === undefined ? null : String(entry.revision);
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function packageVersion(packageJsonPath: string): string | null {
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { version?: string };
|
|
148
|
+
return parsed.version ?? null;
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Gather this check's inputs from the module store and the provisioned
|
|
156
|
+
* browser's descriptor.
|
|
157
|
+
*
|
|
158
|
+
* Lives here rather than in the CLI so the two `AuditDeps` builders stay
|
|
159
|
+
* one line each and cannot drift apart — they already each carry their own
|
|
160
|
+
* copy of every other category's wiring.
|
|
161
|
+
*/
|
|
162
|
+
export function collectBrowserPinDeps(
|
|
163
|
+
modules: Array<{ id: string; sourcePath: string; manifestData: unknown }>,
|
|
164
|
+
): BrowserPinAuditDeps {
|
|
165
|
+
const consumers: BundledBrowserClient[] = [];
|
|
166
|
+
for (const module of modules) {
|
|
167
|
+
const manifest = module.manifestData as ModuleManifest | undefined;
|
|
168
|
+
const hookScripts = Object.values(manifest?.hooks ?? {})
|
|
169
|
+
.map((hook) => (hook as { script?: string } | undefined)?.script)
|
|
170
|
+
.filter((script): script is string => typeof script === 'string');
|
|
171
|
+
const bundled = readBundledBrowserClient(module.id, module.sourcePath, hookScripts);
|
|
172
|
+
if (bundled) consumers.push(bundled);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let provisioned: BrowserPinAuditDeps['provisioned'] = null;
|
|
176
|
+
try {
|
|
177
|
+
const browser = resolveBrowser();
|
|
178
|
+
provisioned = { revision: browser.revision, playwrightVersion: browser.playwrightVersion };
|
|
179
|
+
} catch {
|
|
180
|
+
// No browser on this host — the check stays silent. `system doctor`
|
|
181
|
+
// is where an absent browser is reported, not here.
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { consumers, provisioned };
|
|
185
|
+
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
auditAbandonedOperations,
|
|
16
16
|
} from './abandoned-operations';
|
|
17
17
|
import { type BackupsAuditDeps, auditBackups } from './backups';
|
|
18
|
+
import { type BrowserPinAuditDeps, auditBrowserPin } from './browser-pin';
|
|
18
19
|
import { type CapabilityAbiAuditDeps, auditCapabilityAbi } from './capability-abi';
|
|
19
20
|
import { type CliVersionAuditDeps, auditCliVersion } from './cli-version';
|
|
20
21
|
import { type HealthAuditDeps, auditHealth } from './health';
|
|
@@ -48,6 +49,7 @@ export interface AuditDeps {
|
|
|
48
49
|
cliVersion: CliVersionAuditDeps;
|
|
49
50
|
schema: SchemaAuditDeps;
|
|
50
51
|
capabilityAbi: CapabilityAbiAuditDeps;
|
|
52
|
+
browserPin: BrowserPinAuditDeps;
|
|
51
53
|
terraformPlan: TerraformPlanAuditDeps;
|
|
52
54
|
moduleVersions: ModuleVersionsAuditDeps;
|
|
53
55
|
moduleConfigs: ModuleConfigsAuditDeps;
|
|
@@ -101,6 +103,7 @@ export async function runAudit(
|
|
|
101
103
|
wrap('cli_version', auditCliVersion(deps.cliVersion)),
|
|
102
104
|
wrap('schema', auditSchema(deps.schema)),
|
|
103
105
|
wrap('capability_abi', auditCapabilityAbi(deps.capabilityAbi)),
|
|
106
|
+
wrap('browser_pin', auditBrowserPin(deps.browserPin)),
|
|
104
107
|
wrap('terraform_plan', auditTerraformPlan(deps.terraformPlan)),
|
|
105
108
|
wrap('module_versions', auditModuleVersions(deps.moduleVersions)),
|
|
106
109
|
wrap('module_configs', auditModuleConfigs(deps.moduleConfigs)),
|
|
@@ -31,6 +31,16 @@ export interface HealthCheckResult {
|
|
|
31
31
|
status: 'healthy' | 'degraded' | 'unhealthy' | 'no-checks' | 'error';
|
|
32
32
|
checks: HealthCheckItem[];
|
|
33
33
|
error?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Files the hook wrote to its per-run artifact directory.
|
|
36
|
+
*
|
|
37
|
+
* This path is the one that runs UNATTENDED every fifteen minutes, and
|
|
38
|
+
* it used to drop `HookResult`'s artifacts on the floor entirely — the
|
|
39
|
+
* on-demand paths (`module run-hook`, deploy) surfaced them and this one
|
|
40
|
+
* did not, so the artifacts were discarded exactly when nobody was
|
|
41
|
+
* watching to re-run the check by hand.
|
|
42
|
+
*/
|
|
43
|
+
artifactPaths?: string[];
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
export interface HealthCheckOptions {
|
|
@@ -191,11 +201,15 @@ export async function runModuleHealthCheck(
|
|
|
191
201
|
presentation.finish(hookResult.success);
|
|
192
202
|
|
|
193
203
|
if (!hookResult.success) {
|
|
204
|
+
// Artifacts matter MOST here: the hook died, so there are no named
|
|
205
|
+
// checks to explain why, and whatever it managed to write is all the
|
|
206
|
+
// operator gets.
|
|
194
207
|
return {
|
|
195
208
|
moduleId,
|
|
196
209
|
status: 'error',
|
|
197
210
|
checks: [],
|
|
198
211
|
error: hookResult.error,
|
|
212
|
+
artifactPaths: hookResult.artifactPaths,
|
|
199
213
|
};
|
|
200
214
|
}
|
|
201
215
|
|
|
@@ -220,7 +234,7 @@ export async function runModuleHealthCheck(
|
|
|
220
234
|
db.update(modules).set({ state: nextState }).where(eq(modules.id, moduleId)).run();
|
|
221
235
|
}
|
|
222
236
|
|
|
223
|
-
return { moduleId, status, checks };
|
|
237
|
+
return { moduleId, status, checks, artifactPaths: hookResult.artifactPaths };
|
|
224
238
|
}
|
|
225
239
|
|
|
226
240
|
/**
|
|
@@ -15,6 +15,7 @@ import { capabilities, machines, modules } from '../db/schema';
|
|
|
15
15
|
import { loadCapabilityFunctions } from '../hooks/capability-loader';
|
|
16
16
|
import { invokeHook } from '../hooks/executor';
|
|
17
17
|
import { createGaugeLogger } from '../hooks/logger';
|
|
18
|
+
import { describeArtifacts } from '../hooks/types';
|
|
18
19
|
import type { HookDefinition, HookLogger, HookResult } from '../hooks/types';
|
|
19
20
|
import { type ModuleManifest, getSingularSystemSpec } from '../manifest/schema';
|
|
20
21
|
import { decryptSecret } from '../secrets/encryption';
|
|
@@ -624,10 +625,9 @@ async function deployModuleImpl(
|
|
|
624
625
|
|
|
625
626
|
if (!hookResult.success) {
|
|
626
627
|
gauge.stop(false);
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
}
|
|
628
|
+
const errorMsg =
|
|
629
|
+
(hookResult.error || 'Configuration validation failed') +
|
|
630
|
+
describeArtifacts(hookResult.artifactPaths);
|
|
631
631
|
return {
|
|
632
632
|
success: false,
|
|
633
633
|
error: errorMsg,
|
|
@@ -72,6 +72,7 @@ const cleanAudit: AuditDeps = {
|
|
|
72
72
|
cliVersion: { installedVersion: '0.1.5', fetcher: async () => '0.1.5' },
|
|
73
73
|
schema: { journal: () => null, applied: () => [], db: fakeDb },
|
|
74
74
|
capabilityAbi: { modules: [] },
|
|
75
|
+
browserPin: { consumers: [], provisioned: null },
|
|
75
76
|
terraformPlan: { modules: [], run: async () => ({ exitCode: 0, stdout: '', stderr: '' }) },
|
|
76
77
|
moduleVersions: { installed: [], fetcher: async () => ({ latest: null }) },
|
|
77
78
|
moduleConfigs: { modules: [] },
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser runtime is described in two languages: an Ansible role
|
|
3
|
+
* installs it, and TypeScript reads it back. Nothing in either language
|
|
4
|
+
* can see the other, so these are the assertions that keep them agreeing.
|
|
5
|
+
*
|
|
6
|
+
* Both facts here have already drifted in other pairs of files in this
|
|
7
|
+
* repo; neither drift is visible in a diff.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import {
|
|
14
|
+
BROWSER_DESCRIPTOR_PATH,
|
|
15
|
+
BROWSER_EXECUTABLE_PATH,
|
|
16
|
+
BROWSER_ROOT,
|
|
17
|
+
} from '@celilo/capabilities';
|
|
18
|
+
|
|
19
|
+
const MODULE_ROOT = join(import.meta.dir, '../../../../modules/celilo-mgmt');
|
|
20
|
+
|
|
21
|
+
function read(relativePath: string): string {
|
|
22
|
+
return readFileSync(join(MODULE_ROOT, relativePath), 'utf-8');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('celilo-mgmt browser provisioning', () => {
|
|
26
|
+
test('the playbook stamps the module version the manifest declares', () => {
|
|
27
|
+
// The descriptor records which celilo-mgmt wrote it. No template prefix
|
|
28
|
+
// exposes a module's own version ($self: resolves module CONFIG), so
|
|
29
|
+
// the playbook carries a literal — and this is what stops it rotting.
|
|
30
|
+
const manifestVersion = read('manifest.yml').match(/^version:\s*(\S+)$/m)?.[1];
|
|
31
|
+
const playbookVersion = read('ansible/playbook.yml.tpl').match(
|
|
32
|
+
/celilo_mgmt_version:\s*"([^"]+)"/,
|
|
33
|
+
)?.[1];
|
|
34
|
+
|
|
35
|
+
expect(manifestVersion).toBeTruthy();
|
|
36
|
+
expect(playbookVersion).toBe(manifestVersion as string);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('the role installs into the tree the resolver reads', () => {
|
|
40
|
+
const debian = read('ansible/roles/celilo-mgmt/tasks/debian.yml');
|
|
41
|
+
|
|
42
|
+
expect(BROWSER_EXECUTABLE_PATH).toBe(`${BROWSER_ROOT}/current/chrome`);
|
|
43
|
+
expect(debian).toContain(`PLAYWRIGHT_BROWSERS_PATH: ${BROWSER_ROOT}`);
|
|
44
|
+
expect(debian).toContain(`dest: ${BROWSER_EXECUTABLE_PATH}`);
|
|
45
|
+
expect(debian).toContain(`dest: ${BROWSER_DESCRIPTOR_PATH}`);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('the install is gated, so a default host downloads nothing', () => {
|
|
49
|
+
const debian = read('ansible/roles/celilo-mgmt/tasks/debian.yml');
|
|
50
|
+
const manifest = read('manifest.yml');
|
|
51
|
+
|
|
52
|
+
expect(debian).toContain('when: celilo_install_browser | bool');
|
|
53
|
+
// The gate is worthless if the flag defaults on: the e2e topology has
|
|
54
|
+
// no real internet, and the browser comes from an external CDN.
|
|
55
|
+
expect(manifest).toMatch(/name: install_browser[\s\S]*?default: false/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('the role provisions the headless shell, not full Chromium', () => {
|
|
59
|
+
// Reversed after measuring celilo-mgr: both builds there are headless
|
|
60
|
+
// shells and chromium.launch() has been driving one in production all
|
|
61
|
+
// along. Provisioning the full browser would change what works.
|
|
62
|
+
const debian = read('ansible/roles/celilo-mgmt/tasks/debian.yml');
|
|
63
|
+
|
|
64
|
+
expect(debian).toContain('install chromium-headless-shell');
|
|
65
|
+
expect(debian).toContain("'flavor': 'headless-shell'");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { describe, expect, test } from 'bun:test';
|
|
17
|
+
import { unlinkSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { BROWSER_EXECUTABLE_PATH } from '@celilo/capabilities';
|
|
17
19
|
import {
|
|
18
20
|
PREREQUISITES,
|
|
19
21
|
checkPrerequisite,
|
|
@@ -52,6 +54,8 @@ describe('PREREQUISITES table', () => {
|
|
|
52
54
|
expect(names).toContain('git');
|
|
53
55
|
expect(names).toContain('curl');
|
|
54
56
|
expect(names).toContain('unzip');
|
|
57
|
+
expect(names).toContain('browser');
|
|
58
|
+
expect(names).toContain('fonts');
|
|
55
59
|
});
|
|
56
60
|
});
|
|
57
61
|
|
|
@@ -313,6 +317,75 @@ describe('checkPrerequisite', () => {
|
|
|
313
317
|
});
|
|
314
318
|
});
|
|
315
319
|
|
|
320
|
+
// ── Absolute-path prerequisites (the browser) ─────────────────────────
|
|
321
|
+
|
|
322
|
+
describe('absolute-path prerequisites', () => {
|
|
323
|
+
test('a real executable at an absolute path is present, with its version', () => {
|
|
324
|
+
// `bun --version` prints a bare semver, and Bun.which finds a real path
|
|
325
|
+
// for it — so this exercises the absolute-path branch end to end.
|
|
326
|
+
const bunPath = Bun.which('bun');
|
|
327
|
+
if (!bunPath) throw new Error('bun is not on PATH');
|
|
328
|
+
const result = checkPrerequisite({
|
|
329
|
+
name: 'browser',
|
|
330
|
+
description: 'stand-in for the provisioned browser',
|
|
331
|
+
command: bunPath,
|
|
332
|
+
versionFlag: '--version',
|
|
333
|
+
versionRegex: /(\d+\.\d+\.\d+)/,
|
|
334
|
+
minVersion: null,
|
|
335
|
+
});
|
|
336
|
+
expect(result.present).toBe(true);
|
|
337
|
+
expect(result.binaryPath).toBe(bunPath);
|
|
338
|
+
expect(result.version).toMatch(/^\d+\.\d+\.\d+$/);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('a DIRECTORY at the declared path is not present', () => {
|
|
342
|
+
// The case that motivates the whole check: a build directory with no
|
|
343
|
+
// binary in it satisfies a path test and then fails at launch.
|
|
344
|
+
const result = checkPrerequisite({
|
|
345
|
+
name: 'browser',
|
|
346
|
+
description: 'stand-in for the provisioned browser',
|
|
347
|
+
command: '/tmp',
|
|
348
|
+
versionFlag: '--version',
|
|
349
|
+
versionRegex: /(\d+\.\d+\.\d+)/,
|
|
350
|
+
minVersion: null,
|
|
351
|
+
});
|
|
352
|
+
expect(result.present).toBe(false);
|
|
353
|
+
expect(result.binaryPath).toBeNull();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('a non-executable file at the declared path is not present', () => {
|
|
357
|
+
const path = `/tmp/celilo-prereq-not-executable-${process.pid}`;
|
|
358
|
+
writeFileSync(path, 'not a browser', { mode: 0o644 });
|
|
359
|
+
try {
|
|
360
|
+
const result = checkPrerequisite({
|
|
361
|
+
name: 'browser',
|
|
362
|
+
description: 'stand-in for the provisioned browser',
|
|
363
|
+
command: path,
|
|
364
|
+
versionFlag: '--version',
|
|
365
|
+
versionRegex: /(\d+\.\d+\.\d+)/,
|
|
366
|
+
minVersion: null,
|
|
367
|
+
});
|
|
368
|
+
expect(result.present).toBe(false);
|
|
369
|
+
} finally {
|
|
370
|
+
unlinkSync(path);
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test('the browser row names install_browser as its remedy', () => {
|
|
375
|
+
const spec = PREREQUISITES.find((p) => p.name === 'browser');
|
|
376
|
+
if (!spec) throw new Error('browser spec missing');
|
|
377
|
+
expect(spec.command).toBe(BROWSER_EXECUTABLE_PATH);
|
|
378
|
+
expect(getInstallHint('browser', 'apt')).toContain('install_browser');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test('fc-match output yields the resolved font family', () => {
|
|
382
|
+
const spec = PREREQUISITES.find((p) => p.name === 'fonts');
|
|
383
|
+
if (!spec) throw new Error('fonts spec missing');
|
|
384
|
+
const sample = 'DejaVuSans.ttf: "DejaVu Sans" "Book"';
|
|
385
|
+
expect(sample.match(spec.versionRegex)?.[1]).toBe('DejaVu Sans');
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
316
389
|
// ── failingPrerequisites ──────────────────────────────────────────────
|
|
317
390
|
|
|
318
391
|
describe('failingPrerequisites', () => {
|