@celilo/cli 1.12.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +2 -1
- package/CELILO_SUBSYSTEMS.md +21 -2
- package/package.json +3 -3
- package/src/capabilities/public-web-helpers.test.ts +12 -6
- package/src/capabilities/public-web-publish.test.ts +24 -13
- package/src/capabilities/validation.test.ts +31 -0
- package/src/cli/commands/alerts-list.ts +16 -1
- package/src/cli/commands/backup-list.test.ts +82 -1
- package/src/cli/commands/backup-list.ts +113 -4
- package/src/cli/commands/console-get-chain.test.ts +96 -0
- package/src/cli/commands/console.ts +130 -0
- package/src/cli/commands/module-list.ts +3 -41
- package/src/cli/commands/notify-config.test.ts +79 -0
- package/src/cli/commands/notify-config.ts +13 -2
- package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
- package/src/cli/completion.ts +6 -0
- package/src/cli/index.ts +31 -1
- package/src/console/closure.test.ts +322 -0
- package/src/console/closure.ts +294 -0
- package/src/console/control-plane-boundary.test.ts +75 -0
- package/src/console/projection.test.ts +293 -0
- package/src/console/projection.ts +364 -0
- package/src/db/schema.ts +19 -14
- package/src/hooks/broker.test.ts +4 -6
- package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
- package/src/hooks/capability-loader.ts +67 -10
- package/src/hooks/executor.test.ts +85 -4
- package/src/hooks/executor.ts +164 -9
- package/src/hooks/hook-jail-unreachability.test.ts +173 -0
- package/src/hooks/hook-state-dir.test.ts +14 -2
- package/src/hooks/hook-timeout.test.ts +2 -4
- package/src/hooks/hook-trespass.test.ts +50 -5
- package/src/hooks/jail.test.ts +370 -0
- package/src/hooks/jail.ts +491 -0
- package/src/hooks/mount-set.ts +24 -0
- package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
- package/src/manifest/contracts/v1.ts +22 -1
- package/src/manifest/schema.ts +35 -0
- package/src/manifest/validate.test.ts +142 -0
- package/src/manifest/validate.ts +126 -4
- package/src/module/import.test.ts +116 -0
- package/src/module/import.ts +73 -1
- package/src/module/packaging/audit.ts +103 -1
- package/src/module/packaging/classify-module-path.test.ts +36 -0
- package/src/module/packaging/package-rules.ts +18 -0
- package/src/module/web-root.ts +35 -0
- package/src/policy/capability-shape-baseline.ts +8 -0
- package/src/policy/module-business-baseline.ts +26 -2
- package/src/policy/module-script-scan.test.ts +22 -0
- package/src/policy/module-script-scan.ts +32 -0
- package/src/services/alerting/observed-health.ts +71 -0
- package/src/services/api-principal-enrolment.test.ts +252 -0
- package/src/services/api-principal-enrolment.ts +158 -0
- package/src/services/audit/backups.ts +10 -1
- package/src/services/backup-create.ts +33 -7
- package/src/services/backup-metadata.ts +19 -11
- package/src/services/celilo-mgmt-hooks.test.ts +38 -79
- package/src/services/consumer-cleanup.ts +31 -5
- package/src/services/fleet-key.test.ts +47 -0
- package/src/services/fleet-key.ts +75 -0
- package/src/services/instance-ops.test.ts +302 -0
- package/src/services/instance-ops.ts +292 -0
- package/src/services/module-instances.test.ts +428 -42
- package/src/services/module-instances.ts +219 -26
- package/src/services/restore-from-file.ts +6 -5
- package/src/services/system-state-stage.test.ts +165 -0
- package/src/services/system-state-stage.ts +196 -0
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
listBackups,
|
|
11
11
|
loadBackupHistory,
|
|
12
12
|
} from '../../services/backup-metadata';
|
|
13
|
+
import type { BackupHistory } from '../../services/backup-schedule';
|
|
13
14
|
import { getBackupStorage } from '../../services/backup-storage';
|
|
14
15
|
import { celiloIntro } from '../prompts';
|
|
15
16
|
import type { CommandResult } from '../types';
|
|
@@ -136,28 +137,136 @@ export function lastSuccessSummaryLines(
|
|
|
136
137
|
});
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
/**
|
|
141
|
+
* The listing as data, for anything that is not a person reading a terminal.
|
|
142
|
+
*
|
|
143
|
+
* This exists because the human output cannot be parsed back into facts. Ages
|
|
144
|
+
* are rendered relative and BUCKETED — everything past six days collapses to
|
|
145
|
+
* "last week", then "2 weeks ago", then "last month" — so a caller reading the
|
|
146
|
+
* text can place a backup on a day for six days and not one day further.
|
|
147
|
+
*
|
|
148
|
+
* That is fine for a person, who is asking "is this recent". It is not fine for
|
|
149
|
+
* the web console, which draws one square per module per day: a row it cannot
|
|
150
|
+
* place is a row it must leave out, and a missing row renders as a day on which
|
|
151
|
+
* no backup ran. The one thing a coverage grid must never do is invent a gap.
|
|
152
|
+
*
|
|
153
|
+
* So the timestamps go out as epoch ms, exactly as stored, and the caller
|
|
154
|
+
* decides how to say them. Everything else here is what the human path already
|
|
155
|
+
* shows — the same rows and the same per-module summary — because a second
|
|
156
|
+
* source of truth for "what backups exist" is worth less than no second source.
|
|
157
|
+
*/
|
|
158
|
+
/**
|
|
159
|
+
* The two database reads this needs, injectable so the shape can be tested
|
|
160
|
+
* without one (Rule 2.3). The defaults are the real thing.
|
|
161
|
+
*/
|
|
162
|
+
export interface BackupListJsonDeps {
|
|
163
|
+
storageNameOf: (storageId: string) => string;
|
|
164
|
+
historyOf: (moduleId: string) => BackupHistory;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const LIVE_LOOKUPS: BackupListJsonDeps = {
|
|
168
|
+
storageNameOf: (storageId) => getBackupStorage(storageId)?.storageId ?? 'unknown',
|
|
169
|
+
historyOf: loadBackupHistory,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export function backupListJson(
|
|
173
|
+
backupList: Backup[],
|
|
174
|
+
windowDays: number | null,
|
|
175
|
+
deps: BackupListJsonDeps = LIVE_LOOKUPS,
|
|
176
|
+
): CommandResult {
|
|
177
|
+
// One lookup per distinct destination rather than one per row: a month of a
|
|
178
|
+
// failing module is hundreds of rows and two or three storages.
|
|
179
|
+
const storageNames = new Map<string, string>();
|
|
180
|
+
for (const backup of backupList) {
|
|
181
|
+
if (storageNames.has(backup.storageId)) continue;
|
|
182
|
+
storageNames.set(backup.storageId, deps.storageNameOf(backup.storageId));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const payload = {
|
|
186
|
+
asOf: Date.now(),
|
|
187
|
+
/**
|
|
188
|
+
* How far back the rows are COMPLETE for, or null when unbounded.
|
|
189
|
+
*
|
|
190
|
+
* The caller needs this to tell "nothing ran that day" from "you did not
|
|
191
|
+
* ask about that day". They look identical in the data and only one of them
|
|
192
|
+
* is somebody's missing backup.
|
|
193
|
+
*/
|
|
194
|
+
windowDays,
|
|
195
|
+
backups: backupList.map((backup) => ({
|
|
196
|
+
id: backup.id,
|
|
197
|
+
shortId: backup.id.substring(0, 8),
|
|
198
|
+
moduleId: backup.moduleId,
|
|
199
|
+
backupType: backup.backupType,
|
|
200
|
+
status: backup.status,
|
|
201
|
+
sizeBytes: backup.sizeBytes,
|
|
202
|
+
// Epoch ms, both of them. `startedAt` is when the attempt began and
|
|
203
|
+
// `completedAt` is when it finished; a failed attempt has no second one.
|
|
204
|
+
startedAt: backup.startedAt.getTime(),
|
|
205
|
+
completedAt: backup.completedAt ? backup.completedAt.getTime() : null,
|
|
206
|
+
storage: storageNames.get(backup.storageId) ?? 'unknown',
|
|
207
|
+
storagePath: backup.storagePath,
|
|
208
|
+
moduleVersion: backup.moduleVersion,
|
|
209
|
+
schemaVersion: backup.schemaVersion,
|
|
210
|
+
name: backup.name,
|
|
211
|
+
error: backup.errorMessage,
|
|
212
|
+
})),
|
|
213
|
+
modules: backedUpModuleIds(backupList).map((moduleId) => {
|
|
214
|
+
const history = deps.historyOf(moduleId);
|
|
215
|
+
return {
|
|
216
|
+
moduleId,
|
|
217
|
+
lastSuccessAt: history.lastSuccessAt ? history.lastSuccessAt.getTime() : null,
|
|
218
|
+
lastAttemptAt: history.lastAttemptAt ? history.lastAttemptAt.getTime() : null,
|
|
219
|
+
consecutiveFailures: history.consecutiveFailures,
|
|
220
|
+
};
|
|
221
|
+
}),
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// `rawOutput` keeps the payload out of the decorating renderer, which would
|
|
225
|
+
// wrap it and stop it parsing (celilo#698).
|
|
226
|
+
return { success: true, message: JSON.stringify(payload, null, 2), rawOutput: true };
|
|
227
|
+
}
|
|
228
|
+
|
|
139
229
|
export async function handleBackupList(
|
|
140
230
|
args: string[],
|
|
141
231
|
flags: Record<string, boolean | string> = {},
|
|
142
232
|
): Promise<CommandResult> {
|
|
143
233
|
try {
|
|
144
234
|
const moduleIdOrBackupId = args[0];
|
|
145
|
-
|
|
235
|
+
// A JSON caller is a program with a window in mind, not a person skimming a
|
|
236
|
+
// screen, so 20 is the wrong ceiling for it. It still takes --limit.
|
|
237
|
+
const defaultLimit = flags.json ? 5000 : 20;
|
|
238
|
+
const limit = typeof flags.limit === 'string' ? Number.parseInt(flags.limit, 10) : defaultLimit;
|
|
239
|
+
const windowDays = typeof flags.since === 'string' ? Number.parseInt(flags.since, 10) : null;
|
|
240
|
+
if (windowDays !== null && (!Number.isFinite(windowDays) || windowDays <= 0)) {
|
|
241
|
+
return {
|
|
242
|
+
success: false,
|
|
243
|
+
error: `--since takes a positive number of days, got: ${flags.since}`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const since = windowDays === null ? undefined : Date.now() - windowDays * 86_400_000;
|
|
146
247
|
|
|
147
248
|
// If the argument looks like a backup ID (hex chars), show detail view
|
|
148
249
|
if (moduleIdOrBackupId && /^[0-9a-f]{8,}$/i.test(moduleIdOrBackupId)) {
|
|
149
250
|
const backup = getBackup(moduleIdOrBackupId);
|
|
150
251
|
if (backup) {
|
|
252
|
+
// The same envelope as the listing, holding one row. A caller that can
|
|
253
|
+
// parse `backup list --json` can parse this without a second shape, and
|
|
254
|
+
// asking about one backup is the commonest scripted case there is.
|
|
255
|
+
if (flags.json) return backupListJson([backup], null);
|
|
151
256
|
celiloIntro('Backup Detail');
|
|
152
257
|
return showBackupDetail(moduleIdOrBackupId);
|
|
153
258
|
}
|
|
154
259
|
// Fall through to module filter if not a backup ID
|
|
155
260
|
}
|
|
156
261
|
|
|
157
|
-
celiloIntro('Available Backups');
|
|
158
|
-
|
|
159
262
|
const moduleId = moduleIdOrBackupId;
|
|
160
|
-
const backupList = listBackups({ moduleId, limit });
|
|
263
|
+
const backupList = listBackups({ moduleId, limit, since });
|
|
264
|
+
|
|
265
|
+
// Before the banner: --json must emit JSON and nothing else, or the first
|
|
266
|
+
// thing a parser meets is a celilo logo.
|
|
267
|
+
if (flags.json) return backupListJson(backupList, windowDays);
|
|
268
|
+
|
|
269
|
+
celiloIntro('Available Backups');
|
|
161
270
|
|
|
162
271
|
if (backupList.length === 0) {
|
|
163
272
|
console.log('No backups found.\n');
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wiring, end to end: does `celilo console get` actually carry the chain?
|
|
3
|
+
*
|
|
4
|
+
* `orderFirewallChain` is unit-tested, `computeClosure` is unit-tested, and
|
|
5
|
+
* `loadClosureInputs` is unit-tested against real rows. None of that says the
|
|
6
|
+
* command joins them up. A dropped argument at this one call site returns a
|
|
7
|
+
* perfectly well-formed answer with an empty `chains` — which reads as a fleet
|
|
8
|
+
* with no delegation, and is the failure mode CLAUDE.md names.
|
|
9
|
+
*
|
|
10
|
+
* So this drives the REAL command against a REAL database and asserts on what
|
|
11
|
+
* it emits.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
15
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
let testDir: string;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
testDir = mkdtempSync(join(tmpdir(), 'celilo-console-chain-'));
|
|
23
|
+
process.env.CELILO_DB_PATH = join(testDir, 'test.db');
|
|
24
|
+
process.env.CELILO_DATA_DIR = testDir;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
29
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** The live fleet's firewall shape: caddy on iptables, iptables on the router. */
|
|
33
|
+
async function seedFleet() {
|
|
34
|
+
const { getDb } = await import('../../db/client');
|
|
35
|
+
const db = getDb();
|
|
36
|
+
const consumer = JSON.stringify({ requires: { capabilities: [{ name: 'firewall' }] } });
|
|
37
|
+
|
|
38
|
+
db.$client.run(
|
|
39
|
+
`INSERT INTO modules (id, name, version, source_path, manifest_data, state)
|
|
40
|
+
VALUES ('caddy', 'Caddy', '1.0.0', '/p', '${consumer}', 'VERIFIED')`,
|
|
41
|
+
);
|
|
42
|
+
for (const id of ['iptables', 'axon']) {
|
|
43
|
+
db.$client.run(
|
|
44
|
+
`INSERT INTO modules (id, name, version, source_path, manifest_data, state)
|
|
45
|
+
VALUES ('${id}', '${id}', '1.0.0', '/p', '{}', 'VERIFIED')`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
db.$client.run(
|
|
49
|
+
`INSERT INTO capabilities (module_id, capability_name, version, data)
|
|
50
|
+
VALUES ('iptables', 'firewall', '1.0.0', '{"nat_ip":"192.168.0.253"}')`,
|
|
51
|
+
);
|
|
52
|
+
db.$client.run(
|
|
53
|
+
`INSERT INTO capabilities (module_id, capability_name, version, data)
|
|
54
|
+
VALUES ('axon', 'firewall', '1.0.0', '{"has_external":true}')`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('celilo console get, and the delegation chain', () => {
|
|
59
|
+
test('--json carries the chain, in order', async () => {
|
|
60
|
+
await seedFleet();
|
|
61
|
+
const { handleConsoleGet } = await import('./console');
|
|
62
|
+
const result = handleConsoleGet(['caddy'], { json: true });
|
|
63
|
+
|
|
64
|
+
expect(result.success).toBe(true);
|
|
65
|
+
const payload = JSON.parse(result.success ? result.message : '{}');
|
|
66
|
+
expect(payload.chains).toEqual([{ capability: 'firewall', moduleIds: ['iptables', 'axon'] }]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('the router is in the closure, though no manifest mentions it', async () => {
|
|
70
|
+
// caddy requires `firewall` and nothing else. Walking manifests alone stops
|
|
71
|
+
// at the two registered providers and has no way to know one stands on the
|
|
72
|
+
// other — which is the whole reason the chain is derived.
|
|
73
|
+
await seedFleet();
|
|
74
|
+
const { handleConsoleGet } = await import('./console');
|
|
75
|
+
const payload = JSON.parse(
|
|
76
|
+
(() => {
|
|
77
|
+
const r = handleConsoleGet(['caddy'], { json: true });
|
|
78
|
+
return r.success ? r.message : '{}';
|
|
79
|
+
})(),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const axon = payload.nodes.find((n: { moduleId: string }) => n.moduleId === 'axon');
|
|
83
|
+
expect(axon).toBeDefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('the human output prints it as a path, not as more rows', async () => {
|
|
87
|
+
// A list of two firewalls is exactly the claim being corrected. The arrow is
|
|
88
|
+
// the information.
|
|
89
|
+
await seedFleet();
|
|
90
|
+
const { handleConsoleGet } = await import('./console');
|
|
91
|
+
const result = handleConsoleGet(['caddy'], {});
|
|
92
|
+
|
|
93
|
+
expect(result.success).toBe(true);
|
|
94
|
+
expect(result.success ? result.message : '').toContain('iptables -> axon');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo console …` — the narrow reads the web console polls.
|
|
3
|
+
*
|
|
4
|
+
* A NOTE ON THE VERB NAMES, because they read oddly on purpose. celilo's remote
|
|
5
|
+
* API authorises at two levels (`command:subcommand`), and a leaf is classified
|
|
6
|
+
* read-only by its subcommand token against `READ_VERBS` in `@celilo/core`. So
|
|
7
|
+
* these are named from that vocabulary — `status`, `list`, `get` — rather than
|
|
8
|
+
* for prose. `console roster` would be a nicer name and would classify as a
|
|
9
|
+
* WRITE, which would either deny the console its own data or require widening
|
|
10
|
+
* the read-verb set for every principal in the fleet.
|
|
11
|
+
*
|
|
12
|
+
* All three are reads. The console's principal is granted read ops only, so a
|
|
13
|
+
* mutating verb added to this file would be refused at the API boundary rather
|
|
14
|
+
* than running.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_CLOSURE_DEPTH,
|
|
19
|
+
UNBOUNDED_CLOSURE_DEPTH,
|
|
20
|
+
computeClosure,
|
|
21
|
+
} from '../../console/closure';
|
|
22
|
+
import {
|
|
23
|
+
consoleStatus,
|
|
24
|
+
loadBindings,
|
|
25
|
+
loadClosureInputs,
|
|
26
|
+
moduleExists,
|
|
27
|
+
} from '../../console/projection';
|
|
28
|
+
import { getDb } from '../../db/client';
|
|
29
|
+
import { loadCapabilityProviderRows } from '../../services/consumer-cleanup';
|
|
30
|
+
import { hasFlag } from '../parser';
|
|
31
|
+
import type { CommandResult } from '../types';
|
|
32
|
+
|
|
33
|
+
/** `celilo console status [--json]` — the dashboard's single poll. */
|
|
34
|
+
export function handleConsoleStatus(flags: Record<string, string | boolean> = {}): CommandResult {
|
|
35
|
+
const payload = consoleStatus(getDb());
|
|
36
|
+
|
|
37
|
+
if (hasFlag(flags, 'json')) {
|
|
38
|
+
return { success: true, message: JSON.stringify(payload), rawOutput: true, data: payload };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const lines = payload.modules.map(
|
|
42
|
+
(m) =>
|
|
43
|
+
`${m.id.padEnd(24)} ${m.state.padEnd(12)} ${m.health.cell.padEnd(14)} ${
|
|
44
|
+
m.systems.map((s) => `${s.hostname}@${s.zone}`).join(', ') || '(no system)'
|
|
45
|
+
}`,
|
|
46
|
+
);
|
|
47
|
+
return {
|
|
48
|
+
success: true,
|
|
49
|
+
message: [`zones: ${payload.zones.join(' > ')}`, '', ...lines].join('\n'),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `celilo console get <module-id> [--depth N] [--json]` — one module's bounded
|
|
55
|
+
* capability closure.
|
|
56
|
+
*
|
|
57
|
+
* `--depth 0` walks the whole graph. The walk terminates on cycles either way.
|
|
58
|
+
*/
|
|
59
|
+
export function handleConsoleGet(
|
|
60
|
+
args: string[],
|
|
61
|
+
flags: Record<string, string | boolean> = {},
|
|
62
|
+
): CommandResult {
|
|
63
|
+
const moduleId = args[0];
|
|
64
|
+
if (!moduleId) {
|
|
65
|
+
return { success: false, error: 'Module ID required\n\nUsage: celilo console get <module-id>' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const db = getDb();
|
|
69
|
+
if (!moduleExists(db, moduleId)) {
|
|
70
|
+
return { success: false, error: `Module not found: ${moduleId}` };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const depth = parseDepth(flags.depth);
|
|
74
|
+
if (depth instanceof Error) return { success: false, error: depth.message };
|
|
75
|
+
|
|
76
|
+
const { manifests, providerStates, chains } = loadClosureInputs(db);
|
|
77
|
+
|
|
78
|
+
// Real bindings, for every module the walk might reach. The console draws what
|
|
79
|
+
// the fleet IS doing, not what its manifests permit.
|
|
80
|
+
const bindings = new Map([...manifests.keys()].map((id) => [id, loadBindings(db, id)] as const));
|
|
81
|
+
|
|
82
|
+
const result = computeClosure({
|
|
83
|
+
rootModuleId: moduleId,
|
|
84
|
+
manifests,
|
|
85
|
+
providerRows: loadCapabilityProviderRows(db),
|
|
86
|
+
providerStates,
|
|
87
|
+
depth,
|
|
88
|
+
bindings,
|
|
89
|
+
chains,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (hasFlag(flags, 'json')) {
|
|
93
|
+
return { success: true, message: JSON.stringify(result), rawOutput: true, data: result };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (result.nodes.length === 0) {
|
|
97
|
+
// An answer, not a failure. The console says the same thing in words rather
|
|
98
|
+
// than rendering an empty picture that reads as still loading.
|
|
99
|
+
return { success: true, message: `${moduleId} depends on nothing.` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The chain is printed as a path rather than as more rows, because that is the
|
|
103
|
+
// whole claim: these providers are ordered, and a list of them is not.
|
|
104
|
+
const chainLines = result.chains.map(
|
|
105
|
+
(chain) => `${chain.capability}: ${chain.moduleIds.join(' -> ')}`,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
success: true,
|
|
110
|
+
message: [
|
|
111
|
+
...result.nodes.map(
|
|
112
|
+
(n) =>
|
|
113
|
+
`hop ${n.hop} ${n.moduleId.padEnd(24)} ${n.optional ? '(optional)' : ' '} via ${n.via.join(', ')}`,
|
|
114
|
+
),
|
|
115
|
+
...(chainLines.length > 0 ? ['', 'delegates upstream:', ...chainLines] : []),
|
|
116
|
+
].join('\n'),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Depth flag, or an Error explaining what was wrong with it. */
|
|
121
|
+
function parseDepth(raw: string | boolean | undefined): number | Error {
|
|
122
|
+
if (raw === undefined || raw === true) return DEFAULT_CLOSURE_DEPTH;
|
|
123
|
+
const parsed = Number(raw);
|
|
124
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
125
|
+
return new Error(
|
|
126
|
+
`Invalid --depth: ${raw}\n\nExpected a non-negative integer (${UNBOUNDED_CLOSURE_DEPTH} walks the whole graph).`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return parsed;
|
|
130
|
+
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { moduleHealthCell } from '../../services/alerting/format';
|
|
5
|
-
import { loadAllLiveAlerts, summariseByModule } from '../../services/alerting/store';
|
|
1
|
+
import { getDb } from '../../db/client';
|
|
2
|
+
import { modules } from '../../db/schema';
|
|
3
|
+
import { loadObservedHealth } from '../../services/alerting/observed-health';
|
|
6
4
|
import { formatPausedDuration } from '../../services/module-pause';
|
|
7
5
|
import { hasFlag } from '../parser';
|
|
8
6
|
import type { CommandResult } from '../types';
|
|
@@ -77,39 +75,3 @@ export async function handleModuleList(
|
|
|
77
75
|
data: moduleRows,
|
|
78
76
|
};
|
|
79
77
|
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Observed health per module: `ok`, `N firing`, `suppressed`, or
|
|
83
|
-
* `not observed`.
|
|
84
|
-
*
|
|
85
|
-
* Only computed for DEPLOYED modules — a module still being imported has
|
|
86
|
-
* nothing to observe, and reporting it as unwatched would be noise rather
|
|
87
|
-
* than a finding.
|
|
88
|
-
*/
|
|
89
|
-
function loadObservedHealth(db: DbClient): Map<string, string> {
|
|
90
|
-
const monitored = new Set(
|
|
91
|
-
db
|
|
92
|
-
.select({ target: monitors.target })
|
|
93
|
-
.from(monitors)
|
|
94
|
-
.where(eq(monitors.enabled, true))
|
|
95
|
-
.all()
|
|
96
|
-
.map((m) => m.target),
|
|
97
|
-
);
|
|
98
|
-
const byModule = summariseByModule(loadAllLiveAlerts(db));
|
|
99
|
-
|
|
100
|
-
const result = new Map<string, string>();
|
|
101
|
-
for (const module of db.select().from(modulesTable).all()) {
|
|
102
|
-
if (module.state !== 'INSTALLED' && module.state !== 'VERIFIED') continue;
|
|
103
|
-
|
|
104
|
-
const summary = byModule.get(module.id) ?? [];
|
|
105
|
-
result.set(
|
|
106
|
-
module.id,
|
|
107
|
-
moduleHealthCell({
|
|
108
|
-
monitored: monitored.has(module.id),
|
|
109
|
-
firingCount: summary.filter((s) => !s.suppressed).length,
|
|
110
|
-
suppressed: summary.length > 0 && summary.every((s) => s.suppressed),
|
|
111
|
-
}),
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
return result;
|
|
115
|
-
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo person list --json` — the shape the web console maps an identity
|
|
3
|
+
* against.
|
|
4
|
+
*
|
|
5
|
+
* Worth its own test for a reason that is not obvious from the three lines it
|
|
6
|
+
* covers. The console server decides whether an authenticated browser subject
|
|
7
|
+
* corresponds to a real person, and it decides it by reading THIS. Its own
|
|
8
|
+
* tests answer that question against a fake celilo, so they prove the console
|
|
9
|
+
* parses what it was told to expect and say nothing about what celilo emits.
|
|
10
|
+
* This is the end that closes.
|
|
11
|
+
*
|
|
12
|
+
* If the payload ever stops being `[{ name, timezone }]`, the console does not
|
|
13
|
+
* error: it maps nobody, draws no acknowledge control, and reports every
|
|
14
|
+
* operator as unknown to the fleet. That reads as a configuration problem for
|
|
15
|
+
* as long as anyone is willing to believe it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
19
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { closeDb, getDb } from '../../db/client';
|
|
23
|
+
import { createPerson } from '../../services/alerting/people';
|
|
24
|
+
import { setupTestDatabaseAt } from '../../test-utils/database';
|
|
25
|
+
import { handlePerson } from './notify-config';
|
|
26
|
+
|
|
27
|
+
let dir: string;
|
|
28
|
+
const previousDbPath = process.env.CELILO_DB_PATH;
|
|
29
|
+
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
dir = mkdtempSync(join(tmpdir(), 'celilo-person-'));
|
|
32
|
+
const dbPath = join(dir, 'test.db');
|
|
33
|
+
const db = await setupTestDatabaseAt(dbPath);
|
|
34
|
+
createPerson(db, { name: 'peter', timezone: 'America/Los_Angeles' });
|
|
35
|
+
createPerson(db, { name: 'ada', timezone: 'Europe/London' });
|
|
36
|
+
db.$client.close();
|
|
37
|
+
process.env.CELILO_DB_PATH = dbPath;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
closeDb();
|
|
42
|
+
if (previousDbPath === undefined) delete process.env.CELILO_DB_PATH;
|
|
43
|
+
else process.env.CELILO_DB_PATH = previousDbPath;
|
|
44
|
+
rmSync(dir, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** Run `person list`, insisting it succeeded before reading what it said. */
|
|
48
|
+
async function personList(flags: Record<string, boolean | string>): Promise<string> {
|
|
49
|
+
const result = await handlePerson('list', [], flags);
|
|
50
|
+
if (!result.success) throw new Error(`person list failed: ${result.error}`);
|
|
51
|
+
return result.message;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function listJson(): Promise<{ name: string; timezone: string }[]> {
|
|
55
|
+
return JSON.parse(await personList({ json: true }));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('celilo person list --json', () => {
|
|
59
|
+
test('names every person, sorted the way listPeople sorts them', async () => {
|
|
60
|
+
expect((await listJson()).map((p) => p.name)).toEqual(['ada', 'peter']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('carries the timezone, which is the only other thing a person IS', async () => {
|
|
64
|
+
const ada = (await listJson()).find((p) => p.name === 'ada');
|
|
65
|
+
expect(ada?.timezone).toBe('Europe/London');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('an empty fleet is an empty array, not a sentence about it', async () => {
|
|
69
|
+
// The human listing prints "Nobody configured." and a hint. A caller that
|
|
70
|
+
// parsed that would throw, which is a better failure than the one this
|
|
71
|
+
// avoids — but the console polls this, so it gets JSON either way.
|
|
72
|
+
getDb().$client.run('DELETE FROM people');
|
|
73
|
+
expect(await listJson()).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('without --json it stays the human table, and says how many', async () => {
|
|
77
|
+
expect(await personList({})).toContain('2 person');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
listRoutes,
|
|
34
34
|
} from '../../services/alerting/people';
|
|
35
35
|
import { parseClockTime } from '../../services/alerting/quiet-hours';
|
|
36
|
+
import { hasFlag } from '../parser';
|
|
36
37
|
import type { CommandResult } from '../types';
|
|
37
38
|
|
|
38
39
|
const NO_SCHEMAS = defineEvents({});
|
|
@@ -109,8 +110,18 @@ function personAdd(args: string[], flags: Record<string, boolean | string>): Com
|
|
|
109
110
|
return { success: true, message: `Added ${name} (${timezone}${quietNote})` };
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
function personList(): CommandResult {
|
|
113
|
+
function personList(flags: Record<string, boolean | string> = {}): CommandResult {
|
|
113
114
|
const people = listPeople(getDb());
|
|
115
|
+
|
|
116
|
+
// The web console's subject-to-person mapping reads this. It never invents a
|
|
117
|
+
// person and never lets celilo fall back to `people[0]`, so it has to be able
|
|
118
|
+
// to ASK who exists. `person:list` is already in the read-only grant set, so
|
|
119
|
+
// the flag adds a shape rather than an authority.
|
|
120
|
+
if (hasFlag(flags, 'json')) {
|
|
121
|
+
const payload = people.map((p) => ({ name: p.name, timezone: p.timezone }));
|
|
122
|
+
return { success: true, message: JSON.stringify(payload), rawOutput: true, data: payload };
|
|
123
|
+
}
|
|
124
|
+
|
|
114
125
|
if (people.length === 0) {
|
|
115
126
|
console.log('\nNobody configured.\n');
|
|
116
127
|
console.log(
|
|
@@ -158,7 +169,7 @@ export async function handlePerson(
|
|
|
158
169
|
switch (subcommand) {
|
|
159
170
|
case undefined:
|
|
160
171
|
case 'list':
|
|
161
|
-
return personList();
|
|
172
|
+
return personList(flags);
|
|
162
173
|
case 'add':
|
|
163
174
|
return personAdd(args, flags);
|
|
164
175
|
case 'remove':
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo system ensure-fleet-key` — mint celilo's fleet SSH keypair if it
|
|
3
|
+
* is absent, record its public half, and print it.
|
|
4
|
+
*
|
|
5
|
+
* The celilo-side half of "minting the fleet key is not the module's job"
|
|
6
|
+
* (openspec/changes/hook-process-boundary, design D9b). celilo-mgmt's
|
|
7
|
+
* `on_install` used to generate the keypair itself, writing into celilo's
|
|
8
|
+
* data directory from inside a hook — the one directory the hook jail
|
|
9
|
+
* exists to keep out of the mount set. The hook now asks for the key
|
|
10
|
+
* through this command and receives the public half, rather than producing
|
|
11
|
+
* it.
|
|
12
|
+
*
|
|
13
|
+
* Idempotent: an existing key is reused. Re-keying would strand every
|
|
14
|
+
* machine whose authorized_keys holds the old public half.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { getDb } from '../../db/client';
|
|
18
|
+
import { ensureFleetKey, getFleetSshDir } from '../../services/fleet-key';
|
|
19
|
+
import { initializeSystem } from '../../services/system-init';
|
|
20
|
+
import type { CommandResult } from '../types';
|
|
21
|
+
|
|
22
|
+
export async function handleSystemEnsureFleetKey(): Promise<CommandResult> {
|
|
23
|
+
let key: ReturnType<typeof ensureFleetKey>;
|
|
24
|
+
try {
|
|
25
|
+
key = ensureFleetKey();
|
|
26
|
+
} catch (err) {
|
|
27
|
+
return {
|
|
28
|
+
success: false,
|
|
29
|
+
error: `Could not create the fleet SSH key in ${getFleetSshDir()}: ${
|
|
30
|
+
err instanceof Error ? err.message : String(err)
|
|
31
|
+
}`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
initializeSystem(getDb(), { 'ssh.public_key': key.publicKey });
|
|
37
|
+
} catch (err) {
|
|
38
|
+
return {
|
|
39
|
+
success: false,
|
|
40
|
+
error: `Fleet key is on disk but recording ssh.public_key failed: ${
|
|
41
|
+
err instanceof Error ? err.message : String(err)
|
|
42
|
+
}`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The public key alone, and `rawOutput` to say so on the record: this
|
|
47
|
+
// command exists to be read by celilo-mgmt's on_install as much as by an
|
|
48
|
+
// operator, and the key IS the answer either way (an operator pastes it into
|
|
49
|
+
// a machine's authorized_keys). Wrapping it in prose would make the caller
|
|
50
|
+
// parse for it, which is how a hook ends up depending on a sentence.
|
|
51
|
+
return { success: true, message: key.publicKey, rawOutput: true };
|
|
52
|
+
}
|
package/src/cli/completion.ts
CHANGED
|
@@ -37,6 +37,7 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
37
37
|
'backup',
|
|
38
38
|
'capability',
|
|
39
39
|
'commands',
|
|
40
|
+
'console',
|
|
40
41
|
'dns',
|
|
41
42
|
'completion',
|
|
42
43
|
'alerts',
|
|
@@ -658,6 +659,10 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
658
659
|
}
|
|
659
660
|
|
|
660
661
|
// Completion subcommands
|
|
662
|
+
if (command === 'console' && currentIndex === 1) {
|
|
663
|
+
return filterSuggestions(['status', 'get'], args[1] || '');
|
|
664
|
+
}
|
|
665
|
+
|
|
661
666
|
if (command === 'completion' && currentIndex === 1) {
|
|
662
667
|
const subcommands = ['bash', 'zsh'];
|
|
663
668
|
return filterSuggestions(subcommands, args[1] || '');
|
|
@@ -669,6 +674,7 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
669
674
|
'init',
|
|
670
675
|
'apply-config',
|
|
671
676
|
'discover-network',
|
|
677
|
+
'ensure-fleet-key',
|
|
672
678
|
'config',
|
|
673
679
|
'secret',
|
|
674
680
|
'vault-password',
|