@celilo/cli 1.13.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 +31 -5
- 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-helpers.test.ts +12 -6
- package/src/capabilities/public-web-publish.test.ts +42 -13
- package/src/capabilities/validation.test.ts +31 -0
- package/src/cli/commands/alerts-sweep.ts +3 -0
- package/src/cli/commands/console-get-chain.test.ts +96 -0
- package/src/cli/commands/console.ts +13 -5
- package/src/cli/commands/monitor.ts +15 -2
- package/src/cli/commands/notify-config.test.ts +79 -0
- package/src/cli/commands/notify-config.ts +13 -2
- package/src/cli/commands/system-doctor.test.ts +121 -1
- package/src/cli/commands/system-doctor.ts +151 -1
- package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
- package/src/cli/completion.ts +10 -2
- package/src/cli/index.ts +7 -1
- package/src/console/closure.test.ts +76 -0
- package/src/console/closure.ts +87 -1
- package/src/console/control-plane-boundary.test.ts +82 -4
- package/src/console/projection.test.ts +63 -1
- package/src/console/projection.ts +39 -2
- package/src/db/schema.ts +0 -1
- package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
- package/src/hooks/capability-loader.ts +81 -10
- 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/manifest/contracts/v1.ts +22 -1
- package/src/manifest/validate.ts +25 -4
- package/src/module/web-root.ts +35 -0
- package/src/policy/module-business-baseline.ts +27 -3
- package/src/policy/module-script-scan.test.ts +22 -0
- package/src/policy/module-script-scan.ts +92 -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/api-principal-enrolment.test.ts +73 -0
- package/src/services/api-principal-enrolment.ts +55 -0
- package/src/services/backup-create.ts +36 -7
- package/src/services/backup-restore.ts +2 -0
- package/src/services/celilo-mgmt-hooks.test.ts +38 -79
- package/src/services/deploy-ansible.ts +9 -1
- package/src/services/fleet-key.test.ts +47 -0
- package/src/services/fleet-key.ts +75 -0
- 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 +12 -6
- package/src/services/static-content-converge.test.ts +338 -0
- package/src/services/static-content-converge.ts +299 -0
- package/src/services/system-state-stage.test.ts +165 -0
- package/src/services/system-state-stage.ts +196 -0
|
@@ -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
|
|
|
@@ -1,19 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Behavior tests for the celilo-mgmt module's on_backup / on_restore
|
|
3
|
-
* hooks (Phase 3 of openspec/specs/management-server-backup/spec.md).
|
|
4
|
-
*
|
|
5
|
-
* We import the hook scripts directly and call their handlers with a
|
|
6
|
-
* synthesized HookContext, then assert on the side-effects (files
|
|
7
|
-
* written to backup_dir / restore staging dirs). This catches
|
|
8
|
-
* regressions in the hook bodies without spinning up the full
|
|
9
|
-
* deploy/backup pipeline.
|
|
10
|
-
*
|
|
11
|
-
* What this DOESN'T test: invokeHook validation, end-to-end backup
|
|
12
|
-
* envelope round-trip. Those live in backup-envelope-roundtrip.test.ts
|
|
13
|
-
* and (eventually) a fresh-box e2e.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { Database } from 'bun:sqlite';
|
|
17
1
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
18
2
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
3
|
import { tmpdir } from 'node:os';
|
|
@@ -82,13 +66,13 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
|
|
|
82
66
|
let dir: string;
|
|
83
67
|
let backupDir: string;
|
|
84
68
|
let crossModuleRoot: string;
|
|
85
|
-
let
|
|
86
|
-
let keyPath: string;
|
|
69
|
+
let systemStateRoot: string;
|
|
87
70
|
|
|
88
71
|
beforeEach(() => {
|
|
89
72
|
dir = mkdtempSync(join(tmpdir(), 'celilo-mgmt-backup-test-'));
|
|
90
73
|
backupDir = join(dir, 'backup-dir');
|
|
91
74
|
mkdirSync(backupDir, { recursive: true });
|
|
75
|
+
|
|
92
76
|
crossModuleRoot = join(dir, 'cross-module-root');
|
|
93
77
|
mkdirSync(join(crossModuleRoot, 'modules', 'caddy', 'terraform'), { recursive: true });
|
|
94
78
|
writeFileSync(
|
|
@@ -104,25 +88,20 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
|
|
|
104
88
|
}),
|
|
105
89
|
);
|
|
106
90
|
|
|
107
|
-
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
keyPath = join(dir, 'master.key');
|
|
119
|
-
writeFileSync(keyPath, 'fake-master-key-32-bytes-padding!');
|
|
120
|
-
process.env.CELILO_MASTER_KEY_PATH = keyPath;
|
|
91
|
+
// celilo's own state, as the framework stages it before invoking the hook
|
|
92
|
+
// (services/system-state-stage.ts). The hook reads nothing outside this
|
|
93
|
+
// directory and cross_module_root — that is what design D9b bought, and
|
|
94
|
+
// it is why there is no CELILO_DB_PATH or master.key on disk here.
|
|
95
|
+
systemStateRoot = join(dir, 'system-state');
|
|
96
|
+
mkdirSync(join(systemStateRoot, 'ssh'), { recursive: true });
|
|
97
|
+
mkdirSync(join(systemStateRoot, 'module_src', 'caddy'), { recursive: true });
|
|
98
|
+
writeFileSync(join(systemStateRoot, 'celilo.db'), 'SQLite format 3\u0000snapshot');
|
|
99
|
+
writeFileSync(join(systemStateRoot, 'master.key'), 'fake-master-key-32-bytes-padding!');
|
|
100
|
+
writeFileSync(join(systemStateRoot, 'ssh', 'id_ed25519'), 'PRIVATE');
|
|
101
|
+
writeFileSync(join(systemStateRoot, 'module_src', 'caddy', 'manifest.yml'), 'id: caddy');
|
|
121
102
|
});
|
|
122
103
|
|
|
123
104
|
afterEach(() => {
|
|
124
|
-
process.env.CELILO_DB_PATH = undefined;
|
|
125
|
-
process.env.CELILO_MASTER_KEY_PATH = undefined;
|
|
126
105
|
try {
|
|
127
106
|
rmSync(dir, { recursive: true, force: true });
|
|
128
107
|
} catch {
|
|
@@ -130,16 +109,20 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
|
|
|
130
109
|
}
|
|
131
110
|
});
|
|
132
111
|
|
|
133
|
-
it('
|
|
112
|
+
it('lays the staged celilo state into backup_dir alongside the cross-module state', async () => {
|
|
134
113
|
const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
114
|
+
const result = (await hook(
|
|
115
|
+
buildContext({
|
|
116
|
+
backup_dir: backupDir,
|
|
117
|
+
cross_module_root: crossModuleRoot,
|
|
118
|
+
system_state_root: systemStateRoot,
|
|
119
|
+
}),
|
|
120
|
+
)) as BackupHookOutput;
|
|
140
121
|
|
|
141
122
|
expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true);
|
|
142
123
|
expect(existsSync(join(backupDir, 'master.key'))).toBe(true);
|
|
124
|
+
expect(existsSync(join(backupDir, 'ssh', 'id_ed25519'))).toBe(true);
|
|
125
|
+
expect(existsSync(join(backupDir, 'module_src', 'caddy', 'manifest.yml'))).toBe(true);
|
|
143
126
|
expect(existsSync(join(backupDir, 'machine-pool.json'))).toBe(true);
|
|
144
127
|
expect(
|
|
145
128
|
existsSync(
|
|
@@ -153,39 +136,27 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
|
|
|
153
136
|
expect(result.size_bytes).toBeGreaterThan(0);
|
|
154
137
|
});
|
|
155
138
|
|
|
156
|
-
it('
|
|
157
|
-
// stateDir = dirname(db_path) = dir; on_backup reads dir/modules/<id>.
|
|
158
|
-
const modSrc = join(dir, 'modules', 'caddy');
|
|
159
|
-
mkdirSync(join(modSrc, 'scripts', 'node_modules', '@celilo'), { recursive: true });
|
|
160
|
-
mkdirSync(join(modSrc, 'generated', 'terraform'), { recursive: true });
|
|
161
|
-
mkdirSync(join(modSrc, 'ansible', 'files'), { recursive: true });
|
|
162
|
-
writeFileSync(join(modSrc, 'manifest.yml'), 'id: caddy');
|
|
163
|
-
writeFileSync(join(modSrc, 'scripts', 'hook.ts'), '// hook');
|
|
164
|
-
writeFileSync(join(modSrc, 'generated', 'terraform', 'main.tf'), 'resource {}');
|
|
165
|
-
writeFileSync(join(modSrc, 'scripts', 'node_modules', '@celilo', 'dep.js'), '// vendored');
|
|
166
|
-
// A >2MB "compiled binary" sitting in source — must be skipped by size.
|
|
167
|
-
writeFileSync(join(modSrc, 'ansible', 'files', 'server-bin'), Buffer.alloc(3 * 1024 * 1024));
|
|
168
|
-
|
|
139
|
+
it('refuses when the framework did not stage celilo state', async () => {
|
|
169
140
|
const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
|
|
170
|
-
await hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }));
|
|
171
141
|
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
//
|
|
176
|
-
// not OOM the in-memory tar+encrypt — turnip's full dirs were ~1.6GB).
|
|
177
|
-
expect(existsSync(join(backupDir, 'module_src', 'caddy', 'generated'))).toBe(false);
|
|
178
|
-
expect(existsSync(join(backupDir, 'module_src', 'caddy', 'scripts', 'node_modules'))).toBe(
|
|
179
|
-
false,
|
|
180
|
-
);
|
|
142
|
+
// Silence is the dangerous outcome here. Without the DB and the master
|
|
143
|
+
// key an envelope restores to nothing, and a hook that shrugged and
|
|
144
|
+
// returned success would report artifact_count > 0 for the machine-pool
|
|
145
|
+
// and cross-module files alone. Found at restore, which is too late.
|
|
181
146
|
expect(
|
|
182
|
-
|
|
183
|
-
).
|
|
147
|
+
hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot })),
|
|
148
|
+
).rejects.toThrow(/system_state_root was not provided/);
|
|
184
149
|
});
|
|
185
150
|
|
|
186
151
|
it('machine-pool.json is valid JSON (array)', async () => {
|
|
187
152
|
const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
|
|
188
|
-
await hook(
|
|
153
|
+
await hook(
|
|
154
|
+
buildContext({
|
|
155
|
+
backup_dir: backupDir,
|
|
156
|
+
cross_module_root: crossModuleRoot,
|
|
157
|
+
system_state_root: systemStateRoot,
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
189
160
|
|
|
190
161
|
const machinePool = JSON.parse(readFileSync(join(backupDir, 'machine-pool.json'), 'utf-8'));
|
|
191
162
|
expect(Array.isArray(machinePool)).toBe(true);
|
|
@@ -194,25 +165,13 @@ describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', ()
|
|
|
194
165
|
it('proceeds (with a warning) when cross_module_root is missing', async () => {
|
|
195
166
|
const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
|
|
196
167
|
const result = (await hook(
|
|
197
|
-
buildContext({ backup_dir: backupDir }),
|
|
168
|
+
buildContext({ backup_dir: backupDir, system_state_root: systemStateRoot }),
|
|
198
169
|
)) as BackupHookOutput;
|
|
199
170
|
|
|
200
171
|
expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true);
|
|
201
172
|
expect(existsSync(join(backupDir, 'cross_module_state'))).toBe(false);
|
|
202
173
|
expect(result.schema_version).toBe('1.1');
|
|
203
174
|
});
|
|
204
|
-
|
|
205
|
-
it('proceeds (with a warning) when master.key is missing on disk', async () => {
|
|
206
|
-
rmSync(keyPath);
|
|
207
|
-
const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
|
|
208
|
-
const result = (await hook(
|
|
209
|
-
buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }),
|
|
210
|
-
)) as BackupHookOutput;
|
|
211
|
-
|
|
212
|
-
expect(existsSync(join(backupDir, 'master.key'))).toBe(false);
|
|
213
|
-
expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true);
|
|
214
|
-
expect(result.artifact_count).toBeGreaterThan(0);
|
|
215
|
-
});
|
|
216
175
|
});
|
|
217
176
|
|
|
218
177
|
describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_restore', () => {
|
|
@@ -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,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* celilo's fleet SSH keypair, minted by the framework rather than by
|
|
3
|
+
* celilo-mgmt's on_install (design D9b of
|
|
4
|
+
* openspec/changes/hook-process-boundary).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
8
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { ensureFleetKey, getFleetSshDir } from './fleet-key';
|
|
12
|
+
|
|
13
|
+
describe('ensureFleetKey', () => {
|
|
14
|
+
let dataDir: string;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
dataDir = mkdtempSync(join(tmpdir(), 'celilo-fleet-key-'));
|
|
18
|
+
process.env.CELILO_DB_PATH = join(dataDir, 'celilo.db');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
23
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('mints an ed25519 keypair next to the DB and returns the public half', () => {
|
|
27
|
+
const key = ensureFleetKey();
|
|
28
|
+
|
|
29
|
+
expect(key.created).toBe(true);
|
|
30
|
+
expect(key.publicKey.startsWith('ssh-ed25519 ')).toBe(true);
|
|
31
|
+
expect(existsSync(join(dataDir, '.ssh', 'id_ed25519'))).toBe(true);
|
|
32
|
+
expect(readFileSync(join(dataDir, '.ssh', 'id_ed25519.pub'), 'utf-8').trim()).toBe(
|
|
33
|
+
key.publicKey,
|
|
34
|
+
);
|
|
35
|
+
expect(getFleetSshDir()).toBe(join(dataDir, '.ssh'));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('reuses an existing key instead of re-keying', () => {
|
|
39
|
+
const first = ensureFleetKey();
|
|
40
|
+
const second = ensureFleetKey();
|
|
41
|
+
|
|
42
|
+
// Re-keying would strand every machine whose authorized_keys holds the
|
|
43
|
+
// old public half, and a redeploy calls this every time.
|
|
44
|
+
expect(second.created).toBe(false);
|
|
45
|
+
expect(second.publicKey).toBe(first.publicKey);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* celilo's fleet SSH keypair — the key celilo authenticates to managed
|
|
3
|
+
* machines with. The DB carries only the public half (`ssh.public_key`);
|
|
4
|
+
* the private half lives on disk and never leaves the management box.
|
|
5
|
+
*
|
|
6
|
+
* It used to be minted by celilo-mgmt's `on_install`, which derived
|
|
7
|
+
* `dirname(config.db_path)` and wrote into celilo's data directory from
|
|
8
|
+
* inside a module hook. That is a write into the one directory the hook
|
|
9
|
+
* jail exists to keep out of the mount set
|
|
10
|
+
* (openspec/changes/hook-process-boundary, design D9b): staging covers
|
|
11
|
+
* copies OUT of celilo's state, and nothing covers writes IN.
|
|
12
|
+
*
|
|
13
|
+
* So minting moved here. celilo owns the key's lifecycle, and "create it
|
|
14
|
+
* if absent" wants to be idempotent and tested once rather than in each
|
|
15
|
+
* module that reaches for it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execFileSync } from 'node:child_process';
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
import { getDbPath } from '../config/paths';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Where the fleet keypair lives.
|
|
25
|
+
*
|
|
26
|
+
* Next to the DB, not under `getDataDir()`. Those are the same directory
|
|
27
|
+
* on a deb install (`CELILO_DATA_DIR=/var/celilo`, db at
|
|
28
|
+
* `/var/celilo/celilo.db`) and differ only when `CELILO_DB_PATH` points
|
|
29
|
+
* somewhere custom. Following the DB is what `on_install` did, so it is
|
|
30
|
+
* where every existing box's key already sits, and restore has followed
|
|
31
|
+
* the same rule since it was written (`applyStagedSystemFiles`).
|
|
32
|
+
*
|
|
33
|
+
* One exported helper rather than two hand-derived joins is the point:
|
|
34
|
+
* mint and restore can no longer drift to different directories.
|
|
35
|
+
*/
|
|
36
|
+
export function getFleetSshDir(): string {
|
|
37
|
+
return join(dirname(getDbPath()), '.ssh');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface FleetKey {
|
|
41
|
+
/** The public half, as it goes into `ssh.public_key` and authorized_keys. */
|
|
42
|
+
publicKey: string;
|
|
43
|
+
/** True when this call minted the key; false when it was already there. */
|
|
44
|
+
created: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Ensure the fleet keypair exists and return its public half.
|
|
49
|
+
*
|
|
50
|
+
* Idempotent: an existing key is reused, never regenerated. Re-keying
|
|
51
|
+
* would silently strand every machine whose authorized_keys holds the old
|
|
52
|
+
* public half, and a redeploy must not do that.
|
|
53
|
+
*
|
|
54
|
+
* Permissions are left to `mkdirSync`'s mode and to ssh-keygen, which
|
|
55
|
+
* `fchmod`s the private half to 0600 itself. An explicit chmod pass was
|
|
56
|
+
* written here first and then removed: umask only ever REMOVES mode bits, so
|
|
57
|
+
* neither the directory nor the key can come out wider than asked for, and no
|
|
58
|
+
* test could be made to fail without it.
|
|
59
|
+
*/
|
|
60
|
+
export function ensureFleetKey(): FleetKey {
|
|
61
|
+
const sshDir = getFleetSshDir();
|
|
62
|
+
const keyPath = join(sshDir, 'id_ed25519');
|
|
63
|
+
const publicKeyPath = `${keyPath}.pub`;
|
|
64
|
+
|
|
65
|
+
if (existsSync(publicKeyPath)) {
|
|
66
|
+
return { publicKey: readFileSync(publicKeyPath, 'utf-8').trim(), created: false };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
mkdirSync(sshDir, { recursive: true, mode: 0o700 });
|
|
70
|
+
execFileSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', keyPath, '-C', 'celilo-fleet'], {
|
|
71
|
+
stdio: 'pipe',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return { publicKey: readFileSync(publicKeyPath, 'utf-8').trim(), created: true };
|
|
75
|
+
}
|
|
@@ -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,
|