@celilo/cli 1.14.0 → 2.0.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 +1 -1
- package/CELILO_SUBSYSTEMS.md +25 -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 +14 -0
- package/src/hooks/executor.ts +110 -17
- package/src/hooks/hook-jail-toolchain-reach.test.ts +224 -0
- package/src/hooks/hook-jail-unreachability.test.ts +28 -2
- 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 +9 -3
- package/src/hooks/jail-browser-launch-flags.test.ts +34 -0
- package/src/hooks/jail.test.ts +92 -0
- package/src/hooks/jail.ts +128 -11
- package/src/hooks/mount-set.test.ts +28 -6
- package/src/hooks/mount-set.ts +34 -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 +251 -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/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/deploy-ansible.ts +9 -1
- package/src/services/health-runner.ts +2 -0
- package/src/services/module-build.test.ts +1 -64
- package/src/services/module-build.ts +10 -86
- package/src/services/module-deploy.ts +20 -0
- package/src/services/remote-access.test.ts +139 -0
- package/src/services/remote-access.ts +98 -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,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook-jail regression — a host that used to jail its hooks and has stopped.
|
|
3
|
+
*
|
|
4
|
+
* Design D8's three states want three different things (see
|
|
5
|
+
* openspec/changes/hook-process-boundary/design.md): jailed is normal and
|
|
6
|
+
* raises nothing; a host that never jailed (a Mac) is a configuration fact
|
|
7
|
+
* that `celilo system doctor` reports and is NOT an event; a host that ran
|
|
8
|
+
* hooks jailed yesterday and runs them unjailed today is a kernel upgrade, an
|
|
9
|
+
* AppArmor policy change, or a changed container profile, and is exactly the
|
|
10
|
+
* failure that looks like nothing. Only the third state raises here.
|
|
11
|
+
*
|
|
12
|
+
* The state this reads is `hook-jail-mode.json`, written by `recordJailMode`
|
|
13
|
+
* on every real module-hook run. The transition survives the record being
|
|
14
|
+
* overwritten because an unjailed record carries `lastJailed` — the jailed
|
|
15
|
+
* record it replaced on the same host.
|
|
16
|
+
*
|
|
17
|
+
* The monitor scheduling this check is a SELF-monitor: `celilo monitor add
|
|
18
|
+
* hook_jail` creates it `suppressible: false`, so per
|
|
19
|
+
* openspec/specs/alerting/spec.md no ancestor alert and no deploy window can
|
|
20
|
+
* silence it. Losing confinement during a broad outage is precisely when it
|
|
21
|
+
* must still page.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately cheap: one local file read, no remote contact, safe on every
|
|
24
|
+
* sweep.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { AlertSeverity } from '../../db/schema';
|
|
28
|
+
import type { JailModeRecord } from '../../hooks/jail';
|
|
29
|
+
import { type FailingKey, builtinAlertKey } from './keys';
|
|
30
|
+
|
|
31
|
+
export const HOOK_JAIL_CHECK = 'hook_jail';
|
|
32
|
+
|
|
33
|
+
export interface HookJailState {
|
|
34
|
+
/** The recorded mode, or undefined when no hook has recorded one yet. */
|
|
35
|
+
record: JailModeRecord | undefined;
|
|
36
|
+
/** The host this check runs on, compared against the record's own. */
|
|
37
|
+
host: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One failing key when this host used to jail and has stopped; empty
|
|
42
|
+
* otherwise. The message names the host and the reason, which is what the
|
|
43
|
+
* security-model spec requires the alert to carry.
|
|
44
|
+
*
|
|
45
|
+
* A record written by another host is ignored rather than compared: a database
|
|
46
|
+
* or data directory restored onto a new box is a move, not a regression, and
|
|
47
|
+
* the new box has its own history to make.
|
|
48
|
+
*/
|
|
49
|
+
export function hookJailFailingKeys(state: HookJailState, severity: AlertSeverity): FailingKey[] {
|
|
50
|
+
const { record, host } = state;
|
|
51
|
+
if (!record || record.host !== host) return [];
|
|
52
|
+
if (record.mode !== 'unjailed' || !record.lastJailed) return [];
|
|
53
|
+
|
|
54
|
+
return [
|
|
55
|
+
{
|
|
56
|
+
key: builtinAlertKey(HOOK_JAIL_CHECK, 'host', host),
|
|
57
|
+
severity,
|
|
58
|
+
message:
|
|
59
|
+
`Hooks on ${host} ran jailed (${record.lastJailed.backend}) until ` +
|
|
60
|
+
`${record.lastJailed.recordedAt} and now run unjailed: ` +
|
|
61
|
+
`${record.reason ?? 'no reason was recorded'}`,
|
|
62
|
+
details:
|
|
63
|
+
'A host that quietly stops confining hooks is a failure that otherwise\n' +
|
|
64
|
+
'looks like nothing — the usual causes are a kernel upgrade, an AppArmor\n' +
|
|
65
|
+
'policy change, or a changed container profile. `celilo system doctor`\n' +
|
|
66
|
+
'reports what the host can do right now. The alert resolves when a hook\n' +
|
|
67
|
+
'runs jailed on this host again.',
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
}
|
|
@@ -7,6 +7,7 @@ import type { DbClient } from '../../db/client';
|
|
|
7
7
|
import { type Monitor, alerts, monitorRuns, monitors } from '../../db/schema';
|
|
8
8
|
import { setupTestDatabaseAt } from '../../test-utils/database';
|
|
9
9
|
import type { HealthCheckResult } from '../health-runner';
|
|
10
|
+
import { HOOK_JAIL_CHECK, type HookJailState } from './hook-jail';
|
|
10
11
|
import { moduleAlertKey, moduleCheckAlertKey } from './keys';
|
|
11
12
|
import { type MonitorRunDeps, runOneMonitor } from './run-monitor';
|
|
12
13
|
import { loadLiveAlerts, markUnsuppressed } from './store';
|
|
@@ -40,6 +41,7 @@ describe('runOneMonitor', () => {
|
|
|
40
41
|
runModuleCheck: async () => result,
|
|
41
42
|
runBuiltinCheck: async () => [],
|
|
42
43
|
loadModuleCoverage: () => [],
|
|
44
|
+
loadJailState: () => ({ record: undefined, host: 'test-host' }),
|
|
43
45
|
now: () => now,
|
|
44
46
|
graceMs: 60_000,
|
|
45
47
|
};
|
|
@@ -182,4 +184,64 @@ describe('runOneMonitor', () => {
|
|
|
182
184
|
expect(summary.outcome).toBe('success');
|
|
183
185
|
expect(liveKeys()).toEqual([]);
|
|
184
186
|
});
|
|
187
|
+
|
|
188
|
+
describe('the hook-jail self-monitor (hook-process-boundary task 4.4)', () => {
|
|
189
|
+
const HOST = 'celilo-mgr';
|
|
190
|
+
const regressed: HookJailState = {
|
|
191
|
+
host: HOST,
|
|
192
|
+
record: {
|
|
193
|
+
mode: 'unjailed',
|
|
194
|
+
backend: 'none',
|
|
195
|
+
reason: 'bubblewrap is installed but could not build a namespace',
|
|
196
|
+
host: HOST,
|
|
197
|
+
recordedAt: NOW.toISOString(),
|
|
198
|
+
lastJailed: { backend: 'bubblewrap', recordedAt: '2026-07-27T03:00:00.000Z' },
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
const healed: HookJailState = {
|
|
202
|
+
host: HOST,
|
|
203
|
+
record: {
|
|
204
|
+
mode: 'jailed',
|
|
205
|
+
backend: 'bubblewrap',
|
|
206
|
+
host: HOST,
|
|
207
|
+
recordedAt: LATER.toISOString(),
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
let jailMonitor: Monitor;
|
|
212
|
+
|
|
213
|
+
beforeEach(() => {
|
|
214
|
+
db.insert(monitors)
|
|
215
|
+
.values({
|
|
216
|
+
id: 'mon-jail',
|
|
217
|
+
kind: 'builtin_check',
|
|
218
|
+
target: HOOK_JAIL_CHECK,
|
|
219
|
+
intervalMinutes: 15,
|
|
220
|
+
severity: 'critical',
|
|
221
|
+
suppressible: false,
|
|
222
|
+
})
|
|
223
|
+
.run();
|
|
224
|
+
jailMonitor = db.select().from(monitors).where(eq(monitors.id, 'mon-jail')).get() as Monitor;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const jailDeps = (state: HookJailState, now: Date = NOW): MonitorRunDeps => ({
|
|
228
|
+
...deps(healthy, now),
|
|
229
|
+
loadJailState: () => state,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('a jailed-to-unjailed transition fires an alert naming the host', async () => {
|
|
233
|
+
const summary = await runOneMonitor(db, jailMonitor, jailDeps(regressed));
|
|
234
|
+
expect(summary.outcome).toBe('success');
|
|
235
|
+
const live = loadLiveAlerts(db, jailMonitor.id);
|
|
236
|
+
expect(live.map((a) => a.key)).toEqual([`builtin:hook_jail/host:${HOST}`]);
|
|
237
|
+
const row = db.select().from(alerts).where(eq(alerts.monitorId, jailMonitor.id)).get();
|
|
238
|
+
expect(row?.message).toContain(HOST);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('re-jailing resolves it by set difference', async () => {
|
|
242
|
+
await runOneMonitor(db, jailMonitor, jailDeps(regressed));
|
|
243
|
+
await runOneMonitor(db, jailMonitor, jailDeps(healed, LATER));
|
|
244
|
+
expect(loadLiveAlerts(db, jailMonitor.id)).toEqual([]);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
185
247
|
});
|
|
@@ -19,6 +19,7 @@ import type { HealthCheckResult } from '../health-runner';
|
|
|
19
19
|
import { failingKeysFromFindings } from './builtin-monitors';
|
|
20
20
|
import { HEALTH_COVERAGE_CHECK, healthCoverageFailingKeys } from './health-coverage';
|
|
21
21
|
import type { ModuleCoverageInput } from './health-coverage';
|
|
22
|
+
import { HOOK_JAIL_CHECK, type HookJailState, hookJailFailingKeys } from './hook-jail';
|
|
22
23
|
import {
|
|
23
24
|
type FailingKey,
|
|
24
25
|
builtinMonitorKey,
|
|
@@ -35,6 +36,8 @@ export interface MonitorRunDeps {
|
|
|
35
36
|
runBuiltinCheck(category: DriftCategory): Promise<DriftFinding[]>;
|
|
36
37
|
/** Module roster for the health-coverage check. */
|
|
37
38
|
loadModuleCoverage(): ModuleCoverageInput[];
|
|
39
|
+
/** Recorded jail mode and current host, for the hook-jail self-monitor. */
|
|
40
|
+
loadJailState(): HookJailState;
|
|
38
41
|
now(): Date;
|
|
39
42
|
/** Grace window before a new alert may notify. */
|
|
40
43
|
graceMs: number;
|
|
@@ -95,6 +98,15 @@ async function runCheck(
|
|
|
95
98
|
};
|
|
96
99
|
}
|
|
97
100
|
|
|
101
|
+
// Same shape: one local file read. This is the self-monitor for D8's third
|
|
102
|
+
// state — a host that used to jail its hooks and has stopped.
|
|
103
|
+
if (monitor.target === HOOK_JAIL_CHECK) {
|
|
104
|
+
return {
|
|
105
|
+
outcome: 'success',
|
|
106
|
+
failingKeys: hookJailFailingKeys(deps.loadJailState(), severity),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
98
110
|
const category = monitor.target as DriftCategory;
|
|
99
111
|
try {
|
|
100
112
|
const findings = await deps.runBuiltinCheck(category);
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
refuseIfInFlight,
|
|
42
42
|
startOperation,
|
|
43
43
|
} from './module-operations';
|
|
44
|
+
import { remoteAccessPolicy } from './remote-access';
|
|
44
45
|
import { stageSystemState } from './system-state-stage';
|
|
45
46
|
|
|
46
47
|
export interface BackupCreateOptions {
|
|
@@ -399,6 +400,7 @@ export async function createModuleBackup(
|
|
|
399
400
|
{
|
|
400
401
|
debug: false,
|
|
401
402
|
systems: getModuleSystems(moduleId, db),
|
|
403
|
+
remoteAccess: remoteAccessPolicy(moduleId, db),
|
|
402
404
|
},
|
|
403
405
|
);
|
|
404
406
|
|
|
@@ -563,6 +565,7 @@ export async function importModuleBackup(
|
|
|
563
565
|
{
|
|
564
566
|
debug: false,
|
|
565
567
|
systems: getModuleSystems(moduleId, db),
|
|
568
|
+
remoteAccess: remoteAccessPolicy(moduleId, db),
|
|
566
569
|
},
|
|
567
570
|
);
|
|
568
571
|
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
refuseIfInFlight,
|
|
31
31
|
startOperation,
|
|
32
32
|
} from './module-operations';
|
|
33
|
+
import { remoteAccessPolicy } from './remote-access';
|
|
33
34
|
|
|
34
35
|
export interface RestoreResult {
|
|
35
36
|
success: boolean;
|
|
@@ -288,6 +289,7 @@ export async function restoreModuleBackup(
|
|
|
288
289
|
{
|
|
289
290
|
debug: false,
|
|
290
291
|
systems: getModuleSystems(backup.moduleId, db),
|
|
292
|
+
remoteAccess: remoteAccessPolicy(backup.moduleId, db),
|
|
291
293
|
},
|
|
292
294
|
);
|
|
293
295
|
|
|
@@ -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,
|
|
@@ -18,6 +18,7 @@ import type { ModuleManifest } from '../manifest/schema';
|
|
|
18
18
|
import { decryptSecret } from '../secrets/encryption';
|
|
19
19
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
20
20
|
import { getModuleSystems } from './deployed-systems';
|
|
21
|
+
import { remoteAccessPolicy } from './remote-access';
|
|
21
22
|
|
|
22
23
|
export interface HealthCheckItem {
|
|
23
24
|
name: string;
|
|
@@ -196,6 +197,7 @@ export async function runModuleHealthCheck(
|
|
|
196
197
|
capabilities: capabilityFunctions,
|
|
197
198
|
requiredCapabilities,
|
|
198
199
|
systems: getModuleSystems(moduleId, db),
|
|
200
|
+
remoteAccess: remoteAccessPolicy(moduleId, db),
|
|
199
201
|
},
|
|
200
202
|
);
|
|
201
203
|
presentation.finish(hookResult.success);
|
|
@@ -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
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Module Build Service
|
|
3
3
|
*
|
|
4
|
-
* Executes module build scripts (Ansible playbooks)
|
|
4
|
+
* Executes module build scripts (Ansible playbooks)
|
|
5
5
|
* to compile custom software binaries before deployment.
|
|
6
6
|
*
|
|
7
7
|
* Example: Caddy with RFC2136 DNS provider
|
|
@@ -40,65 +40,26 @@ export interface BuildStatusRecord {
|
|
|
40
40
|
buildLog?: string;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
/**
|
|
44
|
-
* Detect if module uses Nix environment
|
|
45
|
-
* Policy function - checks for flake.nix existence
|
|
46
|
-
*
|
|
47
|
-
* @param modulePath - Path to module directory
|
|
48
|
-
* @returns True if flake.nix exists
|
|
49
|
-
*/
|
|
50
|
-
function detectNixEnvironment(modulePath: string): boolean {
|
|
51
|
-
const flakePath = `${modulePath}/flake.nix`;
|
|
52
|
-
return existsSync(flakePath);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Check if nix command is available
|
|
57
|
-
* Policy function - validates nix availability
|
|
58
|
-
*
|
|
59
|
-
* @returns True if nix command can be executed
|
|
60
|
-
*/
|
|
61
|
-
async function isNixAvailable(): Promise<boolean> {
|
|
62
|
-
const { spawn } = await import('node:child_process');
|
|
63
|
-
|
|
64
|
-
return new Promise((resolve) => {
|
|
65
|
-
const child = spawn('nix', ['--version'], {
|
|
66
|
-
stdio: 'ignore',
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
child.on('close', (code) => {
|
|
70
|
-
resolve(code === 0);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
child.on('error', () => {
|
|
74
|
-
resolve(false);
|
|
75
|
-
});
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
|
|
79
43
|
/**
|
|
80
44
|
* Execute build playbook with streaming progress
|
|
81
45
|
* Execution function - runs ansible-playbook with fuel-gauge progress indicator
|
|
82
46
|
*
|
|
83
47
|
* @param modulePath - Path to module directory
|
|
84
|
-
* @param
|
|
85
|
-
* @param useNix - Whether to run inside Nix environment
|
|
48
|
+
* @param commandStr - Build command to execute
|
|
86
49
|
* @returns Build result with output
|
|
87
50
|
*/
|
|
88
51
|
async function executeBuildCommand(
|
|
89
52
|
modulePath: string,
|
|
90
53
|
commandStr: string,
|
|
91
|
-
useNix: boolean,
|
|
92
54
|
): Promise<{ success: boolean; output: string; error?: string }> {
|
|
93
55
|
const { executeBuildWithProgress } = await import('./build-stream');
|
|
94
|
-
const { shellEscape } = await import('../utils/shell');
|
|
95
56
|
|
|
96
57
|
// build-stream passes through `shell: true`, so spawn re-wraps everything in
|
|
97
58
|
// `/bin/sh -c`. If we sent ['bash', '-c', commandStr] as command+args, sh
|
|
98
59
|
// would re-parse the join — `&&` outside the inner quotes splits the chain
|
|
99
60
|
// and `bun install` runs in modulePath instead of the cd'd subdir. Pass the
|
|
100
61
|
// full command as one string so sh -c gets a single, unsplit shell command.
|
|
101
|
-
const command =
|
|
62
|
+
const command = commandStr;
|
|
102
63
|
|
|
103
64
|
const result = await executeBuildWithProgress({
|
|
104
65
|
command,
|
|
@@ -117,7 +78,6 @@ async function executeBuildCommand(
|
|
|
117
78
|
async function executeBuildScript(
|
|
118
79
|
modulePath: string,
|
|
119
80
|
scriptPath: string,
|
|
120
|
-
useNix: boolean,
|
|
121
81
|
): Promise<{ success: boolean; output: string; error?: string }> {
|
|
122
82
|
const fullScriptPath = `${modulePath}/${scriptPath}`;
|
|
123
83
|
|
|
@@ -132,11 +92,7 @@ async function executeBuildScript(
|
|
|
132
92
|
let command: string;
|
|
133
93
|
let args: string[];
|
|
134
94
|
|
|
135
|
-
if (
|
|
136
|
-
const innerCommand = isShellScript ? 'bash' : 'ansible-playbook';
|
|
137
|
-
command = 'nix';
|
|
138
|
-
args = ['develop', '--command', innerCommand, scriptPath];
|
|
139
|
-
} else if (isShellScript) {
|
|
95
|
+
if (isShellScript) {
|
|
140
96
|
command = 'bash';
|
|
141
97
|
args = [scriptPath];
|
|
142
98
|
} else {
|
|
@@ -207,7 +163,6 @@ function formatBytes(bytes: number): string {
|
|
|
207
163
|
* @param artifacts - List of artifact paths
|
|
208
164
|
* @param status - Build status
|
|
209
165
|
* @param buildLog - Build output log
|
|
210
|
-
* @param environment - Build environment (nix or system)
|
|
211
166
|
* @param db - Database client
|
|
212
167
|
*/
|
|
213
168
|
async function recordBuildMetadata(
|
|
@@ -216,7 +171,6 @@ async function recordBuildMetadata(
|
|
|
216
171
|
artifacts: string[],
|
|
217
172
|
status: BuildStatus,
|
|
218
173
|
buildLog: string,
|
|
219
|
-
environment: 'nix' | 'system' | null,
|
|
220
174
|
db: DbClient,
|
|
221
175
|
): Promise<void> {
|
|
222
176
|
await db.insert(moduleBuilds).values({
|
|
@@ -225,7 +179,6 @@ async function recordBuildMetadata(
|
|
|
225
179
|
artifacts,
|
|
226
180
|
status,
|
|
227
181
|
buildLog,
|
|
228
|
-
environment,
|
|
229
182
|
});
|
|
230
183
|
}
|
|
231
184
|
|
|
@@ -268,38 +221,18 @@ export async function buildModuleFromSource(moduleId: string, db: DbClient): Pro
|
|
|
268
221
|
};
|
|
269
222
|
}
|
|
270
223
|
|
|
271
|
-
// Detect Nix environment and verify availability
|
|
272
|
-
const nixDetected = detectNixEnvironment(modulePath);
|
|
273
|
-
const nixAvailable = nixDetected ? await isNixAvailable() : false;
|
|
274
|
-
const useNix = nixDetected && nixAvailable;
|
|
275
|
-
const environment: 'nix' | 'system' = useNix ? 'nix' : 'system';
|
|
276
|
-
|
|
277
|
-
if (useNix) {
|
|
278
|
-
log.info('Entering Nix environment (flake.nix detected)...');
|
|
279
|
-
} else if (nixDetected && !nixAvailable) {
|
|
280
|
-
log.warn('flake.nix detected but nix command not available - using system Ansible');
|
|
281
|
-
}
|
|
282
|
-
|
|
283
224
|
try {
|
|
284
225
|
// Execute build script with streaming progress
|
|
285
226
|
const buildResult = command
|
|
286
|
-
? await executeBuildCommand(modulePath, command
|
|
287
|
-
: await executeBuildScript(modulePath, script as string
|
|
227
|
+
? await executeBuildCommand(modulePath, command)
|
|
228
|
+
: await executeBuildScript(modulePath, script as string);
|
|
288
229
|
|
|
289
230
|
// Check if build command failed
|
|
290
231
|
if (!buildResult.success) {
|
|
291
232
|
const errorMsg = buildResult.error || 'Build command failed';
|
|
292
233
|
|
|
293
234
|
// Record failed build
|
|
294
|
-
await recordBuildMetadata(
|
|
295
|
-
moduleId,
|
|
296
|
-
manifest.version,
|
|
297
|
-
[],
|
|
298
|
-
'failed',
|
|
299
|
-
buildResult.output,
|
|
300
|
-
environment,
|
|
301
|
-
db,
|
|
302
|
-
);
|
|
235
|
+
await recordBuildMetadata(moduleId, manifest.version, [], 'failed', buildResult.output, db);
|
|
303
236
|
|
|
304
237
|
return {
|
|
305
238
|
success: false,
|
|
@@ -327,7 +260,6 @@ export async function buildModuleFromSource(moduleId: string, db: DbClient): Pro
|
|
|
327
260
|
[],
|
|
328
261
|
'failed',
|
|
329
262
|
`${buildLog}\n\n${errorMsg}`,
|
|
330
|
-
environment,
|
|
331
263
|
db,
|
|
332
264
|
);
|
|
333
265
|
|
|
@@ -346,15 +278,7 @@ export async function buildModuleFromSource(moduleId: string, db: DbClient): Pro
|
|
|
346
278
|
|
|
347
279
|
// Record successful build
|
|
348
280
|
const artifactPaths = verification.map((v) => v.path);
|
|
349
|
-
await recordBuildMetadata(
|
|
350
|
-
moduleId,
|
|
351
|
-
manifest.version,
|
|
352
|
-
artifactPaths,
|
|
353
|
-
'success',
|
|
354
|
-
buildLog,
|
|
355
|
-
environment,
|
|
356
|
-
db,
|
|
357
|
-
);
|
|
281
|
+
await recordBuildMetadata(moduleId, manifest.version, artifactPaths, 'success', buildLog, db);
|
|
358
282
|
|
|
359
283
|
return {
|
|
360
284
|
success: true,
|
|
@@ -364,7 +288,7 @@ export async function buildModuleFromSource(moduleId: string, db: DbClient): Pro
|
|
|
364
288
|
}
|
|
365
289
|
|
|
366
290
|
// No artifacts defined, just record success
|
|
367
|
-
await recordBuildMetadata(moduleId, manifest.version, [], 'success', buildLog,
|
|
291
|
+
await recordBuildMetadata(moduleId, manifest.version, [], 'success', buildLog, db);
|
|
368
292
|
|
|
369
293
|
return {
|
|
370
294
|
success: true,
|
|
@@ -376,7 +300,7 @@ export async function buildModuleFromSource(moduleId: string, db: DbClient): Pro
|
|
|
376
300
|
const buildLog = `Build failed: ${errorMsg}`;
|
|
377
301
|
|
|
378
302
|
// Record failed build
|
|
379
|
-
await recordBuildMetadata(moduleId, manifest.version, [], 'failed', buildLog,
|
|
303
|
+
await recordBuildMetadata(moduleId, manifest.version, [], 'failed', buildLog, db);
|
|
380
304
|
|
|
381
305
|
return {
|
|
382
306
|
success: false,
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
runProviderBackfill,
|
|
47
47
|
} from './provider-arrival';
|
|
48
48
|
import { checkProxmoxReachable, formatProxmoxUnreachableError } from './proxmox-preflight';
|
|
49
|
+
import { remoteAccessPolicy } from './remote-access';
|
|
49
50
|
import { LOCAL_MACHINE_IP, deleteTemporarySshKey, writeTemporarySshKey } from './ssh-key-manager';
|
|
50
51
|
import { buildTerraformEnvForService } from './terraform-env';
|
|
51
52
|
|
|
@@ -720,6 +721,7 @@ async function deployModuleImpl(
|
|
|
720
721
|
capabilities: capabilityFunctions,
|
|
721
722
|
requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
|
|
722
723
|
systems: getModuleSystems(moduleId, db),
|
|
724
|
+
remoteAccess: remoteAccessPolicy(moduleId, db),
|
|
723
725
|
},
|
|
724
726
|
);
|
|
725
727
|
|
|
@@ -858,6 +860,7 @@ async function deployModuleImpl(
|
|
|
858
860
|
capabilities: capFns,
|
|
859
861
|
requiredCapabilities: (manifest.requires?.capabilities ?? []).map((c) => c.name),
|
|
860
862
|
systems: getModuleSystems(moduleId, db),
|
|
863
|
+
remoteAccess: remoteAccessPolicy(moduleId, db),
|
|
861
864
|
},
|
|
862
865
|
};
|
|
863
866
|
},
|
|
@@ -1159,6 +1162,7 @@ async function deployModuleImpl(
|
|
|
1159
1162
|
capabilities: capFunctions,
|
|
1160
1163
|
requiredCapabilities: providerManifest.requires.capabilities.map((c) => c.name),
|
|
1161
1164
|
systems: getModuleSystems(capRecord.moduleId, db),
|
|
1165
|
+
remoteAccess: remoteAccessPolicy(capRecord.moduleId, db),
|
|
1162
1166
|
},
|
|
1163
1167
|
);
|
|
1164
1168
|
|
|
@@ -1308,6 +1312,21 @@ async function deployModuleImpl(
|
|
|
1308
1312
|
}
|
|
1309
1313
|
}
|
|
1310
1314
|
|
|
1315
|
+
// The provider's own deploy runs the static-content converge alongside
|
|
1316
|
+
// everything else (design D10, task 4.5): the release set is written into
|
|
1317
|
+
// the generated inventory, the playbook's static_content-tagged tasks
|
|
1318
|
+
// converge /srv/www, and a rebuilt host recovers with no consumer
|
|
1319
|
+
// involvement. Same plan + writer the publish-time converge uses — one
|
|
1320
|
+
// code path, two callers.
|
|
1321
|
+
if (manifest.provides?.capabilities?.some((cap) => cap.name === 'public_web')) {
|
|
1322
|
+
const { writeStaticContentVars } = await import('./static-content-converge');
|
|
1323
|
+
const staticVars = await writeStaticContentVars(db, moduleId, generatedPath);
|
|
1324
|
+
if (!staticVars.success) {
|
|
1325
|
+
return { success: false, error: staticVars.error, phases };
|
|
1326
|
+
}
|
|
1327
|
+
log.success('Static-content release set written to the inventory');
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1311
1330
|
try {
|
|
1312
1331
|
const ansibleResult = await executeAnsible(generatedPath, {
|
|
1313
1332
|
noInteractive: !process.stdout.isTTY,
|
|
@@ -1412,6 +1431,7 @@ async function deployModuleImpl(
|
|
|
1412
1431
|
capabilities: capFns,
|
|
1413
1432
|
requiredCapabilities: manifest.requires.capabilities.map((c) => c.name),
|
|
1414
1433
|
systems: getModuleSystems(moduleId, db),
|
|
1434
|
+
remoteAccess: remoteAccessPolicy(moduleId, db),
|
|
1415
1435
|
},
|
|
1416
1436
|
};
|
|
1417
1437
|
},
|