@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
package/src/cli/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { handleCapabilityInfo } from './commands/capability-info';
|
|
|
25
25
|
import { handleCapabilityList } from './commands/capability-list';
|
|
26
26
|
import { handleCommands } from './commands/commands-json';
|
|
27
27
|
import { handleCompletion } from './commands/completion';
|
|
28
|
+
import { handleConsoleGet, handleConsoleStatus } from './commands/console';
|
|
28
29
|
import { handleDnsRegistrations } from './commands/dns';
|
|
29
30
|
import {
|
|
30
31
|
handleEventsAck,
|
|
@@ -123,6 +124,7 @@ import { handleSystemAudit } from './commands/system-audit';
|
|
|
123
124
|
import { handleSystemConfigGet, handleSystemConfigSet } from './commands/system-config';
|
|
124
125
|
import { handleSystemDiscoverNetwork } from './commands/system-discover-network';
|
|
125
126
|
import { handleSystemDoctor } from './commands/system-doctor';
|
|
127
|
+
import { handleSystemEnsureFleetKey } from './commands/system-ensure-fleet-key';
|
|
126
128
|
import { handleSystemInit } from './commands/system-init';
|
|
127
129
|
import { handleSystemMigrate } from './commands/system-migrate';
|
|
128
130
|
import { handleSystemSecretGet } from './commands/system-secret-get';
|
|
@@ -225,6 +227,7 @@ Commands:
|
|
|
225
227
|
api Manage remote-API access (principals, grants, authorized_keys)
|
|
226
228
|
completion Generate shell completion scripts (bash/zsh)
|
|
227
229
|
commands Print the CLI command registry as JSON (drives @celilo/mcp)
|
|
230
|
+
console Narrow read-only projections for the web console
|
|
228
231
|
|
|
229
232
|
help, --help, -h Show this help message
|
|
230
233
|
|
|
@@ -803,7 +806,11 @@ Subcommands:
|
|
|
803
806
|
|
|
804
807
|
list [module-id] List available backups
|
|
805
808
|
Options:
|
|
806
|
-
--limit <n> Number of backups to show (default: 20)
|
|
809
|
+
--limit <n> Number of backups to show (default: 20; 5000 with --json)
|
|
810
|
+
--json Emit the listing as JSON with EXACT timestamps.
|
|
811
|
+
The human output buckets ages past six days into
|
|
812
|
+
"last week", so it cannot be parsed back into days.
|
|
813
|
+
--since <days> Only attempts from the last N days
|
|
807
814
|
|
|
808
815
|
restore <backup-id> Restore from a backup
|
|
809
816
|
Options:
|
|
@@ -1116,6 +1123,7 @@ Subcommands:
|
|
|
1116
1123
|
to define, not a module's.
|
|
1117
1124
|
|
|
1118
1125
|
discover-network Record the network this box sits on, from its own routing table (idempotent)
|
|
1126
|
+
ensure-fleet-key Mint celilo's fleet SSH key if absent, record ssh.public_key, print the public half
|
|
1119
1127
|
|
|
1120
1128
|
config set <key> <value> Set system-wide configuration value
|
|
1121
1129
|
config get [key] Get system configuration value(s)
|
|
@@ -1275,6 +1283,24 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1275
1283
|
return handleCommands(parsed.args, parsed.flags);
|
|
1276
1284
|
}
|
|
1277
1285
|
|
|
1286
|
+
// The web console's narrow reads.
|
|
1287
|
+
if (parsed.command === 'console') {
|
|
1288
|
+
const flagError = checkFlags('console', parsed.subcommand, parsed.flags, parsed.args);
|
|
1289
|
+
if (flagError) return flagError;
|
|
1290
|
+
|
|
1291
|
+
switch (parsed.subcommand) {
|
|
1292
|
+
case 'status':
|
|
1293
|
+
return handleConsoleStatus(parsed.flags);
|
|
1294
|
+
case 'get':
|
|
1295
|
+
return handleConsoleGet(parsed.args, parsed.flags);
|
|
1296
|
+
default:
|
|
1297
|
+
return {
|
|
1298
|
+
success: false,
|
|
1299
|
+
error: 'Console subcommand required: status | get',
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1278
1304
|
// Top-level alias: `celilo audit` → `celilo system audit`
|
|
1279
1305
|
if (parsed.command === 'audit') {
|
|
1280
1306
|
return handleSystemAudit(parsed.args, parsed.flags);
|
|
@@ -2197,6 +2223,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
2197
2223
|
return handleSystemDiscoverNetwork();
|
|
2198
2224
|
}
|
|
2199
2225
|
|
|
2226
|
+
if (parsed.subcommand === 'ensure-fleet-key') {
|
|
2227
|
+
return handleSystemEnsureFleetKey();
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2200
2230
|
if (parsed.subcommand === 'config') {
|
|
2201
2231
|
const configSubcommand = parsed.args[0];
|
|
2202
2232
|
if (!configSubcommand) {
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The six graph shapes the plan names, five of which the live fleet produced.
|
|
3
|
+
*
|
|
4
|
+
* They are here rather than in an e2e suite because every one of them is a
|
|
5
|
+
* property of the walk, and a walk is testable without a fleet.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
9
|
+
import type { ProviderRow, ProviderState } from '../services/consumer-cleanup';
|
|
10
|
+
import { DEFAULT_CLOSURE_DEPTH, UNBOUNDED_CLOSURE_DEPTH, computeClosure } from './closure';
|
|
11
|
+
|
|
12
|
+
/** Only the two capability lists matter here; the rest of a manifest does not. */
|
|
13
|
+
function manifest(spec: { requires?: string[]; optional?: string[] }): ModuleManifest {
|
|
14
|
+
return {
|
|
15
|
+
requires: { capabilities: (spec.requires ?? []).map((name) => ({ name })) },
|
|
16
|
+
optional: { capabilities: (spec.optional ?? []).map((name) => ({ name })) },
|
|
17
|
+
} as unknown as ModuleManifest;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function deployed(...moduleIds: string[]): ProviderState[] {
|
|
21
|
+
return moduleIds.map((moduleId) => ({ moduleId, state: 'VERIFIED' }));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('computeClosure', () => {
|
|
25
|
+
test('follows an optional-only edge, and marks it optional', () => {
|
|
26
|
+
// The live case: a module reaches the internal resolver ONLY optionally.
|
|
27
|
+
// Following `requires` alone drops the resolver while looking complete.
|
|
28
|
+
const rows: ProviderRow[] = [{ moduleId: 'technitium', capabilityName: 'dns_internal' }];
|
|
29
|
+
const result = computeClosure({
|
|
30
|
+
rootModuleId: 'wireguard-manager',
|
|
31
|
+
manifests: new Map([['wireguard-manager', manifest({ optional: ['dns_internal'] })]]),
|
|
32
|
+
providerRows: rows,
|
|
33
|
+
providerStates: deployed('technitium'),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(result.nodes).toHaveLength(1);
|
|
37
|
+
expect(result.nodes[0]?.moduleId).toBe('technitium');
|
|
38
|
+
expect(result.nodes[0]?.optional).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('a module reached both ways is not optional', () => {
|
|
42
|
+
const rows: ProviderRow[] = [
|
|
43
|
+
{ moduleId: 'caddy', capabilityName: 'private_web' },
|
|
44
|
+
{ moduleId: 'caddy', capabilityName: 'public_web' },
|
|
45
|
+
];
|
|
46
|
+
const result = computeClosure({
|
|
47
|
+
rootModuleId: 'app',
|
|
48
|
+
manifests: new Map([
|
|
49
|
+
['app', manifest({ requires: ['private_web'], optional: ['public_web'] })],
|
|
50
|
+
]),
|
|
51
|
+
providerRows: rows,
|
|
52
|
+
providerStates: deployed('caddy'),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(result.nodes[0]?.optional).toBe(false);
|
|
56
|
+
expect(result.nodes[0]?.via).toEqual(['private_web', 'public_web']);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('the default depth excludes a three-hop dependency', () => {
|
|
60
|
+
const rows: ProviderRow[] = [
|
|
61
|
+
{ moduleId: 'b', capabilityName: 'cap_b' },
|
|
62
|
+
{ moduleId: 'c', capabilityName: 'cap_c' },
|
|
63
|
+
{ moduleId: 'd', capabilityName: 'cap_d' },
|
|
64
|
+
];
|
|
65
|
+
const manifests = new Map([
|
|
66
|
+
['a', manifest({ requires: ['cap_b'] })],
|
|
67
|
+
['b', manifest({ requires: ['cap_c'] })],
|
|
68
|
+
['c', manifest({ requires: ['cap_d'] })],
|
|
69
|
+
['d', manifest({})],
|
|
70
|
+
]);
|
|
71
|
+
const result = computeClosure({
|
|
72
|
+
rootModuleId: 'a',
|
|
73
|
+
manifests,
|
|
74
|
+
providerRows: rows,
|
|
75
|
+
providerStates: deployed('b', 'c', 'd'),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(result.depth).toBe(DEFAULT_CLOSURE_DEPTH);
|
|
79
|
+
expect(result.nodes.map((n) => n.moduleId)).toEqual(['b', 'c']);
|
|
80
|
+
expect(result.truncated).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('an unbounded walk terminates on a cycle, listing each module once', () => {
|
|
84
|
+
// caddy requires authentik, authentik requires caddy. The real graph.
|
|
85
|
+
const rows: ProviderRow[] = [
|
|
86
|
+
{ moduleId: 'authentik', capabilityName: 'idp' },
|
|
87
|
+
{ moduleId: 'caddy', capabilityName: 'private_web' },
|
|
88
|
+
];
|
|
89
|
+
const manifests = new Map([
|
|
90
|
+
['caddy', manifest({ requires: ['idp'] })],
|
|
91
|
+
['authentik', manifest({ requires: ['private_web'] })],
|
|
92
|
+
]);
|
|
93
|
+
const result = computeClosure({
|
|
94
|
+
rootModuleId: 'caddy',
|
|
95
|
+
manifests,
|
|
96
|
+
providerRows: rows,
|
|
97
|
+
providerStates: deployed('caddy', 'authentik'),
|
|
98
|
+
depth: UNBOUNDED_CLOSURE_DEPTH,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(result.nodes.map((n) => n.moduleId)).toEqual(['authentik']);
|
|
102
|
+
expect(result.truncated).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a capability with several providers highlights every one', () => {
|
|
106
|
+
// firewall is deliberately multi-provider: an edge provider plus inner layers.
|
|
107
|
+
const rows: ProviderRow[] = [
|
|
108
|
+
{ moduleId: 'iptables', capabilityName: 'firewall' },
|
|
109
|
+
{ moduleId: 'opnsense', capabilityName: 'firewall' },
|
|
110
|
+
];
|
|
111
|
+
const result = computeClosure({
|
|
112
|
+
rootModuleId: 'app',
|
|
113
|
+
manifests: new Map([['app', manifest({ requires: ['firewall'] })]]),
|
|
114
|
+
providerRows: rows,
|
|
115
|
+
providerStates: deployed('iptables', 'opnsense'),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(result.nodes.map((n) => n.moduleId)).toEqual(['iptables', 'opnsense']);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('a module that depends on nothing returns an empty closure, not a failure', () => {
|
|
122
|
+
const result = computeClosure({
|
|
123
|
+
rootModuleId: 'standalone',
|
|
124
|
+
manifests: new Map([['standalone', manifest({})]]),
|
|
125
|
+
providerRows: [],
|
|
126
|
+
providerStates: [],
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(result.nodes).toEqual([]);
|
|
130
|
+
expect(result.truncated).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('a module that provides what it consumes is not its own dependency', () => {
|
|
134
|
+
const rows: ProviderRow[] = [
|
|
135
|
+
{ moduleId: 'caddy', capabilityName: 'private_web' },
|
|
136
|
+
{ moduleId: 'other', capabilityName: 'private_web' },
|
|
137
|
+
];
|
|
138
|
+
const result = computeClosure({
|
|
139
|
+
rootModuleId: 'caddy',
|
|
140
|
+
manifests: new Map([['caddy', manifest({ requires: ['private_web'] })]]),
|
|
141
|
+
providerRows: rows,
|
|
142
|
+
providerStates: deployed('caddy', 'other'),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(result.nodes.map((n) => n.moduleId)).toEqual(['other']);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('the shortest hop wins when a module is reachable two ways', () => {
|
|
149
|
+
const rows: ProviderRow[] = [
|
|
150
|
+
{ moduleId: 'b', capabilityName: 'cap_b' },
|
|
151
|
+
{ moduleId: 'shared', capabilityName: 'cap_shared' },
|
|
152
|
+
{ moduleId: 'shared', capabilityName: 'cap_deep' },
|
|
153
|
+
];
|
|
154
|
+
const manifests = new Map([
|
|
155
|
+
['a', manifest({ requires: ['cap_b', 'cap_shared'] })],
|
|
156
|
+
['b', manifest({ requires: ['cap_deep'] })],
|
|
157
|
+
['shared', manifest({})],
|
|
158
|
+
]);
|
|
159
|
+
const result = computeClosure({
|
|
160
|
+
rootModuleId: 'a',
|
|
161
|
+
manifests,
|
|
162
|
+
providerRows: rows,
|
|
163
|
+
providerStates: deployed('b', 'shared'),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const shared = result.nodes.find((n) => n.moduleId === 'shared');
|
|
167
|
+
expect(shared?.hop).toBe(1);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('the walk follows real bindings, not declarations', () => {
|
|
172
|
+
const rows: ProviderRow[] = [
|
|
173
|
+
{ moduleId: 'cpanel', capabilityName: 'external_web' },
|
|
174
|
+
{ moduleId: 'caddy', capabilityName: 'public_web' },
|
|
175
|
+
{ moduleId: 'forgejo', capabilityName: 'source_forge' },
|
|
176
|
+
];
|
|
177
|
+
const manifests = new Map([
|
|
178
|
+
['site', manifest({ optional: ['external_web', 'public_web', 'source_forge'] })],
|
|
179
|
+
['cpanel', manifest({})],
|
|
180
|
+
['caddy', manifest({})],
|
|
181
|
+
['forgejo', manifest({})],
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
test('a declared capability the module never called into is NOT a dependency', () => {
|
|
185
|
+
// The tango-nexus case, which is what celilo#1072 was filed for: four
|
|
186
|
+
// optional declarations, one actual binding. Following the declarations
|
|
187
|
+
// claims the module is standing on three things it has never touched.
|
|
188
|
+
const result = computeClosure({
|
|
189
|
+
rootModuleId: 'site',
|
|
190
|
+
manifests,
|
|
191
|
+
providerRows: rows,
|
|
192
|
+
providerStates: deployed('cpanel', 'caddy', 'forgejo'),
|
|
193
|
+
bindings: new Map([['site', new Map([['external_web', 'cpanel']])]]),
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
expect(result.nodes.map((n) => n.moduleId)).toEqual(['cpanel']);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('without bindings the walk falls back to declarations, and says more', () => {
|
|
200
|
+
// Not a silent fallback: the caller decides. A walk with no binding data
|
|
201
|
+
// answers "what could this reach", which is a different and still useful
|
|
202
|
+
// question, and it must not be mistaken for the other one.
|
|
203
|
+
const result = computeClosure({
|
|
204
|
+
rootModuleId: 'site',
|
|
205
|
+
manifests,
|
|
206
|
+
providerRows: rows,
|
|
207
|
+
providerStates: deployed('cpanel', 'caddy', 'forgejo'),
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
expect(result.nodes.map((n) => n.moduleId).sort()).toEqual(['caddy', 'cpanel', 'forgejo']);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('a binding to a DIFFERENT provider of the same capability is not followed', () => {
|
|
214
|
+
// firewall has several live providers. A consumer bound to one of them is
|
|
215
|
+
// not thereby standing on the others.
|
|
216
|
+
const multi: ProviderRow[] = [
|
|
217
|
+
{ moduleId: 'iptables', capabilityName: 'firewall' },
|
|
218
|
+
{ moduleId: 'axon', capabilityName: 'firewall' },
|
|
219
|
+
];
|
|
220
|
+
const result = computeClosure({
|
|
221
|
+
rootModuleId: 'app',
|
|
222
|
+
manifests: new Map([
|
|
223
|
+
['app', manifest({ requires: ['firewall'] })],
|
|
224
|
+
['iptables', manifest({})],
|
|
225
|
+
['axon', manifest({})],
|
|
226
|
+
]),
|
|
227
|
+
providerRows: multi,
|
|
228
|
+
providerStates: deployed('iptables', 'axon'),
|
|
229
|
+
bindings: new Map([['app', new Map([['firewall', 'iptables']])]]),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(result.nodes.map((n) => n.moduleId)).toEqual(['iptables']);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('a delegated provider is reached, though no manifest names it', () => {
|
|
236
|
+
// The case the whole chain exists for. `app` is BOUND to iptables, so the
|
|
237
|
+
// manifest walk stops there and the ISP router the packet actually leaves
|
|
238
|
+
// through never appears — even though iptables cannot reach the internet
|
|
239
|
+
// without it. celilo wires that edge at hook time; nothing declares it.
|
|
240
|
+
const multi: ProviderRow[] = [
|
|
241
|
+
{ moduleId: 'iptables', capabilityName: 'firewall' },
|
|
242
|
+
{ moduleId: 'axon', capabilityName: 'firewall' },
|
|
243
|
+
];
|
|
244
|
+
const result = computeClosure({
|
|
245
|
+
rootModuleId: 'app',
|
|
246
|
+
manifests: new Map([
|
|
247
|
+
['app', manifest({ requires: ['firewall'] })],
|
|
248
|
+
['iptables', manifest({})],
|
|
249
|
+
['axon', manifest({})],
|
|
250
|
+
]),
|
|
251
|
+
providerRows: multi,
|
|
252
|
+
providerStates: deployed('iptables', 'axon'),
|
|
253
|
+
bindings: new Map([['app', new Map([['firewall', 'iptables']])]]),
|
|
254
|
+
chains: [{ capability: 'firewall', moduleIds: ['iptables', 'axon'] }],
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(result.nodes.map((n) => n.moduleId)).toEqual(['iptables', 'axon']);
|
|
258
|
+
// Hop 2, because it is reached THROUGH iptables. Not a second hop-1
|
|
259
|
+
// dependency of the selection, which is what a fan would say.
|
|
260
|
+
expect(result.nodes.find((n) => n.moduleId === 'axon')?.hop).toBe(2);
|
|
261
|
+
// A packet has no alternative route to the internet.
|
|
262
|
+
expect(result.nodes.find((n) => n.moduleId === 'axon')?.optional).toBe(false);
|
|
263
|
+
expect(result.chains).toEqual([{ capability: 'firewall', moduleIds: ['iptables', 'axon'] }]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('a chain the walk never touched is not reported', () => {
|
|
267
|
+
// The chain is a fleet fact and the closure is about one module. A selection
|
|
268
|
+
// that consumes no firewall stands on none of it.
|
|
269
|
+
const result = computeClosure({
|
|
270
|
+
rootModuleId: 'site',
|
|
271
|
+
manifests,
|
|
272
|
+
providerRows: rows,
|
|
273
|
+
providerStates: deployed('cpanel', 'caddy', 'forgejo'),
|
|
274
|
+
chains: [{ capability: 'firewall', moduleIds: ['iptables', 'axon'] }],
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
expect(result.chains).toEqual([]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('the depth bound cuts a delegated provider like any other', () => {
|
|
281
|
+
// Three layers, bounded at two, so the LAST link is the one the bound cuts.
|
|
282
|
+
// The delegation edges are walked inside the same loop as the declared ones
|
|
283
|
+
// and get the same bound; a rule applied after the walk would smuggle the
|
|
284
|
+
// whole chain into a two-hop answer.
|
|
285
|
+
//
|
|
286
|
+
// A two-layer chain would not test this. `iptables` is the only declared
|
|
287
|
+
// dependency, so the walk exhausts at hop 2 whether or not the delegation
|
|
288
|
+
// is followed, and the assertion would hold for the wrong reason.
|
|
289
|
+
const result = computeClosure({
|
|
290
|
+
rootModuleId: 'app',
|
|
291
|
+
manifests: new Map([
|
|
292
|
+
['app', manifest({ requires: ['firewall'] })],
|
|
293
|
+
['iptables', manifest({})],
|
|
294
|
+
['mid', manifest({})],
|
|
295
|
+
['axon', manifest({})],
|
|
296
|
+
]),
|
|
297
|
+
providerRows: [{ moduleId: 'iptables', capabilityName: 'firewall' }],
|
|
298
|
+
providerStates: deployed('iptables', 'mid', 'axon'),
|
|
299
|
+
depth: 2,
|
|
300
|
+
chains: [{ capability: 'firewall', moduleIds: ['iptables', 'mid', 'axon'] }],
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
expect(result.nodes.map((n) => n.moduleId)).toEqual(['iptables', 'mid']);
|
|
304
|
+
expect(result.truncated).toBe(true);
|
|
305
|
+
// Still reported WHOLE, including the member past the bound. Half a path is
|
|
306
|
+
// not a path, and the operator asked to see less, not to be told the packet
|
|
307
|
+
// stops at `mid`.
|
|
308
|
+
expect(result.chains[0]?.moduleIds).toEqual(['iptables', 'mid', 'axon']);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('a module with no bindings at all depends on nothing', () => {
|
|
312
|
+
const result = computeClosure({
|
|
313
|
+
rootModuleId: 'site',
|
|
314
|
+
manifests,
|
|
315
|
+
providerRows: rows,
|
|
316
|
+
providerStates: deployed('cpanel', 'caddy', 'forgejo'),
|
|
317
|
+
bindings: new Map([['site', new Map()]]),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
expect(result.nodes).toEqual([]);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bounded capability closure: which modules a selected module stands on.
|
|
3
|
+
*
|
|
4
|
+
* Pure, so the console's central question is answerable without a database and
|
|
5
|
+
* the awkward graph shapes below are testable directly.
|
|
6
|
+
*
|
|
7
|
+
* The edge is `planConsumerCleanup()`'s edge, called rather than copied. That
|
|
8
|
+
* function already answers "which providers does this module reach", already
|
|
9
|
+
* counts `requires` AND `optional` as dependency edges, and already handles the
|
|
10
|
+
* two cases a fresh implementation gets wrong: a capability with several
|
|
11
|
+
* providers (firewall has an edge provider plus inner layers, all of them real)
|
|
12
|
+
* and a module that both provides and consumes a capability, which must not
|
|
13
|
+
* become its own dependency. A second walk would drift from it.
|
|
14
|
+
*
|
|
15
|
+
* What this adds is the part a one-hop cleanup plan has no use for: distance,
|
|
16
|
+
* optionality, and termination.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
type ConsumedCapabilities,
|
|
21
|
+
type ProviderRow,
|
|
22
|
+
type ProviderState,
|
|
23
|
+
planConsumerCleanup,
|
|
24
|
+
} from '../services/consumer-cleanup';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Hops to walk when the caller does not say.
|
|
28
|
+
*
|
|
29
|
+
* Two, because the fleet's useful answer is "what this stands on, and what that
|
|
30
|
+
* stands on". Deliberately a default and not a law: `depth: UNBOUNDED` walks the
|
|
31
|
+
* whole graph, and the walk terminates on cycles either way.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_CLOSURE_DEPTH = 2;
|
|
34
|
+
|
|
35
|
+
/** Walk until the graph runs out rather than until a hop count does. */
|
|
36
|
+
export const UNBOUNDED_CLOSURE_DEPTH = 0;
|
|
37
|
+
|
|
38
|
+
export interface ClosureRequest {
|
|
39
|
+
rootModuleId: string;
|
|
40
|
+
/**
|
|
41
|
+
* Each module's consumed capabilities, by module id. A module with no entry
|
|
42
|
+
* ends that branch of the walk rather than failing the whole thing: a manifest
|
|
43
|
+
* celilo cannot parse is a real state, and one bad row must not blank the
|
|
44
|
+
* picture.
|
|
45
|
+
*/
|
|
46
|
+
manifests: ReadonlyMap<string, ConsumedCapabilities>;
|
|
47
|
+
providerRows: readonly ProviderRow[];
|
|
48
|
+
providerStates: readonly ProviderState[];
|
|
49
|
+
depth?: number;
|
|
50
|
+
/**
|
|
51
|
+
* Which providers each module has actually called into, keyed by module id
|
|
52
|
+
* then capability name.
|
|
53
|
+
*
|
|
54
|
+
* When supplied, the walk follows only real bindings. Without it the walk
|
|
55
|
+
* follows every declared capability, which answers "what could this reach"
|
|
56
|
+
* rather than "what is this standing on". Optional so the walk stays testable
|
|
57
|
+
* without a database, not so the distinction is optional.
|
|
58
|
+
*/
|
|
59
|
+
bindings?: ReadonlyMap<string, ReadonlyMap<string, string>>;
|
|
60
|
+
/**
|
|
61
|
+
* Ordered provider chains, most downstream first.
|
|
62
|
+
*
|
|
63
|
+
* These are edges NO MANIFEST DECLARES. celilo wires the `firewall`
|
|
64
|
+
* providers into a chain at hook time from `has_external`, so `iptables`
|
|
65
|
+
* genuinely stands on `axon` while neither manifest says a word about the
|
|
66
|
+
* other. A walk over manifests alone stops at `iptables`, and the box the
|
|
67
|
+
* packet actually leaves through never appears in the answer.
|
|
68
|
+
*
|
|
69
|
+
* Supplied by the caller rather than derived here so this stays pure. The CLI
|
|
70
|
+
* reads them with `loadFirewallChain`.
|
|
71
|
+
*/
|
|
72
|
+
chains?: readonly DelegationChain[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One capability's providers in delegation order.
|
|
77
|
+
*
|
|
78
|
+
* Ordered because for `firewall` they are not alternatives: the order is the
|
|
79
|
+
* packet's path. Same shape as the console protocol's `DelegationChain`, so the
|
|
80
|
+
* server hands it on rather than translating it.
|
|
81
|
+
*/
|
|
82
|
+
export interface DelegationChain {
|
|
83
|
+
capability: string;
|
|
84
|
+
/** Module ids, most downstream first. */
|
|
85
|
+
moduleIds: string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ClosureNode {
|
|
89
|
+
moduleId: string;
|
|
90
|
+
/** 1 is a direct dependency of the root. */
|
|
91
|
+
hop: number;
|
|
92
|
+
/**
|
|
93
|
+
* True when every edge that reaches this module is an `optional.capabilities`
|
|
94
|
+
* edge.
|
|
95
|
+
*
|
|
96
|
+
* Load-bearing rather than cosmetic: on a real fleet the internal DNS resolver
|
|
97
|
+
* is reached only optionally, so a walk that followed `requires` alone drops
|
|
98
|
+
* the resolver out of the picture entirely while looking complete.
|
|
99
|
+
*/
|
|
100
|
+
optional: boolean;
|
|
101
|
+
/** Capability names by which the walk reached this module, sorted. */
|
|
102
|
+
via: string[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ClosureResult {
|
|
106
|
+
nodes: ClosureNode[];
|
|
107
|
+
/**
|
|
108
|
+
* The delegation chains this closure touched, whole.
|
|
109
|
+
*
|
|
110
|
+
* A chain is emitted when the walk reached ANY of its members, and it carries
|
|
111
|
+
* every member rather than only the reached ones, because half a path is not
|
|
112
|
+
* a path. Empty means the closure touched no chain — which, now that
|
|
113
|
+
* something computes them, is an answer rather than a gap.
|
|
114
|
+
*/
|
|
115
|
+
chains: DelegationChain[];
|
|
116
|
+
/** Hops actually walked. Echoes the request so the console can say what it bounded. */
|
|
117
|
+
depth: number;
|
|
118
|
+
/**
|
|
119
|
+
* True when the walk stopped because it hit the bound and the graph continued.
|
|
120
|
+
* Distinct from an exhausted graph, which is a complete answer.
|
|
121
|
+
*/
|
|
122
|
+
truncated: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface Edge {
|
|
126
|
+
to: string;
|
|
127
|
+
hop: number;
|
|
128
|
+
capability: string;
|
|
129
|
+
optional: boolean;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Which of a module's consumed capabilities it declared as optional.
|
|
134
|
+
*
|
|
135
|
+
* A name in both sets counts as required: `requires` is the stronger claim, and
|
|
136
|
+
* a module that declares a capability both ways still fails without it.
|
|
137
|
+
*/
|
|
138
|
+
function optionalCapabilityNames(manifest: ConsumedCapabilities): Set<string> {
|
|
139
|
+
const required = new Set((manifest.requires?.capabilities ?? []).map((c) => c.name));
|
|
140
|
+
return new Set(
|
|
141
|
+
(manifest.optional?.capabilities ?? []).map((c) => c.name).filter((n) => !required.has(n)),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A chain's consecutive pairs, as edges out of each member.
|
|
147
|
+
*
|
|
148
|
+
* `[iptables, axon]` becomes one edge `iptables -> axon`. The last member has
|
|
149
|
+
* no edge out of it: it is the internet-facing end and delegates to nothing.
|
|
150
|
+
*/
|
|
151
|
+
function delegationEdgesByModule(
|
|
152
|
+
chains: readonly DelegationChain[],
|
|
153
|
+
): Map<string, { to: string; capability: string }[]> {
|
|
154
|
+
const byModule = new Map<string, { to: string; capability: string }[]>();
|
|
155
|
+
for (const chain of chains) {
|
|
156
|
+
for (let i = 0; i + 1 < chain.moduleIds.length; i += 1) {
|
|
157
|
+
const from = chain.moduleIds[i];
|
|
158
|
+
const to = chain.moduleIds[i + 1];
|
|
159
|
+
if (from === undefined || to === undefined) continue;
|
|
160
|
+
const out = byModule.get(from) ?? [];
|
|
161
|
+
out.push({ to, capability: chain.capability });
|
|
162
|
+
byModule.set(from, out);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return byModule;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function computeClosure(request: ClosureRequest): ClosureResult {
|
|
169
|
+
const depth = request.depth ?? DEFAULT_CLOSURE_DEPTH;
|
|
170
|
+
const providerRows = [...request.providerRows];
|
|
171
|
+
const providerStates = [...request.providerStates];
|
|
172
|
+
const chains = request.chains ?? [];
|
|
173
|
+
const delegationsOut = delegationEdgesByModule(chains);
|
|
174
|
+
|
|
175
|
+
const edges: Edge[] = [];
|
|
176
|
+
// The root counts as visited from the start, so a cycle back to it terminates
|
|
177
|
+
// rather than re-entering, and the selection never lists itself.
|
|
178
|
+
const visited = new Set([request.rootModuleId]);
|
|
179
|
+
let frontier = [request.rootModuleId];
|
|
180
|
+
let hop = 0;
|
|
181
|
+
let truncated = false;
|
|
182
|
+
|
|
183
|
+
while (frontier.length > 0) {
|
|
184
|
+
hop += 1;
|
|
185
|
+
if (depth !== UNBOUNDED_CLOSURE_DEPTH && hop > depth) {
|
|
186
|
+
// Something was still reachable when the bound stopped us. That is a
|
|
187
|
+
// different answer from an exhausted graph and the console says so.
|
|
188
|
+
truncated = true;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const next: string[] = [];
|
|
193
|
+
for (const moduleId of frontier) {
|
|
194
|
+
const manifest = request.manifests.get(moduleId);
|
|
195
|
+
if (!manifest) continue;
|
|
196
|
+
|
|
197
|
+
const optionalNames = optionalCapabilityNames(manifest);
|
|
198
|
+
const bound = request.bindings?.get(moduleId);
|
|
199
|
+
|
|
200
|
+
for (const target of planConsumerCleanup(moduleId, manifest, providerRows, providerStates)) {
|
|
201
|
+
// A cycle walks back to the selection. Terminating the WALK there is not
|
|
202
|
+
// enough: the edge is still real, and recording it would list the
|
|
203
|
+
// selected module as one of its own dependencies. caddy requires
|
|
204
|
+
// authentik and authentik requires caddy, so this is the ordinary case
|
|
205
|
+
// on a real fleet rather than a pathological one.
|
|
206
|
+
if (target.providerId === request.rootModuleId) continue;
|
|
207
|
+
|
|
208
|
+
for (const capability of target.capabilityNames) {
|
|
209
|
+
// A capability the consumer declared and never called into is not a
|
|
210
|
+
// dependency. Following it would put a provider inside the closure on
|
|
211
|
+
// the strength of a manifest line, which is a claim that the module is
|
|
212
|
+
// standing on something it has never touched.
|
|
213
|
+
if (bound && bound.get(capability) !== target.providerId) continue;
|
|
214
|
+
|
|
215
|
+
edges.push({
|
|
216
|
+
to: target.providerId,
|
|
217
|
+
hop,
|
|
218
|
+
capability,
|
|
219
|
+
optional: optionalNames.has(capability),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
if (!visited.has(target.providerId)) {
|
|
223
|
+
visited.add(target.providerId);
|
|
224
|
+
next.push(target.providerId);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The edges the manifests do not carry. Followed here, inside the same loop,
|
|
230
|
+
// so a delegated provider gets its hop, its cycle check and its depth bound
|
|
231
|
+
// from the one walk rather than from a second rule applied afterwards.
|
|
232
|
+
//
|
|
233
|
+
// Never optional: a packet has no alternative route to the internet, so a
|
|
234
|
+
// delegation is the strongest kind of dependency there is.
|
|
235
|
+
for (const moduleId of frontier) {
|
|
236
|
+
for (const edge of delegationsOut.get(moduleId) ?? []) {
|
|
237
|
+
if (edge.to === request.rootModuleId) continue;
|
|
238
|
+
edges.push({ to: edge.to, hop, capability: edge.capability, optional: false });
|
|
239
|
+
if (!visited.has(edge.to)) {
|
|
240
|
+
visited.add(edge.to);
|
|
241
|
+
next.push(edge.to);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
frontier = next;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const nodes = collectNodes(edges);
|
|
250
|
+
const reached = new Set([request.rootModuleId, ...nodes.map((node) => node.moduleId)]);
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
nodes,
|
|
254
|
+
chains: chains.filter((chain) => chain.moduleIds.some((id) => reached.has(id))),
|
|
255
|
+
depth,
|
|
256
|
+
truncated,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Fold the edge list into one node per module.
|
|
262
|
+
*
|
|
263
|
+
* A module is recorded at the SHORTEST hop that reached it, and is optional only
|
|
264
|
+
* if no edge anywhere in the walk reached it by a required capability. Deciding
|
|
265
|
+
* either from the first edge seen would make the answer depend on iteration
|
|
266
|
+
* order.
|
|
267
|
+
*/
|
|
268
|
+
function collectNodes(edges: readonly Edge[]): ClosureNode[] {
|
|
269
|
+
const byModule = new Map<string, { hop: number; via: Set<string>; anyRequired: boolean }>();
|
|
270
|
+
|
|
271
|
+
for (const edge of edges) {
|
|
272
|
+
const existing = byModule.get(edge.to);
|
|
273
|
+
if (!existing) {
|
|
274
|
+
byModule.set(edge.to, {
|
|
275
|
+
hop: edge.hop,
|
|
276
|
+
via: new Set([edge.capability]),
|
|
277
|
+
anyRequired: !edge.optional,
|
|
278
|
+
});
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
existing.hop = Math.min(existing.hop, edge.hop);
|
|
282
|
+
existing.via.add(edge.capability);
|
|
283
|
+
existing.anyRequired = existing.anyRequired || !edge.optional;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return [...byModule.entries()]
|
|
287
|
+
.map(([moduleId, v]) => ({
|
|
288
|
+
moduleId,
|
|
289
|
+
hop: v.hop,
|
|
290
|
+
optional: !v.anyRequired,
|
|
291
|
+
via: [...v.via].sort(),
|
|
292
|
+
}))
|
|
293
|
+
.sort((a, b) => a.hop - b.hop || a.moduleId.localeCompare(b.moduleId));
|
|
294
|
+
}
|