@celilo/cli 0.23.0 → 0.24.1
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 -2
- package/CELILO_SUBSYSTEMS.md +27 -7
- package/package.json +6 -5
- package/src/cli/commands/alerts-act.ts +1 -1
- package/src/cli/commands/backup-create.ts +26 -11
- package/src/cli/commands/backup-list.test.ts +83 -0
- package/src/cli/commands/backup-list.ts +67 -3
- package/src/cli/commands/backup-prune.ts +17 -17
- package/src/cli/commands/backup-sweep.ts +20 -8
- package/src/cli/commands/firewall-interface-list.test.ts +85 -0
- package/src/cli/commands/firewall-interface-list.ts +123 -0
- package/src/cli/commands/machine-add.ts +30 -2
- package/src/cli/commands/module-config.test.ts +70 -3
- package/src/cli/commands/module-config.ts +262 -28
- package/src/cli/commands/module-status.ts +155 -12
- package/src/cli/commands/monitor.ts +116 -19
- package/src/cli/commands/system-migrate.ts +14 -0
- package/src/cli/commands/system-update.ts +4 -1
- package/src/cli/completion.ts +35 -9
- package/src/cli/index.ts +59 -2
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/hooks/capability-loader.ts +130 -4
- package/src/hooks/load-hook-config.test.ts +169 -1
- package/src/hooks/load-hook-config.ts +118 -20
- package/src/hooks/types.ts +2 -1
- package/src/manifest/contracts/v1.ts +16 -0
- package/src/manifest/schema.ts +40 -65
- package/src/services/alerting/builtin-monitors.test.ts +18 -10
- package/src/services/alerting/cadence-migration.test.ts +155 -0
- package/src/services/alerting/cadence-migration.ts +90 -0
- package/src/services/alerting/coverage-source.ts +8 -11
- package/src/services/alerting/deploy-hooks.test.ts +16 -7
- package/src/services/alerting/deploy-hooks.ts +11 -5
- package/src/services/alerting/health-cadence.test.ts +58 -0
- package/src/services/alerting/health-cadence.ts +128 -0
- package/src/services/alerting/health-coverage.ts +18 -8
- package/src/services/alerting/monitors.ts +50 -15
- package/src/services/alerting/sweep-runner.test.ts +51 -3
- package/src/services/alerting/sweep-runner.ts +30 -7
- package/src/services/audit/backup-source.ts +24 -1
- package/src/services/audit/backups.test.ts +95 -10
- package/src/services/audit/backups.ts +40 -37
- package/src/services/audit/interface-classification.test.ts +220 -0
- package/src/services/audit/interface-classification.ts +167 -0
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-age-agreement.test.ts +118 -0
- package/src/services/backup-create.ts +36 -30
- package/src/services/backup-metadata.ts +52 -1
- package/src/services/backup-retention.test.ts +123 -0
- package/src/services/backup-retention.ts +66 -5
- package/src/services/backup-schedule.test.ts +166 -0
- package/src/services/backup-schedule.ts +105 -15
- package/src/services/backup-staging.ts +14 -1
- package/src/services/backup-sweep.test.ts +22 -3
- package/src/services/backup-sweep.ts +15 -5
- package/src/services/cadence.test.ts +97 -0
- package/src/services/cadence.ts +165 -0
- package/src/services/config-provenance.test.ts +155 -0
- package/src/services/config-provenance.ts +104 -0
- package/src/services/machine-detector.ts +23 -1
- package/src/services/module-config.ts +33 -0
- package/src/services/storage-providers/s3.test.ts +96 -13
- package/src/services/storage-providers/s3.ts +48 -15
- package/src/services/zone-detector.test.ts +34 -3
- package/src/services/zone-detector.ts +33 -13
- package/src/variables/context.ts +69 -15
- package/src/variables/declarative-derivation.test.ts +53 -0
- package/src/variables/declarative-derivation.ts +13 -2
|
@@ -6,7 +6,24 @@
|
|
|
6
6
|
import { subnetContains } from '@celilo/capabilities';
|
|
7
7
|
import { eq } from 'drizzle-orm';
|
|
8
8
|
import { getDb } from '../db/client';
|
|
9
|
-
import { type NetworkZone, systemConfig } from '../db/schema';
|
|
9
|
+
import { NETWORK_ZONES, type NetworkZone, systemConfig } from '../db/schema';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The zones a subnet can be declared for — every `NetworkZone` except
|
|
13
|
+
* `external`.
|
|
14
|
+
*
|
|
15
|
+
* `external` is deliberately absent and must stay absent: it has no
|
|
16
|
+
* `network.external.subnet` and must never be given one. It is not a segment
|
|
17
|
+
* celilo manages, it is whatever the ISP handed you, so it is the RESIDUAL —
|
|
18
|
+
* decided by `isPubliclyRoutable`, not by containment (design D1, amended).
|
|
19
|
+
*
|
|
20
|
+
* Derived from NETWORK_ZONES rather than listed by hand. The hand-written list
|
|
21
|
+
* that used to be here had already dropped `control-plane-vpn`, which is the
|
|
22
|
+
* third instance of that bug class in this repo.
|
|
23
|
+
*/
|
|
24
|
+
const SUBNET_BACKED_ZONES: readonly NetworkZone[] = NETWORK_ZONES.filter(
|
|
25
|
+
(zone) => zone !== 'external',
|
|
26
|
+
);
|
|
10
27
|
|
|
11
28
|
/**
|
|
12
29
|
* Get system network configuration for a zone
|
|
@@ -28,25 +45,28 @@ async function getZoneSubnet(zone: NetworkZone): Promise<string | null> {
|
|
|
28
45
|
}
|
|
29
46
|
|
|
30
47
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
48
|
+
* Which zone an address belongs to, or `'unknown'` when celilo cannot say.
|
|
49
|
+
*
|
|
50
|
+
* **`'unknown'` is the honest answer, and it used to be unreachable.** This
|
|
51
|
+
* returned `'external'` on no-match — so on a firewall with five RFC1918 legs
|
|
52
|
+
* and three declared zones, private gateway addresses were each reported as
|
|
53
|
+
* facing the internet. `NetworkInterface.zone` was already typed
|
|
54
|
+
* `NetworkZone | 'unknown'` and nothing could produce it, because this claimed
|
|
55
|
+
* `external` instead. The slot for the honest answer existed and was
|
|
56
|
+
* unreachable.
|
|
33
57
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
58
|
+
* This answers ONLY the containment question. Whether an unmatched address is
|
|
59
|
+
* an external edge is a different question, answered by `isPubliclyRoutable` —
|
|
60
|
+
* see design D1 on why conflating the two was the defect.
|
|
36
61
|
*/
|
|
37
|
-
export async function detectZoneFromIp(ip: string): Promise<NetworkZone> {
|
|
38
|
-
|
|
39
|
-
const zones: NetworkZone[] = ['internal', 'dmz', 'app', 'secure', 'secure-mgmt'];
|
|
40
|
-
|
|
41
|
-
for (const zone of zones) {
|
|
62
|
+
export async function detectZoneFromIp(ip: string): Promise<NetworkZone | 'unknown'> {
|
|
63
|
+
for (const zone of SUBNET_BACKED_ZONES) {
|
|
42
64
|
const subnet = await getZoneSubnet(zone);
|
|
43
65
|
if (subnet && subnetContains(subnet, ip)) {
|
|
44
66
|
return zone;
|
|
45
67
|
}
|
|
46
68
|
}
|
|
47
|
-
|
|
48
|
-
// No match found - must be external
|
|
49
|
-
return 'external';
|
|
69
|
+
return 'unknown';
|
|
50
70
|
}
|
|
51
71
|
|
|
52
72
|
/**
|
package/src/variables/context.ts
CHANGED
|
@@ -185,9 +185,14 @@ function resolveSelfRefsInObject(
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
/**
|
|
188
|
-
* Build resolution context for a module
|
|
188
|
+
* Build the resolution context for a module, provisioning as it goes.
|
|
189
189
|
*
|
|
190
|
-
* Execution function (Rule 10.1) - performs database queries
|
|
190
|
+
* Execution function (Rule 10.1) - performs database queries AND writes:
|
|
191
|
+
* it seeds `module_configs` with defaults and zone-derived networking, and
|
|
192
|
+
* records the module's deployed system (allocating IPAM addresses when the
|
|
193
|
+
* host is a celilo-provisioned container). This is the generate/deploy-time
|
|
194
|
+
* entrypoint. Anything that only wants to READ the resolved configuration
|
|
195
|
+
* wants {@link readResolutionContext} instead.
|
|
191
196
|
*
|
|
192
197
|
* @param moduleId - Module to build context for
|
|
193
198
|
* @param db - Database client (optional, for testing)
|
|
@@ -197,6 +202,55 @@ export async function buildResolutionContext(
|
|
|
197
202
|
moduleId: string,
|
|
198
203
|
db = getDb(),
|
|
199
204
|
): Promise<ResolutionContext> {
|
|
205
|
+
return assembleResolutionContext(moduleId, db, { provision: true });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The same resolved configuration, computed without touching the database.
|
|
210
|
+
*
|
|
211
|
+
* Every derived value is recomputed from its current upstream, exactly as a
|
|
212
|
+
* build would compute it, but nothing is written: no config rows are seeded,
|
|
213
|
+
* no addresses are allocated, no deployed system is recorded. That makes it
|
|
214
|
+
* safe to call from read paths that run on a cadence — health checks, hook
|
|
215
|
+
* invocations, capability factories — where a provisioning side effect would
|
|
216
|
+
* be both surprising and, in the IPAM case, harmful.
|
|
217
|
+
*
|
|
218
|
+
* This is what {@link import('../hooks/load-hook-config').loadHookConfigMap}
|
|
219
|
+
* uses so a hook sees the value a derive currently produces rather than only
|
|
220
|
+
* the ones that happen to have been stored.
|
|
221
|
+
*
|
|
222
|
+
* @param moduleId - Module to build context for
|
|
223
|
+
* @param db - Database client (optional, for testing)
|
|
224
|
+
* @returns Resolution context with all data sources
|
|
225
|
+
*/
|
|
226
|
+
export async function readResolutionContext(
|
|
227
|
+
moduleId: string,
|
|
228
|
+
db = getDb(),
|
|
229
|
+
): Promise<ResolutionContext> {
|
|
230
|
+
return assembleResolutionContext(moduleId, db, { provision: false });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The shared body of both entrypoints. `provision` is deliberately private
|
|
235
|
+
* (Rule 10.3): callers choose a named function, not a flag.
|
|
236
|
+
*/
|
|
237
|
+
async function assembleResolutionContext(
|
|
238
|
+
moduleId: string,
|
|
239
|
+
db: DbClient,
|
|
240
|
+
{ provision }: { provision: boolean },
|
|
241
|
+
): Promise<ResolutionContext> {
|
|
242
|
+
/**
|
|
243
|
+
* Seed a config row — a no-op when only reading. Every seeded value is also
|
|
244
|
+
* assigned into `selfConfig` by the caller, so the resolved context is the
|
|
245
|
+
* same either way; what differs is whether it is written down.
|
|
246
|
+
*/
|
|
247
|
+
const persistConfig = (
|
|
248
|
+
key: string,
|
|
249
|
+
value: string | number | boolean | unknown[] | Record<string, unknown>,
|
|
250
|
+
): void => {
|
|
251
|
+
if (provision) upsertModuleConfig(db, moduleId, key, value);
|
|
252
|
+
};
|
|
253
|
+
|
|
200
254
|
// Fetch module manifest for VM resources
|
|
201
255
|
const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
202
256
|
|
|
@@ -226,12 +280,12 @@ export async function buildResolutionContext(
|
|
|
226
280
|
|
|
227
281
|
// Store assigned values in module config
|
|
228
282
|
if (assigned.hostname) {
|
|
229
|
-
|
|
283
|
+
persistConfig('hostname', assigned.hostname);
|
|
230
284
|
selfConfig.hostname = assigned.hostname;
|
|
231
285
|
}
|
|
232
286
|
|
|
233
287
|
if (assigned.zone) {
|
|
234
|
-
|
|
288
|
+
persistConfig('zone', assigned.zone);
|
|
235
289
|
selfConfig.zone = assigned.zone;
|
|
236
290
|
}
|
|
237
291
|
}
|
|
@@ -248,9 +302,7 @@ export async function buildResolutionContext(
|
|
|
248
302
|
// declared shape — e.g. `default: 2222` (YAML int) round-trips as
|
|
249
303
|
// `number` not the string "2222". This is the root of Defect 1.
|
|
250
304
|
if (variable.default !== undefined && !selfConfig[variable.name]) {
|
|
251
|
-
|
|
252
|
-
db,
|
|
253
|
-
moduleId,
|
|
305
|
+
persistConfig(
|
|
254
306
|
variable.name,
|
|
255
307
|
variable.default as string | number | boolean | unknown[] | Record<string, unknown>,
|
|
256
308
|
);
|
|
@@ -303,7 +355,7 @@ export async function buildResolutionContext(
|
|
|
303
355
|
for (const { manifestKey, configKey, systemValue } of resourceMappings) {
|
|
304
356
|
if (systemValue != null) {
|
|
305
357
|
// Canonical system size — always wins so a resize propagates.
|
|
306
|
-
|
|
358
|
+
persistConfig(configKey, systemValue);
|
|
307
359
|
selfConfig[configKey] = String(systemValue);
|
|
308
360
|
continue;
|
|
309
361
|
}
|
|
@@ -312,9 +364,7 @@ export async function buildResolutionContext(
|
|
|
312
364
|
// Pass them through unstringified so valueJson preserves the
|
|
313
365
|
// shape — see comment in the variable-defaults block above.
|
|
314
366
|
if (value !== undefined && !selfConfig[configKey]) {
|
|
315
|
-
|
|
316
|
-
db,
|
|
317
|
-
moduleId,
|
|
367
|
+
persistConfig(
|
|
318
368
|
configKey,
|
|
319
369
|
value as string | number | boolean | unknown[] | Record<string, unknown>,
|
|
320
370
|
);
|
|
@@ -333,7 +383,11 @@ export async function buildResolutionContext(
|
|
|
333
383
|
// outputs (resolveInfrastructureVariables), not here.
|
|
334
384
|
// This is the single place generate-time addresses are recorded — `target_ip`
|
|
335
385
|
// no longer lives in module_configs.
|
|
336
|
-
|
|
386
|
+
//
|
|
387
|
+
// Provisioning only. A read must never reach this: allocating an address is
|
|
388
|
+
// not something looking at a config should do, and by the time any hook runs
|
|
389
|
+
// the row is already there for `buildInfraSystemsMap` below to read.
|
|
390
|
+
if (provision && module?.manifestData) {
|
|
337
391
|
const manifest = module.manifestData as ModuleManifest;
|
|
338
392
|
const declared = getDeclaredSystems(manifest);
|
|
339
393
|
const hostname = selfConfig.hostname;
|
|
@@ -593,7 +647,7 @@ export async function buildResolutionContext(
|
|
|
593
647
|
|
|
594
648
|
// If zone from manifest but not in selfConfig, store it as first-class config
|
|
595
649
|
if (zone && !selfConfig.zone) {
|
|
596
|
-
|
|
650
|
+
persistConfig('zone', zone);
|
|
597
651
|
selfConfig.zone = zone;
|
|
598
652
|
}
|
|
599
653
|
|
|
@@ -625,7 +679,7 @@ export async function buildResolutionContext(
|
|
|
625
679
|
}
|
|
626
680
|
return value;
|
|
627
681
|
})();
|
|
628
|
-
|
|
682
|
+
persistConfig(field, coerced);
|
|
629
683
|
selfConfig[field] = String(coerced);
|
|
630
684
|
}
|
|
631
685
|
}
|
|
@@ -685,7 +739,7 @@ export async function buildResolutionContext(
|
|
|
685
739
|
}
|
|
686
740
|
|
|
687
741
|
if (isNew || isChanged) {
|
|
688
|
-
|
|
742
|
+
persistConfig(key, value);
|
|
689
743
|
selfConfig[key] = value;
|
|
690
744
|
}
|
|
691
745
|
}
|
|
@@ -52,6 +52,59 @@ describe('resolveDeclarativeDerivation', () => {
|
|
|
52
52
|
expect(result).toBe('10.0.10.0/24');
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
+
test('resolves a system key containing hyphens', () => {
|
|
56
|
+
// celilo's zone names are kebab-case, so shipped manifests contain
|
|
57
|
+
// `$system:network.control-plane-vpn.subnet` and
|
|
58
|
+
// `$system:network.secure-mgmt.subnet`. While the match stopped at the
|
|
59
|
+
// first hyphen these looked up `network.control` / `network.secure`,
|
|
60
|
+
// threw, and — being optional — resolved to nothing in silence. That is
|
|
61
|
+
// half of the 2026-08-14 DNS outage.
|
|
62
|
+
const variable: VariableDeclare = {
|
|
63
|
+
name: 'vpn_subnet',
|
|
64
|
+
type: 'string',
|
|
65
|
+
required: false,
|
|
66
|
+
source: 'system',
|
|
67
|
+
derive_from: '$system:network.control-plane-vpn.subnet',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const context: ResolutionContext = {
|
|
71
|
+
moduleId: 'technitium',
|
|
72
|
+
selfConfig: {},
|
|
73
|
+
systemConfig: { 'network.control-plane-vpn.subnet': '10.255.255.0/24' },
|
|
74
|
+
systemSecrets: {},
|
|
75
|
+
secrets: {},
|
|
76
|
+
capabilities: {},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
expect(resolveDeclarativeDerivation(variable, context)).toBe('10.255.255.0/24');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('names the whole hyphenated key when it is missing', () => {
|
|
83
|
+
// The message has to name the key the manifest asked for. Reporting
|
|
84
|
+
// `network.secure` for a manifest that says `network.secure-mgmt` sends
|
|
85
|
+
// the reader looking for a key that was never requested.
|
|
86
|
+
const variable: VariableDeclare = {
|
|
87
|
+
name: 'secure_mgmt_subnet',
|
|
88
|
+
type: 'string',
|
|
89
|
+
required: true,
|
|
90
|
+
source: 'system',
|
|
91
|
+
derive_from: '$system:network.secure-mgmt.subnet',
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const context: ResolutionContext = {
|
|
95
|
+
moduleId: 'technitium',
|
|
96
|
+
selfConfig: {},
|
|
97
|
+
systemConfig: {},
|
|
98
|
+
systemSecrets: {},
|
|
99
|
+
secrets: {},
|
|
100
|
+
capabilities: {},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
expect(() => resolveDeclarativeDerivation(variable, context)).toThrow(
|
|
104
|
+
"Missing system config: network.secure-mgmt.subnet (required by variable 'secure_mgmt_subnet')",
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
55
108
|
test('throws on missing system config', () => {
|
|
56
109
|
const variable: VariableDeclare = {
|
|
57
110
|
name: 'primary_domain',
|
|
@@ -56,8 +56,19 @@ function substituteVariables(
|
|
|
56
56
|
): string {
|
|
57
57
|
let result = input;
|
|
58
58
|
|
|
59
|
-
// Replace $system:key patterns (both $system:key and ${system:key} forms)
|
|
60
|
-
|
|
59
|
+
// Replace $system:key patterns (both $system:key and ${system:key} forms).
|
|
60
|
+
//
|
|
61
|
+
// The key may contain hyphens. celilo's own zone names are kebab-case, so
|
|
62
|
+
// `$system:network.control-plane-vpn.subnet` and
|
|
63
|
+
// `$system:network.secure-mgmt.subnet` are both real keys in shipped
|
|
64
|
+
// manifests — and neither could ever resolve while this class excluded `-`:
|
|
65
|
+
// the match stopped at the first hyphen, looked up `network.control`, and
|
|
66
|
+
// threw. For an optional variable that throw is swallowed, so the derive
|
|
67
|
+
// simply produced nothing, forever, in silence. That is half of the
|
|
68
|
+
// 2026-08-14 DNS outage (`technitium.vpn_subnet`); the other half is that
|
|
69
|
+
// nothing re-derived the value at read time — see
|
|
70
|
+
// `hooks/load-hook-config.ts`.
|
|
71
|
+
result = result.replace(/\$\{?system:([a-zA-Z0-9_.-]+)\}?/g, (_match, key) => {
|
|
61
72
|
const value = context.systemConfig[key];
|
|
62
73
|
if (value === undefined) {
|
|
63
74
|
throw new Error(`Missing system config: ${key} (required by variable '${variableName}')`);
|