@celilo/cli 1.12.0 → 1.13.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 +17 -2
- package/package.json +3 -3
- 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.ts +122 -0
- package/src/cli/commands/module-list.ts +3 -41
- package/src/cli/completion.ts +5 -0
- package/src/cli/index.ts +25 -1
- package/src/console/closure.test.ts +246 -0
- package/src/console/closure.ts +208 -0
- package/src/console/control-plane-boundary.test.ts +75 -0
- package/src/console/projection.test.ts +231 -0
- package/src/console/projection.ts +327 -0
- package/src/db/schema.ts +19 -14
- package/src/hooks/broker.test.ts +4 -6
- 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/schema.ts +35 -0
- package/src/manifest/validate.test.ts +142 -0
- package/src/manifest/validate.ts +101 -0
- 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/policy/capability-shape-baseline.ts +8 -0
- package/src/policy/module-business-baseline.ts +12 -0
- package/src/services/alerting/observed-health.ts +71 -0
- package/src/services/api-principal-enrolment.test.ts +179 -0
- package/src/services/api-principal-enrolment.ts +103 -0
- package/src/services/audit/backups.ts +10 -1
- package/src/services/backup-metadata.ts +19 -11
- package/src/services/consumer-cleanup.ts +31 -5
- 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
|
@@ -0,0 +1,246 @@
|
|
|
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 module with no bindings at all depends on nothing', () => {
|
|
236
|
+
const result = computeClosure({
|
|
237
|
+
rootModuleId: 'site',
|
|
238
|
+
manifests,
|
|
239
|
+
providerRows: rows,
|
|
240
|
+
providerStates: deployed('cpanel', 'caddy', 'forgejo'),
|
|
241
|
+
bindings: new Map([['site', new Map()]]),
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(result.nodes).toEqual([]);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
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
|
+
|
|
62
|
+
export interface ClosureNode {
|
|
63
|
+
moduleId: string;
|
|
64
|
+
/** 1 is a direct dependency of the root. */
|
|
65
|
+
hop: number;
|
|
66
|
+
/**
|
|
67
|
+
* True when every edge that reaches this module is an `optional.capabilities`
|
|
68
|
+
* edge.
|
|
69
|
+
*
|
|
70
|
+
* Load-bearing rather than cosmetic: on a real fleet the internal DNS resolver
|
|
71
|
+
* is reached only optionally, so a walk that followed `requires` alone drops
|
|
72
|
+
* the resolver out of the picture entirely while looking complete.
|
|
73
|
+
*/
|
|
74
|
+
optional: boolean;
|
|
75
|
+
/** Capability names by which the walk reached this module, sorted. */
|
|
76
|
+
via: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ClosureResult {
|
|
80
|
+
nodes: ClosureNode[];
|
|
81
|
+
/** Hops actually walked. Echoes the request so the console can say what it bounded. */
|
|
82
|
+
depth: number;
|
|
83
|
+
/**
|
|
84
|
+
* True when the walk stopped because it hit the bound and the graph continued.
|
|
85
|
+
* Distinct from an exhausted graph, which is a complete answer.
|
|
86
|
+
*/
|
|
87
|
+
truncated: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface Edge {
|
|
91
|
+
to: string;
|
|
92
|
+
hop: number;
|
|
93
|
+
capability: string;
|
|
94
|
+
optional: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Which of a module's consumed capabilities it declared as optional.
|
|
99
|
+
*
|
|
100
|
+
* A name in both sets counts as required: `requires` is the stronger claim, and
|
|
101
|
+
* a module that declares a capability both ways still fails without it.
|
|
102
|
+
*/
|
|
103
|
+
function optionalCapabilityNames(manifest: ConsumedCapabilities): Set<string> {
|
|
104
|
+
const required = new Set((manifest.requires?.capabilities ?? []).map((c) => c.name));
|
|
105
|
+
return new Set(
|
|
106
|
+
(manifest.optional?.capabilities ?? []).map((c) => c.name).filter((n) => !required.has(n)),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function computeClosure(request: ClosureRequest): ClosureResult {
|
|
111
|
+
const depth = request.depth ?? DEFAULT_CLOSURE_DEPTH;
|
|
112
|
+
const providerRows = [...request.providerRows];
|
|
113
|
+
const providerStates = [...request.providerStates];
|
|
114
|
+
|
|
115
|
+
const edges: Edge[] = [];
|
|
116
|
+
// The root counts as visited from the start, so a cycle back to it terminates
|
|
117
|
+
// rather than re-entering, and the selection never lists itself.
|
|
118
|
+
const visited = new Set([request.rootModuleId]);
|
|
119
|
+
let frontier = [request.rootModuleId];
|
|
120
|
+
let hop = 0;
|
|
121
|
+
let truncated = false;
|
|
122
|
+
|
|
123
|
+
while (frontier.length > 0) {
|
|
124
|
+
hop += 1;
|
|
125
|
+
if (depth !== UNBOUNDED_CLOSURE_DEPTH && hop > depth) {
|
|
126
|
+
// Something was still reachable when the bound stopped us. That is a
|
|
127
|
+
// different answer from an exhausted graph and the console says so.
|
|
128
|
+
truncated = true;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const next: string[] = [];
|
|
133
|
+
for (const moduleId of frontier) {
|
|
134
|
+
const manifest = request.manifests.get(moduleId);
|
|
135
|
+
if (!manifest) continue;
|
|
136
|
+
|
|
137
|
+
const optionalNames = optionalCapabilityNames(manifest);
|
|
138
|
+
const bound = request.bindings?.get(moduleId);
|
|
139
|
+
|
|
140
|
+
for (const target of planConsumerCleanup(moduleId, manifest, providerRows, providerStates)) {
|
|
141
|
+
// A cycle walks back to the selection. Terminating the WALK there is not
|
|
142
|
+
// enough: the edge is still real, and recording it would list the
|
|
143
|
+
// selected module as one of its own dependencies. caddy requires
|
|
144
|
+
// authentik and authentik requires caddy, so this is the ordinary case
|
|
145
|
+
// on a real fleet rather than a pathological one.
|
|
146
|
+
if (target.providerId === request.rootModuleId) continue;
|
|
147
|
+
|
|
148
|
+
for (const capability of target.capabilityNames) {
|
|
149
|
+
// A capability the consumer declared and never called into is not a
|
|
150
|
+
// dependency. Following it would put a provider inside the closure on
|
|
151
|
+
// the strength of a manifest line, which is a claim that the module is
|
|
152
|
+
// standing on something it has never touched.
|
|
153
|
+
if (bound && bound.get(capability) !== target.providerId) continue;
|
|
154
|
+
|
|
155
|
+
edges.push({
|
|
156
|
+
to: target.providerId,
|
|
157
|
+
hop,
|
|
158
|
+
capability,
|
|
159
|
+
optional: optionalNames.has(capability),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (!visited.has(target.providerId)) {
|
|
163
|
+
visited.add(target.providerId);
|
|
164
|
+
next.push(target.providerId);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
frontier = next;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { nodes: collectNodes(edges), depth, truncated };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Fold the edge list into one node per module.
|
|
176
|
+
*
|
|
177
|
+
* A module is recorded at the SHORTEST hop that reached it, and is optional only
|
|
178
|
+
* if no edge anywhere in the walk reached it by a required capability. Deciding
|
|
179
|
+
* either from the first edge seen would make the answer depend on iteration
|
|
180
|
+
* order.
|
|
181
|
+
*/
|
|
182
|
+
function collectNodes(edges: readonly Edge[]): ClosureNode[] {
|
|
183
|
+
const byModule = new Map<string, { hop: number; via: Set<string>; anyRequired: boolean }>();
|
|
184
|
+
|
|
185
|
+
for (const edge of edges) {
|
|
186
|
+
const existing = byModule.get(edge.to);
|
|
187
|
+
if (!existing) {
|
|
188
|
+
byModule.set(edge.to, {
|
|
189
|
+
hop: edge.hop,
|
|
190
|
+
via: new Set([edge.capability]),
|
|
191
|
+
anyRequired: !edge.optional,
|
|
192
|
+
});
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
existing.hop = Math.min(existing.hop, edge.hop);
|
|
196
|
+
existing.via.add(edge.capability);
|
|
197
|
+
existing.anyRequired = existing.anyRequired || !edge.optional;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return [...byModule.entries()]
|
|
201
|
+
.map(([moduleId, v]) => ({
|
|
202
|
+
moduleId,
|
|
203
|
+
hop: v.hop,
|
|
204
|
+
optional: !v.anyRequired,
|
|
205
|
+
via: [...v.via].sort(),
|
|
206
|
+
}))
|
|
207
|
+
.sort((a, b) => a.hop - b.hop || a.moduleId.localeCompare(b.moduleId));
|
|
208
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The console must not be reachable from the data plane.
|
|
3
|
+
*
|
|
4
|
+
* This is asserted here, cheaply, on every pull request. The e2e suite asserts
|
|
5
|
+
* the same thing for real by failing to reach the console from a data-plane
|
|
6
|
+
* zone, but that suite is slow, needs Docker, and does not run on every change.
|
|
7
|
+
* The way this boundary actually erodes is a one-line manifest edit that adds
|
|
8
|
+
* `private_web` because someone wanted to reach the console from a laptop
|
|
9
|
+
* without bringing up the VPN, and a gate that only fires in e2e would not catch
|
|
10
|
+
* that until much later.
|
|
11
|
+
*
|
|
12
|
+
* The history is why this is a test and not a comment. The change's second draft
|
|
13
|
+
* DID publish the console through caddy-internal, the operator refused it, and
|
|
14
|
+
* the final verification step still said "the route resolves through
|
|
15
|
+
* caddy-internal" for a further round after that. Prose did not hold this.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
import { parse } from 'yaml';
|
|
19
|
+
|
|
20
|
+
const MANIFEST_PATH = new URL(
|
|
21
|
+
'../../../../modules/celilo-web-console/manifest.yml',
|
|
22
|
+
import.meta.url,
|
|
23
|
+
).pathname;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Capabilities that give something a way in from the data plane.
|
|
27
|
+
*
|
|
28
|
+
* `private_web` and `public_web` publish a route through a reverse proxy.
|
|
29
|
+
* `firewall` is how a module asks for a port forward or an ingress address.
|
|
30
|
+
* Any of the three would put a data-plane client on a path to the control
|
|
31
|
+
* plane.
|
|
32
|
+
*/
|
|
33
|
+
const INGRESS_CAPABILITIES = ['private_web', 'public_web', 'firewall'];
|
|
34
|
+
|
|
35
|
+
interface CapabilityRef {
|
|
36
|
+
name: string;
|
|
37
|
+
}
|
|
38
|
+
interface Manifest {
|
|
39
|
+
id: string;
|
|
40
|
+
requires?: { capabilities?: CapabilityRef[]; system?: { zone?: string } };
|
|
41
|
+
optional?: { capabilities?: CapabilityRef[] };
|
|
42
|
+
provides?: { capabilities?: CapabilityRef[] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function loadManifest(): Promise<Manifest> {
|
|
46
|
+
return parse(await Bun.file(MANIFEST_PATH).text()) as Manifest;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('celilo-web-console stays in the control plane', () => {
|
|
50
|
+
test('lands in secure-mgmt and nowhere else', async () => {
|
|
51
|
+
const manifest = await loadManifest();
|
|
52
|
+
expect(manifest.requires?.system?.zone).toBe('secure-mgmt');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('requires no capability that could give it data-plane ingress', async () => {
|
|
56
|
+
const manifest = await loadManifest();
|
|
57
|
+
const required = (manifest.requires?.capabilities ?? []).map((c) => c.name);
|
|
58
|
+
const found = required.filter((name) => INGRESS_CAPABILITIES.includes(name));
|
|
59
|
+
expect(found).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('does not pick up ingress through an OPTIONAL capability either', async () => {
|
|
63
|
+
// Optional is the likelier hole. It reads as harmless, and the closure walk
|
|
64
|
+
// in this same directory exists because optional edges are real edges.
|
|
65
|
+
const manifest = await loadManifest();
|
|
66
|
+
const optional = (manifest.optional?.capabilities ?? []).map((c) => c.name);
|
|
67
|
+
const found = optional.filter((name) => INGRESS_CAPABILITIES.includes(name));
|
|
68
|
+
expect(found).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('provides nothing, so nothing can bind to it as a dependency', async () => {
|
|
72
|
+
const manifest = await loadManifest();
|
|
73
|
+
expect(manifest.provides?.capabilities ?? []).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
});
|