@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.
Files changed (67) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +26 -3
  3. package/README.md +0 -2
  4. package/drizzle/0030_drop_module_builds_environment.sql +8 -0
  5. package/drizzle/meta/_journal.json +8 -1
  6. package/package.json +3 -3
  7. package/src/capabilities/public-web-publish.test.ts +18 -0
  8. package/src/cli/commands/alerts-sweep.ts +3 -0
  9. package/src/cli/commands/monitor.ts +15 -2
  10. package/src/cli/commands/system-doctor.test.ts +121 -1
  11. package/src/cli/commands/system-doctor.ts +151 -1
  12. package/src/cli/completion.ts +9 -2
  13. package/src/cli/index.ts +1 -1
  14. package/src/console/control-plane-boundary.test.ts +82 -4
  15. package/src/db/schema.ts +0 -1
  16. package/src/hooks/capability-loader.ts +15 -2
  17. package/src/hooks/executor.ts +116 -17
  18. package/src/hooks/hook-jail-toolchain-reach.test.ts +273 -0
  19. package/src/hooks/hook-jail-unreachability.test.ts +77 -26
  20. package/src/hooks/hook-protocol.ts +44 -0
  21. package/src/hooks/hook-runner-entry.ts +23 -0
  22. package/src/hooks/hook-runner.ts +10 -0
  23. package/src/hooks/hook-trespass.test.ts +74 -12
  24. package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
  25. package/src/hooks/jail.test.ts +92 -0
  26. package/src/hooks/jail.ts +304 -32
  27. package/src/hooks/mount-set.test.ts +116 -7
  28. package/src/hooks/mount-set.ts +189 -20
  29. package/src/hooks/remote-broker.test.ts +350 -0
  30. package/src/hooks/remote-broker.ts +404 -0
  31. package/src/hooks/run-named-hook.ts +2 -0
  32. package/src/hooks/test-fixtures/jail-probe-hook.ts +14 -1
  33. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +227 -0
  34. package/src/hooks/test-fixtures/remote-bridge-probe.ts +82 -0
  35. package/src/hooks/unjailed-lint.test.ts +254 -0
  36. package/src/hooks/unjailed-lint.ts +395 -0
  37. package/src/policy/module-business-baseline.ts +13 -1
  38. package/src/policy/module-script-scan.ts +60 -1
  39. package/src/policy/no-hand-built-ssh.test.ts +39 -1
  40. package/src/policy/no-module-business-in-core.test.ts +1 -1
  41. package/src/services/alerting/coverage-source.test.ts +86 -0
  42. package/src/services/alerting/coverage-source.ts +11 -1
  43. package/src/services/alerting/hook-jail.test.ts +66 -0
  44. package/src/services/alerting/hook-jail.ts +70 -0
  45. package/src/services/alerting/run-monitor.test.ts +62 -0
  46. package/src/services/alerting/run-monitor.ts +12 -0
  47. package/src/services/alerting/sweep-runner.test.ts +1 -0
  48. package/src/services/backup-create.ts +3 -0
  49. package/src/services/backup-restore.ts +2 -0
  50. package/src/services/control-plane-bootstrap.test.ts +177 -0
  51. package/src/services/control-plane-bootstrap.ts +176 -0
  52. package/src/services/control-plane-health.test.ts +66 -0
  53. package/src/services/control-plane-health.ts +67 -0
  54. package/src/services/deploy-ansible.ts +9 -1
  55. package/src/services/deployed-systems.ts +12 -0
  56. package/src/services/dns-discovery.test.ts +93 -0
  57. package/src/services/dns-discovery.ts +92 -0
  58. package/src/services/fleet-checks.ts +6 -2
  59. package/src/services/health-runner.ts +36 -3
  60. package/src/services/module-build.test.ts +1 -64
  61. package/src/services/module-build.ts +10 -86
  62. package/src/services/module-deploy.ts +71 -0
  63. package/src/services/remote-access.test.ts +223 -0
  64. package/src/services/remote-access.ts +149 -0
  65. package/src/services/restore-from-file.ts +6 -1
  66. package/src/services/static-content-converge.test.ts +338 -0
  67. 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 { getModuleSystems, listAllModuleSystems } from './deployed-systems';
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 = 'celilo-mgmt';
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 { getModuleSystems } from './deployed-systems';
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(module.state, status, options.unattended ?? false);
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: hookResult.artifactPaths };
270
+ return { moduleId, status, checks, ...(artifactPaths ? { artifactPaths } : {}) };
238
271
  }
239
272
 
240
273
  /**
@@ -1,8 +1,7 @@
1
- import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
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