@celilo/cli 1.14.0 → 2.1.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_CORE_MODULES.md +2 -2
- package/CELILO_SUBSYSTEMS.md +26 -3
- package/README.md +0 -2
- package/drizzle/0030_drop_module_builds_environment.sql +8 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/capabilities/public-web-publish.test.ts +18 -0
- package/src/cli/commands/alerts-sweep.ts +3 -0
- package/src/cli/commands/monitor.ts +15 -2
- package/src/cli/commands/system-doctor.test.ts +121 -1
- package/src/cli/commands/system-doctor.ts +151 -1
- package/src/cli/completion.ts +9 -2
- package/src/cli/index.ts +1 -1
- package/src/console/control-plane-boundary.test.ts +82 -4
- package/src/db/schema.ts +0 -1
- package/src/hooks/capability-loader.ts +15 -2
- package/src/hooks/executor.ts +116 -17
- package/src/hooks/hook-jail-toolchain-reach.test.ts +273 -0
- package/src/hooks/hook-jail-unreachability.test.ts +77 -26
- package/src/hooks/hook-protocol.ts +44 -0
- package/src/hooks/hook-runner-entry.ts +23 -0
- package/src/hooks/hook-runner.ts +10 -0
- package/src/hooks/hook-trespass.test.ts +74 -12
- package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
- package/src/hooks/jail.test.ts +92 -0
- package/src/hooks/jail.ts +304 -32
- package/src/hooks/mount-set.test.ts +116 -7
- package/src/hooks/mount-set.ts +189 -20
- package/src/hooks/remote-broker.test.ts +350 -0
- package/src/hooks/remote-broker.ts +404 -0
- package/src/hooks/run-named-hook.ts +2 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
- package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
- package/src/hooks/unjailed-lint.test.ts +254 -0
- package/src/hooks/unjailed-lint.ts +395 -0
- package/src/policy/module-business-baseline.ts +13 -1
- package/src/policy/module-script-scan.ts +60 -1
- package/src/policy/no-hand-built-ssh.test.ts +39 -1
- package/src/policy/no-module-business-in-core.test.ts +1 -1
- package/src/services/alerting/coverage-source.test.ts +86 -0
- package/src/services/alerting/coverage-source.ts +11 -1
- package/src/services/alerting/hook-jail.test.ts +66 -0
- package/src/services/alerting/hook-jail.ts +70 -0
- package/src/services/alerting/run-monitor.test.ts +62 -0
- package/src/services/alerting/run-monitor.ts +12 -0
- package/src/services/alerting/sweep-runner.test.ts +1 -0
- package/src/services/backup-create.ts +3 -0
- package/src/services/backup-restore.ts +2 -0
- package/src/services/control-plane-bootstrap.test.ts +177 -0
- package/src/services/control-plane-bootstrap.ts +176 -0
- package/src/services/control-plane-health.test.ts +66 -0
- package/src/services/control-plane-health.ts +67 -0
- package/src/services/deploy-ansible.ts +9 -1
- package/src/services/deployed-systems.ts +12 -0
- package/src/services/dns-discovery.test.ts +93 -0
- package/src/services/dns-discovery.ts +92 -0
- package/src/services/fleet-checks.ts +6 -2
- package/src/services/health-runner.ts +36 -3
- package/src/services/module-build.test.ts +1 -64
- package/src/services/module-build.ts +10 -86
- package/src/services/module-deploy.ts +71 -0
- package/src/services/remote-access.test.ts +223 -0
- package/src/services/remote-access.ts +149 -0
- package/src/services/restore-from-file.ts +6 -1
- package/src/services/static-content-converge.test.ts +338 -0
- package/src/services/static-content-converge.ts +299 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The control plane's health check (celilo#1225).
|
|
3
|
+
*
|
|
4
|
+
* The mapping is what these pin. The failure that made this code exist was not
|
|
5
|
+
* a wrong assertion — the hook's `existsSync(db_path)` was a perfectly correct
|
|
6
|
+
* question — it was a correct question asked from inside a jail that does not
|
|
7
|
+
* bind the directory, so a present database read as absent. Nothing in a unit
|
|
8
|
+
* test can catch that shape; what a unit test CAN hold is that every finding
|
|
9
|
+
* reaches the operator with its status and its remediation intact, because the
|
|
10
|
+
* unattended path is the one nobody is watching.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
import { findingAsHealthCheck } from './control-plane-health';
|
|
15
|
+
import type { FleetFinding } from './fleet-checks';
|
|
16
|
+
|
|
17
|
+
function finding(over: Partial<FleetFinding> = {}): FleetFinding {
|
|
18
|
+
return {
|
|
19
|
+
id: 'dispatcher',
|
|
20
|
+
title: 'Event dispatcher',
|
|
21
|
+
status: 'ok',
|
|
22
|
+
summary: 'a dispatcher is live',
|
|
23
|
+
detail: [],
|
|
24
|
+
remediation: null,
|
|
25
|
+
autoFixable: false,
|
|
26
|
+
...over,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('findingAsHealthCheck', () => {
|
|
31
|
+
test("ok becomes pass — celilo's vocabulary is not the health path's", () => {
|
|
32
|
+
expect(findingAsHealthCheck(finding({ status: 'ok' })).status).toBe('pass');
|
|
33
|
+
expect(findingAsHealthCheck(finding({ status: 'warn' })).status).toBe('warn');
|
|
34
|
+
expect(findingAsHealthCheck(finding({ status: 'fail' })).status).toBe('fail');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('the check is named by the finding id, so an operator can grep for it', () => {
|
|
38
|
+
const item = findingAsHealthCheck(finding({ id: 'schema_drift' }));
|
|
39
|
+
expect(item.name).toBe('schema_drift');
|
|
40
|
+
expect(item.message).toBe('a dispatcher is live');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('the remediation survives the mapping', () => {
|
|
44
|
+
// This is the line that tells an operator what to DO, and the health path
|
|
45
|
+
// is the one that runs unattended every fifteen minutes. Dropping it here
|
|
46
|
+
// would leave a failing check with no next step, quietly.
|
|
47
|
+
const item = findingAsHealthCheck(
|
|
48
|
+
finding({
|
|
49
|
+
status: 'fail',
|
|
50
|
+
detail: ['no live dispatcher — heartbeat absent'],
|
|
51
|
+
remediation: 'systemctl enable --now celilo-events.service',
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
expect(item.details).toContain('no live dispatcher — heartbeat absent');
|
|
55
|
+
expect(item.details).toContain('Fix: systemctl enable --now celilo-events.service');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a healthy finding carries no empty details field', () => {
|
|
59
|
+
expect(findingAsHealthCheck(finding()).details).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('detail lines survive without a remediation', () => {
|
|
63
|
+
const item = findingAsHealthCheck(finding({ status: 'warn', detail: ['two dispatchers'] }));
|
|
64
|
+
expect(item.details).toBe('two dispatchers');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The control plane's health check, run by celilo rather than by a hook.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is not a hook
|
|
5
|
+
*
|
|
6
|
+
* It was `modules/celilo-mgmt/scripts/health-check.ts` until celilo#1225, and
|
|
7
|
+
* it failed under the hook jail in the way that is worst: **it reported a
|
|
8
|
+
* healthy fleet's database missing.** The hook asked `existsSync(db_path)`,
|
|
9
|
+
* the jail does not bind celilo's data directory (deliberately — that is where
|
|
10
|
+
* `master.key` and `celilo.db` live), so the answer was `false` and the deploy
|
|
11
|
+
* failed on a file that was sitting right there. Its other two checks shelled
|
|
12
|
+
* out to the CLI, which a jailed hook cannot reach at all.
|
|
13
|
+
*
|
|
14
|
+
* Every one of those was celilo inspecting itself. Deleting `on_install` for
|
|
15
|
+
* that reason and leaving this behind would have fixed one hook and left the
|
|
16
|
+
* deploy failing on the next.
|
|
17
|
+
*
|
|
18
|
+
* ## What replaced it is stronger, not merely relocated
|
|
19
|
+
*
|
|
20
|
+
* The hook asked two questions: does a file exist, and is this hostname in
|
|
21
|
+
* `celilo machine list`. `runFleetChecks` is what celilo already runs for
|
|
22
|
+
* `celilo system doctor`, and it asks eight, including the four-part dispatcher
|
|
23
|
+
* probe whose whole point is that "a process is up" reports green on a
|
|
24
|
+
* dispatcher that is unsupervised, on stale code, and delivering nothing.
|
|
25
|
+
*
|
|
26
|
+
* So the module gets a better check by having none of its own. That is the
|
|
27
|
+
* outcome to keep in mind before anyone adds a hook back: a health_check hook
|
|
28
|
+
* here can only re-ask, worse, questions celilo can answer directly.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { DbClient } from '../db/client';
|
|
32
|
+
import { withCeliloBusAsync } from './control-plane-bootstrap';
|
|
33
|
+
import { type FleetFinding, runFleetChecks } from './fleet-checks';
|
|
34
|
+
import type { HealthCheckItem } from './health-runner';
|
|
35
|
+
|
|
36
|
+
/** `FleetFinding`'s three statuses in `HealthCheckItem`'s vocabulary. */
|
|
37
|
+
const STATUS: Record<FleetFinding['status'], HealthCheckItem['status']> = {
|
|
38
|
+
ok: 'pass',
|
|
39
|
+
warn: 'warn',
|
|
40
|
+
fail: 'fail',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One finding as a health item.
|
|
45
|
+
*
|
|
46
|
+
* `detail` and `remediation` are folded into `details` rather than dropped:
|
|
47
|
+
* the health path is what runs unattended every fifteen minutes, so the line
|
|
48
|
+
* telling an operator what to DO has to survive the mapping.
|
|
49
|
+
*/
|
|
50
|
+
export function findingAsHealthCheck(finding: FleetFinding): HealthCheckItem {
|
|
51
|
+
const details = [
|
|
52
|
+
...finding.detail,
|
|
53
|
+
...(finding.remediation ? [`Fix: ${finding.remediation}`] : []),
|
|
54
|
+
];
|
|
55
|
+
return {
|
|
56
|
+
name: finding.id,
|
|
57
|
+
status: STATUS[finding.status],
|
|
58
|
+
message: finding.summary,
|
|
59
|
+
...(details.length > 0 ? { details: details.join('\n') } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Run celilo's own fleet checks and report them as the control plane's health. */
|
|
64
|
+
export async function controlPlaneHealthChecks(db: DbClient): Promise<HealthCheckItem[]> {
|
|
65
|
+
const findings = await withCeliloBusAsync((bus) => runFleetChecks(bus, db));
|
|
66
|
+
return findings.map(findingAsHealthCheck);
|
|
67
|
+
}
|
|
@@ -138,7 +138,7 @@ function parseAnsibleLine(line: string): string | null {
|
|
|
138
138
|
*/
|
|
139
139
|
export async function executeAnsible(
|
|
140
140
|
generatedPath: string,
|
|
141
|
-
options?: { noInteractive?: boolean; check?: boolean },
|
|
141
|
+
options?: { noInteractive?: boolean; check?: boolean; tags?: string[] },
|
|
142
142
|
): Promise<AnsibleResult> {
|
|
143
143
|
const ansibleDir = join(generatedPath, 'ansible');
|
|
144
144
|
const inventoryPath = join(ansibleDir, 'inventory', 'hosts.ini');
|
|
@@ -166,6 +166,14 @@ export async function executeAnsible(
|
|
|
166
166
|
// stored record of the host (celilo#902 design D6). One argument, not a
|
|
167
167
|
// second execution path — everything else about the run is identical.
|
|
168
168
|
...(options?.check ? ['--check'] : []),
|
|
169
|
+
// `--tags` scopes the run to the tagged tasks. The static-content
|
|
170
|
+
// converge uses it (capability-owned-tables D10): the full playbook
|
|
171
|
+
// re-templates the bootstrap Caddyfile, which would overwrite the
|
|
172
|
+
// REAL config the provider's reconcile wrote to disk and reload caddy
|
|
173
|
+
// into serving nothing. A publish converge must touch /srv/www and
|
|
174
|
+
// nothing else. An unscoped run (the deploy) still executes every
|
|
175
|
+
// untagged task alongside the tagged ones.
|
|
176
|
+
...(options?.tags?.length ? ['--tags', options.tags.join(',')] : []),
|
|
169
177
|
shellEscape(playbookPath),
|
|
170
178
|
],
|
|
171
179
|
cwd: ansibleDir,
|
|
@@ -48,6 +48,18 @@ function rowToSystem(row: typeof moduleSystems.$inferSelect): DeployedSystem {
|
|
|
48
48
|
* Returns [] for API-only modules (e.g. namecheap) — that is a modeled state,
|
|
49
49
|
* not an error.
|
|
50
50
|
*/
|
|
51
|
+
/**
|
|
52
|
+
* The module that IS celilo's control plane.
|
|
53
|
+
*
|
|
54
|
+
* celilo knows this module by name in several places — its privileged
|
|
55
|
+
* capability allow-list, the subnet it trusts, the fleet checks that ask where
|
|
56
|
+
* the control plane runs, and the deploy step that initialises the box's own
|
|
57
|
+
* state. The id lives here because every one of those already reaches for
|
|
58
|
+
* `getModuleSystems`, and three separate private copies of the string is how
|
|
59
|
+
* they drift.
|
|
60
|
+
*/
|
|
61
|
+
export const CONTROL_PLANE_MODULE_ID = 'celilo-mgmt';
|
|
62
|
+
|
|
51
63
|
export function getModuleSystems(moduleId: string, db: DbClient): DeployedSystem[] {
|
|
52
64
|
const rows = db.select().from(moduleSystems).where(eq(moduleSystems.moduleId, moduleId)).all();
|
|
53
65
|
return rows.map(rowToSystem).sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Moved from `modules/celilo-mgmt/scripts/discovery.test.ts` with celilo#1225.
|
|
3
|
+
*
|
|
4
|
+
* The parser cases came across unchanged. The `discoverDns` cases are new and
|
|
5
|
+
* could not have been written there: the module's version read the filesystem
|
|
6
|
+
* directly, so the file-preference rule — the part with the actual failure mode
|
|
7
|
+
* — had no seam to test through.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import { type ResolverFileReader, chooseDns, discoverDns, parseNameservers } from './dns-discovery';
|
|
12
|
+
|
|
13
|
+
const SYSTEMD = '/run/systemd/resolve/resolv.conf';
|
|
14
|
+
const ETC = '/etc/resolv.conf';
|
|
15
|
+
|
|
16
|
+
/** A host offering exactly the files given, and nothing else. */
|
|
17
|
+
function host(files: Record<string, string>): ResolverFileReader {
|
|
18
|
+
return (path) => files[path] ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('parseNameservers', () => {
|
|
22
|
+
test('extracts nameserver IPs in order', () => {
|
|
23
|
+
const resolv = ['# comment', 'nameserver 9.9.9.9', 'nameserver 8.8.4.4', 'search lan'].join(
|
|
24
|
+
'\n',
|
|
25
|
+
);
|
|
26
|
+
expect(parseNameservers(resolv)).toEqual(['9.9.9.9', '8.8.4.4']);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('returns empty for no nameservers', () => {
|
|
30
|
+
expect(parseNameservers('search lan\noptions edns0')).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('chooseDns', () => {
|
|
35
|
+
test('drops loopback stubs (systemd-resolved 127.0.0.53)', () => {
|
|
36
|
+
expect(chooseDns(['127.0.0.53'])).toEqual({ primary: '1.1.1.1', fallback: '1.1.1.1' });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('uses the first two real upstreams', () => {
|
|
40
|
+
expect(chooseDns(['9.9.9.9', '8.8.4.4', '1.0.0.1'])).toEqual({
|
|
41
|
+
primary: '9.9.9.9',
|
|
42
|
+
fallback: '8.8.4.4',
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('falls back to 1.1.1.1 for the second when only one upstream', () => {
|
|
47
|
+
expect(chooseDns(['9.9.9.9'])).toEqual({ primary: '9.9.9.9', fallback: '1.1.1.1' });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('discoverDns', () => {
|
|
52
|
+
test('prefers the systemd upstream file over the stub in /etc/resolv.conf', () => {
|
|
53
|
+
// The case the file order exists for. Reading only /etc/resolv.conf here
|
|
54
|
+
// discovers 127.0.0.53, which no other box on the network can reach.
|
|
55
|
+
const servers = discoverDns(
|
|
56
|
+
host({
|
|
57
|
+
[SYSTEMD]: 'nameserver 9.9.9.9\nnameserver 8.8.4.4\n',
|
|
58
|
+
[ETC]: 'nameserver 127.0.0.53\n',
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
expect(servers).toEqual({ primary: '9.9.9.9', fallback: '8.8.4.4' });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('falls through to /etc/resolv.conf when the systemd file yields nothing real', () => {
|
|
65
|
+
const servers = discoverDns(
|
|
66
|
+
host({
|
|
67
|
+
[SYSTEMD]: 'nameserver 127.0.0.53\n',
|
|
68
|
+
[ETC]: 'nameserver 192.0.2.10\nnameserver 192.0.2.11\n',
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
expect(servers).toEqual({ primary: '192.0.2.10', fallback: '192.0.2.11' });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('reads /etc/resolv.conf on a host with no systemd resolver file', () => {
|
|
75
|
+
expect(discoverDns(host({ [ETC]: 'nameserver 192.0.2.10\n' }))).toEqual({
|
|
76
|
+
primary: '192.0.2.10',
|
|
77
|
+
fallback: '1.1.1.1',
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('accepts a loopback-only /etc/resolv.conf as the public fallback, not as an error', () => {
|
|
82
|
+
// Last resort: whatever it gives is taken, and a stub-only host yields the
|
|
83
|
+
// public fallback rather than an unreachable address.
|
|
84
|
+
expect(discoverDns(host({ [ETC]: 'nameserver 127.0.0.53\n' }))).toEqual({
|
|
85
|
+
primary: '1.1.1.1',
|
|
86
|
+
fallback: '1.1.1.1',
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('returns the public fallback on a host with no resolver files at all', () => {
|
|
91
|
+
expect(discoverDns(host({}))).toEqual({ primary: '1.1.1.1', fallback: '1.1.1.1' });
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the upstream DNS resolvers this box uses.
|
|
3
|
+
*
|
|
4
|
+
* This sits beside `network-discovery.ts` and for the same reason: celilo
|
|
5
|
+
* records facts about the management box itself, and a module is not the right
|
|
6
|
+
* place to read them. The network half moved first
|
|
7
|
+
* (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md),
|
|
8
|
+
* the fleet key followed (openspec/changes/hook-process-boundary, design D9b),
|
|
9
|
+
* and DNS is the last of the three. It came from
|
|
10
|
+
* `modules/celilo-mgmt/scripts/discovery.ts`, whose hook could only reach
|
|
11
|
+
* celilo by spawning the CLI — which a jailed hook cannot do at all
|
|
12
|
+
* (celilo#1225).
|
|
13
|
+
*
|
|
14
|
+
* Reading it here is not merely tidier, it is the only place it can be
|
|
15
|
+
* correct. celilo-mgmt is never deployed to a remote box (ruled 2026-09-02),
|
|
16
|
+
* so the host whose resolvers we want is always the host celilo runs on. A
|
|
17
|
+
* hook reading `/etc/resolv.conf` reads that same file, one process further
|
|
18
|
+
* out, for no benefit.
|
|
19
|
+
*
|
|
20
|
+
* Pure parsers are separated from the IO wrapper so they test without a host
|
|
21
|
+
* (Rule 2.3).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where a host publishes its real upstream resolvers, most trustworthy first.
|
|
28
|
+
*
|
|
29
|
+
* systemd-resolved's file comes first deliberately: on such a host
|
|
30
|
+
* `/etc/resolv.conf` names the local 127.0.0.53 stub rather than the upstreams
|
|
31
|
+
* behind it, so reading only the second file discovers a loopback address and
|
|
32
|
+
* writes it into system config as the fleet's DNS.
|
|
33
|
+
*/
|
|
34
|
+
const RESOLVER_FILES = ['/run/systemd/resolve/resolv.conf', '/etc/resolv.conf'] as const;
|
|
35
|
+
|
|
36
|
+
/** Where we land when the host offers no usable upstream of its own. */
|
|
37
|
+
const PUBLIC_FALLBACK = '1.1.1.1';
|
|
38
|
+
|
|
39
|
+
export interface DnsServers {
|
|
40
|
+
primary: string;
|
|
41
|
+
fallback: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Extract `nameserver` IPs from resolv.conf-format text, in file order. */
|
|
45
|
+
export function parseNameservers(resolvConf: string): string[] {
|
|
46
|
+
return resolvConf
|
|
47
|
+
.split('\n')
|
|
48
|
+
.map((l) => l.trim())
|
|
49
|
+
.filter((l) => l.startsWith('nameserver '))
|
|
50
|
+
.map((l) => l.split(/\s+/)[1])
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Choose primary and fallback from discovered nameservers.
|
|
56
|
+
*
|
|
57
|
+
* Loopback entries are dropped rather than used. A stub resolver's address is
|
|
58
|
+
* a real answer to "what does this host query" and the wrong answer to "what
|
|
59
|
+
* should the fleet query", because nothing else on the network can reach it.
|
|
60
|
+
*/
|
|
61
|
+
export function chooseDns(nameservers: string[]): DnsServers {
|
|
62
|
+
const upstream = nameservers.filter((ns) => !ns.startsWith('127.') && ns !== '::1');
|
|
63
|
+
return {
|
|
64
|
+
primary: upstream[0] ?? PUBLIC_FALLBACK,
|
|
65
|
+
fallback: upstream[1] ?? PUBLIC_FALLBACK,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Reads a resolver file, or returns null when it is not present. */
|
|
70
|
+
export type ResolverFileReader = (path: string) => string | null;
|
|
71
|
+
|
|
72
|
+
const readResolverFile: ResolverFileReader = (path) =>
|
|
73
|
+
existsSync(path) ? readFileSync(path, 'utf-8') : null;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read this box's upstream resolvers.
|
|
77
|
+
*
|
|
78
|
+
* Takes the systemd file only when it yielded a real server. `/etc/resolv.conf`
|
|
79
|
+
* is the last resort and whatever it gives is accepted, including the fallback,
|
|
80
|
+
* because there is nothing further to consult.
|
|
81
|
+
*/
|
|
82
|
+
export function discoverDns(read: ResolverFileReader = readResolverFile): DnsServers {
|
|
83
|
+
for (const path of RESOLVER_FILES) {
|
|
84
|
+
const contents = read(path);
|
|
85
|
+
if (contents === null) continue;
|
|
86
|
+
const servers = chooseDns(parseNameservers(contents));
|
|
87
|
+
if (servers.primary !== PUBLIC_FALLBACK || path === '/etc/resolv.conf') {
|
|
88
|
+
return servers;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { primary: PUBLIC_FALLBACK, fallback: PUBLIC_FALLBACK };
|
|
92
|
+
}
|
|
@@ -29,7 +29,11 @@ import { loadControlPlaneSubnet, resolveFirewallNatIp } from '../hooks/capabilit
|
|
|
29
29
|
import type { ModuleManifest } from '../manifest/schema';
|
|
30
30
|
|
|
31
31
|
import { getServiceCredentials, listContainerServices } from './container-service';
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
CONTROL_PLANE_MODULE_ID,
|
|
34
|
+
getModuleSystems,
|
|
35
|
+
listAllModuleSystems,
|
|
36
|
+
} from './deployed-systems';
|
|
33
37
|
import { listDnsInternalRecords } from './dns-internal-records';
|
|
34
38
|
import {
|
|
35
39
|
SUPERVISOR_SCOPES,
|
|
@@ -43,7 +47,7 @@ import { describePausedModule, listPausedModules } from './module-pause';
|
|
|
43
47
|
import { resolveSubscription } from './module-subscriptions';
|
|
44
48
|
|
|
45
49
|
/** The module that IS celilo's control plane. */
|
|
46
|
-
const CONTROL_PLANE_MODULE =
|
|
50
|
+
const CONTROL_PLANE_MODULE = CONTROL_PLANE_MODULE_ID;
|
|
47
51
|
|
|
48
52
|
/**
|
|
49
53
|
* Zones reachable from the operator's LAN. A celilo placement zone other
|
|
@@ -17,7 +17,9 @@ import type { HookLogger, HookResult } from '../hooks/types';
|
|
|
17
17
|
import type { ModuleManifest } from '../manifest/schema';
|
|
18
18
|
import { decryptSecret } from '../secrets/encryption';
|
|
19
19
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
20
|
-
import {
|
|
20
|
+
import { controlPlaneHealthChecks } from './control-plane-health';
|
|
21
|
+
import { CONTROL_PLANE_MODULE_ID, getModuleSystems } from './deployed-systems';
|
|
22
|
+
import { remoteAccessPolicy } from './remote-access';
|
|
21
23
|
|
|
22
24
|
export interface HealthCheckItem {
|
|
23
25
|
name: string;
|
|
@@ -151,6 +153,19 @@ export async function runModuleHealthCheck(
|
|
|
151
153
|
return { moduleId, status: 'error', checks: [], error: 'Module not found' };
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
// The control plane checks itself, in celilo's own process (celilo#1225).
|
|
157
|
+
//
|
|
158
|
+
// This was a health_check hook and it failed under the jail in the worst
|
|
159
|
+
// possible way: it asked `existsSync(db_path)`, the jail deliberately does
|
|
160
|
+
// not bind celilo's data directory, and so it reported a healthy fleet's
|
|
161
|
+
// database MISSING and failed the deploy over a file that was right there.
|
|
162
|
+
// `runFleetChecks` is what `celilo system doctor` already runs and asks
|
|
163
|
+
// eight questions where the hook asked two.
|
|
164
|
+
if (moduleId === CONTROL_PLANE_MODULE_ID) {
|
|
165
|
+
const checks = await controlPlaneHealthChecks(db);
|
|
166
|
+
return finishHealthCheck(moduleId, module.state, checks, db, options);
|
|
167
|
+
}
|
|
168
|
+
|
|
154
169
|
const manifest = module.manifestData as ModuleManifest;
|
|
155
170
|
const hookDef = manifest.hooks?.health_check;
|
|
156
171
|
|
|
@@ -196,6 +211,7 @@ export async function runModuleHealthCheck(
|
|
|
196
211
|
capabilities: capabilityFunctions,
|
|
197
212
|
requiredCapabilities,
|
|
198
213
|
systems: getModuleSystems(moduleId, db),
|
|
214
|
+
remoteAccess: remoteAccessPolicy(moduleId, db),
|
|
199
215
|
},
|
|
200
216
|
);
|
|
201
217
|
presentation.finish(hookResult.success);
|
|
@@ -220,6 +236,23 @@ export async function runModuleHealthCheck(
|
|
|
220
236
|
};
|
|
221
237
|
|
|
222
238
|
const checks = outputs.checks || [];
|
|
239
|
+
return finishHealthCheck(moduleId, module.state, checks, db, options, hookResult.artifactPaths);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Derive the verdict from the named checks and move the module's state.
|
|
244
|
+
*
|
|
245
|
+
* Shared by the hook path and the control plane's own path so the two cannot
|
|
246
|
+
* drift on what `degraded` means or on when a state transition is suppressed.
|
|
247
|
+
*/
|
|
248
|
+
function finishHealthCheck(
|
|
249
|
+
moduleId: string,
|
|
250
|
+
moduleState: ModuleState,
|
|
251
|
+
checks: HealthCheckItem[],
|
|
252
|
+
db: DbClient,
|
|
253
|
+
options: HealthCheckOptions,
|
|
254
|
+
artifactPaths?: string[],
|
|
255
|
+
): HealthCheckResult {
|
|
223
256
|
const hasFail = checks.some((c) => c.status === 'fail');
|
|
224
257
|
const hasWarn = checks.some((c) => c.status === 'warn');
|
|
225
258
|
|
|
@@ -229,12 +262,12 @@ export async function runModuleHealthCheck(
|
|
|
229
262
|
? 'degraded'
|
|
230
263
|
: 'healthy';
|
|
231
264
|
|
|
232
|
-
const nextState = nextModuleState(
|
|
265
|
+
const nextState = nextModuleState(moduleState, status, options.unattended ?? false);
|
|
233
266
|
if (nextState) {
|
|
234
267
|
db.update(modules).set({ state: nextState }).where(eq(modules.id, moduleId)).run();
|
|
235
268
|
}
|
|
236
269
|
|
|
237
|
-
return { moduleId, status, checks, artifactPaths
|
|
270
|
+
return { moduleId, status, checks, ...(artifactPaths ? { artifactPaths } : {}) };
|
|
238
271
|
}
|
|
239
272
|
|
|
240
273
|
/**
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect,
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { eq } from 'drizzle-orm';
|
|
5
|
-
import { log } from '../cli/prompts';
|
|
6
5
|
import { type DbClient, createDbClient } from '../db/client';
|
|
7
6
|
import { moduleBuilds, modules } from '../db/schema';
|
|
8
7
|
import { skipIntegration } from '../test-utils/integration-guard';
|
|
@@ -40,7 +39,6 @@ describe('Module Build Service', () => {
|
|
|
40
39
|
version TEXT NOT NULL,
|
|
41
40
|
built_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
42
41
|
artifacts TEXT NOT NULL,
|
|
43
|
-
environment TEXT,
|
|
44
42
|
status TEXT NOT NULL,
|
|
45
43
|
build_log TEXT,
|
|
46
44
|
FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE
|
|
@@ -98,65 +96,6 @@ describe('Module Build Service', () => {
|
|
|
98
96
|
expect(result.error).toContain('does not have a build section');
|
|
99
97
|
});
|
|
100
98
|
|
|
101
|
-
test('should detect Nix environment from flake.nix', async () => {
|
|
102
|
-
// Create test module directory with flake.nix
|
|
103
|
-
await mkdir(TEST_MODULE_DIR, { recursive: true });
|
|
104
|
-
await mkdir(`${TEST_MODULE_DIR}/build`, { recursive: true });
|
|
105
|
-
await writeFile(`${TEST_MODULE_DIR}/flake.nix`, '# Nix flake');
|
|
106
|
-
await writeFile(
|
|
107
|
-
`${TEST_MODULE_DIR}/build/playbook.yml`,
|
|
108
|
-
`---
|
|
109
|
-
- name: Test build
|
|
110
|
-
hosts: localhost
|
|
111
|
-
gather_facts: false
|
|
112
|
-
tasks:
|
|
113
|
-
- name: Debug message
|
|
114
|
-
ansible.builtin.debug:
|
|
115
|
-
msg: "Nix build test"
|
|
116
|
-
`,
|
|
117
|
-
);
|
|
118
|
-
|
|
119
|
-
db.insert(modules)
|
|
120
|
-
.values({
|
|
121
|
-
id: 'nix-module',
|
|
122
|
-
name: 'Nix Module',
|
|
123
|
-
version: '1.0.0',
|
|
124
|
-
sourcePath: TEST_MODULE_DIR,
|
|
125
|
-
manifestData: {
|
|
126
|
-
id: 'nix-module',
|
|
127
|
-
name: 'Nix Module',
|
|
128
|
-
version: '1.0.0',
|
|
129
|
-
build: {
|
|
130
|
-
script: 'build/playbook.yml',
|
|
131
|
-
},
|
|
132
|
-
},
|
|
133
|
-
})
|
|
134
|
-
.run();
|
|
135
|
-
|
|
136
|
-
// Mock log.info / log.warn to capture output (module-build.ts now
|
|
137
|
-
// routes through these instead of console.log so output flows through
|
|
138
|
-
// the active ProgressDisplay during a real deploy).
|
|
139
|
-
const logInfoSpy = spyOn(log, 'info').mockImplementation(() => {});
|
|
140
|
-
const logWarnSpy = spyOn(log, 'warn').mockImplementation(() => {});
|
|
141
|
-
|
|
142
|
-
const _result = await buildModuleFromSource('nix-module', db);
|
|
143
|
-
|
|
144
|
-
// Verify Nix detection or fallback message was logged
|
|
145
|
-
// If nix is available: "Entering Nix environment"
|
|
146
|
-
// If nix not available: "flake.nix detected but nix command not available"
|
|
147
|
-
const calls = [...logInfoSpy.mock.calls, ...logWarnSpy.mock.calls].map((c) => c[0]);
|
|
148
|
-
const hasNixMessage = calls.some(
|
|
149
|
-
(msg) => msg.includes('Nix environment') || msg.includes('flake.nix detected'),
|
|
150
|
-
);
|
|
151
|
-
expect(hasNixMessage).toBe(true);
|
|
152
|
-
|
|
153
|
-
logInfoSpy.mockRestore();
|
|
154
|
-
logWarnSpy.mockRestore();
|
|
155
|
-
|
|
156
|
-
// Build result depends on whether Nix is installed
|
|
157
|
-
// We're just testing that detection happens (message logged)
|
|
158
|
-
}, 10000); // 10 second timeout
|
|
159
|
-
|
|
160
99
|
test.skipIf(skipIntegration({ tools: ['ansible'] }))(
|
|
161
100
|
'should record build metadata in database',
|
|
162
101
|
async () => {
|
|
@@ -208,7 +147,6 @@ describe('Module Build Service', () => {
|
|
|
208
147
|
expect(buildRecord?.moduleId).toBe('record-test');
|
|
209
148
|
expect(buildRecord?.version).toBe('1.0.0');
|
|
210
149
|
expect(buildRecord?.status).toBe('success');
|
|
211
|
-
expect(buildRecord?.environment).toBe('system'); // No flake.nix
|
|
212
150
|
},
|
|
213
151
|
10000,
|
|
214
152
|
); // 10 second timeout for ansible execution
|
|
@@ -282,7 +220,6 @@ describe('Module Build Service', () => {
|
|
|
282
220
|
artifacts: ['/path/to/artifact'],
|
|
283
221
|
status: 'success',
|
|
284
222
|
buildLog: 'Build completed',
|
|
285
|
-
environment: 'nix',
|
|
286
223
|
})
|
|
287
224
|
.run();
|
|
288
225
|
|