@celilo/cli 0.26.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +3 -0
- package/CELILO_SUBSYSTEMS.md +4 -2
- package/drizzle/0025_port_forward_owner.sql +29 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +3 -3
- package/src/__integration__/container-services-cli.integration.test.ts +0 -4
- package/src/ansible/dependencies.test.ts +233 -289
- package/src/ansible/dependencies.ts +151 -83
- package/src/cli/commands/alerts-sweep.ts +14 -3
- package/src/cli/commands/machine-add.ts +0 -1
- package/src/cli/commands/machine-list.ts +10 -4
- package/src/cli/commands/machine-remove.ts +13 -7
- package/src/cli/commands/machine-status.ts +9 -11
- package/src/cli/commands/module-remove.ts +26 -23
- package/src/cli/commands/system-audit.ts +5 -1
- package/src/cli/commands/system-update.ts +10 -2
- package/src/db/schema.ts +30 -10
- package/src/hooks/capability-loader.ts +65 -13
- package/src/hooks/define-hook.test.ts +4 -6
- package/src/hooks/executor.ts +2 -1
- package/src/hooks/types.ts +9 -17
- package/src/infrastructure/property-extractor.test.ts +0 -2
- package/src/manifest/contracts/index.ts +20 -0
- package/src/manifest/contracts/v1.ts +33 -1
- package/src/manifest/schema.ts +48 -58
- package/src/services/alerting/sweep-runner.test.ts +5 -1
- package/src/services/alerting/sweep-runner.ts +14 -8
- package/src/services/aspect-runner.test.ts +0 -1
- package/src/services/audit/undeployed-modules.ts +18 -1
- package/src/services/consumer-cleanup.test.ts +347 -0
- package/src/services/consumer-cleanup.ts +244 -0
- package/src/services/infrastructure-selector.test.ts +0 -7
- package/src/services/infrastructure-selector.ts +24 -25
- package/src/services/infrastructure-variable-resolver.test.ts +0 -6
- package/src/services/infrastructure-variable-resolver.ts +0 -3
- package/src/services/machine-pool.test.ts +53 -85
- package/src/services/machine-pool.ts +68 -84
- package/src/services/module-deploy.ts +17 -39
- package/src/services/module-validator/index.test.ts +9 -0
- package/src/services/port-forwards.test.ts +93 -40
- package/src/services/port-forwards.ts +74 -48
- package/src/services/ssh-key-manager.test.ts +0 -10
- package/src/services/trusted-sources.test.ts +52 -13
- package/src/services/trusted-sources.ts +25 -15
- package/src/test-utils/cli-context.ts +15 -2
- package/src/types/infrastructure.ts +11 -1
- package/src/services/web-route-cleanup.test.ts +0 -250
- package/src/services/web-route-cleanup.ts +0 -144
|
@@ -113,6 +113,16 @@ export const CAPABILITY_MODULE_MAP: Record<string, { script: string; legacyFacto
|
|
|
113
113
|
script: 'scripts/control-plane-vpn-functions.ts',
|
|
114
114
|
legacyFactoryName: 'default',
|
|
115
115
|
},
|
|
116
|
+
// MODULE-provided, unlike its sibling public_web above, which the framework
|
|
117
|
+
// implements (createPublicWeb) against celilo's `web_routes` table. The
|
|
118
|
+
// asymmetry is deliberate: a private route stored in `web_routes` would be
|
|
119
|
+
// picked up by the PUBLIC caddy, which derives its served hostnames from
|
|
120
|
+
// every row of that table — so the provider keeps its own routes and celilo
|
|
121
|
+
// core holds no private-ingress business at all (celilo#846).
|
|
122
|
+
private_web: {
|
|
123
|
+
script: 'scripts/private-web-functions.ts',
|
|
124
|
+
legacyFactoryName: 'default',
|
|
125
|
+
},
|
|
116
126
|
};
|
|
117
127
|
|
|
118
128
|
/**
|
|
@@ -247,7 +257,7 @@ export async function loadCapabilityFunctions(
|
|
|
247
257
|
// buildFirewallChain handles the per-layer wrap with auto-logging
|
|
248
258
|
// internally, so we don't need to wrap the result here.
|
|
249
259
|
if (capName === 'firewall' && allProviders.length > 1) {
|
|
250
|
-
const
|
|
260
|
+
const { chain, self } = await buildFirewallChain(
|
|
251
261
|
allProviders,
|
|
252
262
|
moduleInfo,
|
|
253
263
|
masterKey,
|
|
@@ -256,8 +266,15 @@ export async function loadCapabilityFunctions(
|
|
|
256
266
|
debugLog,
|
|
257
267
|
consumingModuleId,
|
|
258
268
|
);
|
|
259
|
-
if (
|
|
260
|
-
result[capName] =
|
|
269
|
+
if (chain) {
|
|
270
|
+
result[capName] = chain;
|
|
271
|
+
}
|
|
272
|
+
// The chain hands a CONSUMER the innermost layer, which is not this
|
|
273
|
+
// provider's own layer when it sits further out. `on_consumer_removed`
|
|
274
|
+
// must converge THIS firewall, so it gets its own layer by name.
|
|
275
|
+
if (self) {
|
|
276
|
+
result.firewall_registry = self;
|
|
277
|
+
debugLog(`firewall_registry: provider view injected for ${consumingModuleId}`);
|
|
261
278
|
}
|
|
262
279
|
continue;
|
|
263
280
|
}
|
|
@@ -332,6 +349,11 @@ export async function loadCapabilityFunctions(
|
|
|
332
349
|
secrets: providerSecrets,
|
|
333
350
|
systems: getModuleSystems(capability.moduleId, db),
|
|
334
351
|
logger,
|
|
352
|
+
// WHO IS CALLING, so a provider can scope per-consumer state to them.
|
|
353
|
+
// `createPublicWeb` has always had this; compiled factories did not,
|
|
354
|
+
// which made a module-provided capability structurally unable to
|
|
355
|
+
// offer `unregisterRoutes()`-style methods.
|
|
356
|
+
consumerModuleId: consumingModuleId,
|
|
335
357
|
});
|
|
336
358
|
// Stamp here too, not only on the legacy path: a consumer that cannot
|
|
337
359
|
// get what it needs must be able to name WHICH provider could not give
|
|
@@ -354,7 +376,7 @@ export async function loadCapabilityFunctions(
|
|
|
354
376
|
providerSecrets,
|
|
355
377
|
// Single firewall provider (no upstream chain) still needs the injected
|
|
356
378
|
// port-forward store — the chain path isn't taken when there's one provider.
|
|
357
|
-
capName === 'firewall' ? buildPortForwardStore(db) : undefined,
|
|
379
|
+
capName === 'firewall' ? buildPortForwardStore(db, consumingModuleId) : undefined,
|
|
358
380
|
capName === 'firewall' ? loadFirewallZones(db) : undefined,
|
|
359
381
|
// Bound to the CONSUMING module so a trusted-source registration is
|
|
360
382
|
// attributable — reach into every tier must never be anonymous.
|
|
@@ -367,6 +389,12 @@ export async function loadCapabilityFunctions(
|
|
|
367
389
|
wrapWithLogging(capabilityInterface as object, logger, capName),
|
|
368
390
|
);
|
|
369
391
|
debugLog(`${capName}: loaded via legacy factory`);
|
|
392
|
+
// Sole firewall provider running its OWN hook: the interface just built
|
|
393
|
+
// IS its layer, so hand it back under the provider-view name too.
|
|
394
|
+
if (capName === 'firewall' && consumingModuleId === capability.moduleId) {
|
|
395
|
+
result.firewall_registry = result[capName];
|
|
396
|
+
debugLog(`firewall_registry: provider view injected for ${consumingModuleId}`);
|
|
397
|
+
}
|
|
370
398
|
}
|
|
371
399
|
} catch (error) {
|
|
372
400
|
debugLog(
|
|
@@ -972,6 +1000,20 @@ function parseInterfaceBaseline(raw: unknown): string[] | undefined {
|
|
|
972
1000
|
return names.length > 0 ? names : undefined;
|
|
973
1001
|
}
|
|
974
1002
|
|
|
1003
|
+
/**
|
|
1004
|
+
* What a firewall build hands back.
|
|
1005
|
+
*
|
|
1006
|
+
* `chain` is the layer a CONSUMER talks to (the innermost). `self` is the layer
|
|
1007
|
+
* belonging to the module currently running a hook, and is null unless that
|
|
1008
|
+
* module is itself one of the providers — it is what `on_consumer_removed`
|
|
1009
|
+
* converges, and it differs from `chain` whenever the provider sits further out
|
|
1010
|
+
* than the innermost layer.
|
|
1011
|
+
*/
|
|
1012
|
+
interface FirewallChain {
|
|
1013
|
+
chain: unknown;
|
|
1014
|
+
self: unknown | null;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
975
1017
|
async function buildFirewallChain(
|
|
976
1018
|
allProviders: Array<{
|
|
977
1019
|
id: number;
|
|
@@ -986,11 +1028,12 @@ async function buildFirewallChain(
|
|
|
986
1028
|
logger: HookLogger,
|
|
987
1029
|
debugLog: (msg: string) => void,
|
|
988
1030
|
consumingModuleId: string,
|
|
989
|
-
): Promise<
|
|
1031
|
+
): Promise<FirewallChain> {
|
|
990
1032
|
// The shared-core port-forward registry, injected into every firewall provider
|
|
991
1033
|
// in the chain so exposeService/converge reconcile against the one canonical
|
|
992
|
-
// store (openspec/changes/unified-management-no-ssh/proposal.md).
|
|
993
|
-
|
|
1034
|
+
// store (openspec/changes/unified-management-no-ssh/proposal.md). Bound to the
|
|
1035
|
+
// CONSUMING module so every forward it declares is attributable (D2).
|
|
1036
|
+
const store = buildPortForwardStore(db, consumingModuleId);
|
|
994
1037
|
// Trusted-source registry, bound to the CONSUMING module so a registration is
|
|
995
1038
|
// attributable. Only the layers that render their own ruleset receive it.
|
|
996
1039
|
const trustedSourceStore = buildTrustedSourceStore(db, consumingModuleId);
|
|
@@ -1006,17 +1049,17 @@ async function buildFirewallChain(
|
|
|
1006
1049
|
|
|
1007
1050
|
if (!hasExternal) {
|
|
1008
1051
|
debugLog('firewall chain: no provider with external interface found');
|
|
1009
|
-
return null;
|
|
1052
|
+
return { chain: null, self: null };
|
|
1010
1053
|
}
|
|
1011
1054
|
|
|
1012
1055
|
// Build the leaf (external) firewall first
|
|
1013
1056
|
const leafModule = db.select().from(modules).where(eq(modules.id, hasExternal.moduleId)).get();
|
|
1014
|
-
if (!leafModule) return null;
|
|
1057
|
+
if (!leafModule) return { chain: null, self: null };
|
|
1015
1058
|
|
|
1016
1059
|
const leafModulePath = join(leafModule.sourcePath, moduleInfo.script);
|
|
1017
1060
|
if (!existsSync(leafModulePath)) {
|
|
1018
1061
|
debugLog(`firewall chain: leaf module not found at ${leafModulePath}`);
|
|
1019
|
-
return null;
|
|
1062
|
+
return { chain: null, self: null };
|
|
1020
1063
|
}
|
|
1021
1064
|
|
|
1022
1065
|
const leafConfig = await loadModuleConfig(hasExternal.moduleId, db);
|
|
@@ -1025,7 +1068,7 @@ async function buildFirewallChain(
|
|
|
1025
1068
|
const leafExported =
|
|
1026
1069
|
typeof leafMod.default === 'function' ? leafMod.default : leafMod[moduleInfo.legacyFactoryName];
|
|
1027
1070
|
|
|
1028
|
-
if (typeof leafExported !== 'function') return null;
|
|
1071
|
+
if (typeof leafExported !== 'function') return { chain: null, self: null };
|
|
1029
1072
|
|
|
1030
1073
|
// Branded compiled factory (Phase 8 path): call with the canonical
|
|
1031
1074
|
// context. wrapWithLogging is applied internally so the leaf interface
|
|
@@ -1037,6 +1080,7 @@ async function buildFirewallChain(
|
|
|
1037
1080
|
secrets: leafSecrets,
|
|
1038
1081
|
systems: getModuleSystems(hasExternal.moduleId, db),
|
|
1039
1082
|
logger,
|
|
1083
|
+
consumerModuleId: consumingModuleId,
|
|
1040
1084
|
});
|
|
1041
1085
|
leafFirewall = stampProvider(leafFirewall, hasExternal.moduleId);
|
|
1042
1086
|
debugLog(
|
|
@@ -1065,8 +1109,15 @@ async function buildFirewallChain(
|
|
|
1065
1109
|
// doesn't fit defineCapabilityFunction's single-context shape.
|
|
1066
1110
|
const downstream = allProviders.filter((p) => p.moduleId !== hasExternal.moduleId);
|
|
1067
1111
|
|
|
1112
|
+
// Each layer as its OWN provider sees it, so `on_consumer_removed` converges
|
|
1113
|
+
// the firewall that declares the hook rather than whichever layer a consumer
|
|
1114
|
+
// happens to talk to.
|
|
1115
|
+
const selfLayer = (id: string, iface: unknown): unknown | null =>
|
|
1116
|
+
id === consumingModuleId ? iface : null;
|
|
1117
|
+
let self = selfLayer(hasExternal.moduleId, leafFirewall);
|
|
1118
|
+
|
|
1068
1119
|
if (downstream.length === 0) {
|
|
1069
|
-
return leafFirewall;
|
|
1120
|
+
return { chain: leafFirewall, self };
|
|
1070
1121
|
}
|
|
1071
1122
|
|
|
1072
1123
|
let currentUpstream = leafFirewall;
|
|
@@ -1141,9 +1192,10 @@ async function buildFirewallChain(
|
|
|
1141
1192
|
wrapWithLogging(downstreamFirewall as object, logger, 'firewall'),
|
|
1142
1193
|
provider.moduleId,
|
|
1143
1194
|
);
|
|
1195
|
+
self = self ?? selfLayer(provider.moduleId, currentUpstream);
|
|
1144
1196
|
}
|
|
1145
1197
|
|
|
1146
|
-
return currentUpstream;
|
|
1198
|
+
return { chain: currentUpstream, self };
|
|
1147
1199
|
}
|
|
1148
1200
|
|
|
1149
1201
|
/**
|
|
@@ -46,6 +46,7 @@ function makeContext(overrides: Partial<HookContext> = {}): HookContext {
|
|
|
46
46
|
secrets: {},
|
|
47
47
|
systems: [],
|
|
48
48
|
logger: makeLogger(),
|
|
49
|
+
consumerModuleId: 'test-consumer',
|
|
49
50
|
debug: false,
|
|
50
51
|
screenshotDir: '',
|
|
51
52
|
capabilities: {},
|
|
@@ -69,9 +70,6 @@ const fakePublicWeb: PublicWebCapability = {
|
|
|
69
70
|
async upload_static_assets() {
|
|
70
71
|
return { success: true, filesUploaded: 0, contentHash: 'fake' };
|
|
71
72
|
},
|
|
72
|
-
async unregister_routes() {
|
|
73
|
-
return undefined;
|
|
74
|
-
},
|
|
75
73
|
async getServerIp() {
|
|
76
74
|
return '10.0.10.10';
|
|
77
75
|
},
|
|
@@ -297,9 +295,6 @@ describe('defineCapabilityFunction', () => {
|
|
|
297
295
|
async upload_static_assets() {
|
|
298
296
|
return { success: true, filesUploaded: 0, contentHash: 'x' };
|
|
299
297
|
},
|
|
300
|
-
async unregister_routes() {
|
|
301
|
-
return undefined;
|
|
302
|
-
},
|
|
303
298
|
async getServerIp() {
|
|
304
299
|
return '10.0.10.10';
|
|
305
300
|
},
|
|
@@ -350,6 +345,7 @@ describe('defineCapabilityFunction', () => {
|
|
|
350
345
|
secrets: { token: 'abc' },
|
|
351
346
|
systems: [],
|
|
352
347
|
logger: makeLogger(),
|
|
348
|
+
consumerModuleId: 'test-consumer',
|
|
353
349
|
});
|
|
354
350
|
|
|
355
351
|
expect(typeof methods.create_oidc_client).toBe('function');
|
|
@@ -376,6 +372,7 @@ describe('defineCapabilityFunction', () => {
|
|
|
376
372
|
secrets: {},
|
|
377
373
|
systems: [],
|
|
378
374
|
logger: makeLogger(),
|
|
375
|
+
consumerModuleId: 'test-consumer',
|
|
379
376
|
});
|
|
380
377
|
|
|
381
378
|
const result = await methods.registerHost({ fqdn: 'www.example.com' });
|
|
@@ -404,6 +401,7 @@ describe('defineCapabilityFunction', () => {
|
|
|
404
401
|
secrets: {},
|
|
405
402
|
systems: [],
|
|
406
403
|
logger: makeLogger(),
|
|
404
|
+
consumerModuleId: 'test-consumer',
|
|
407
405
|
});
|
|
408
406
|
|
|
409
407
|
const result = await methods.exposeService({
|
package/src/hooks/executor.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from '@celilo/capabilities';
|
|
29
29
|
import {
|
|
30
30
|
type ContractHookSignature,
|
|
31
|
+
contractHookSignature,
|
|
31
32
|
resolveContract,
|
|
32
33
|
supportedContractVersions,
|
|
33
34
|
} from '../manifest/contracts';
|
|
@@ -435,7 +436,7 @@ export async function invokeHook(
|
|
|
435
436
|
};
|
|
436
437
|
}
|
|
437
438
|
|
|
438
|
-
const signature = contract.hooks
|
|
439
|
+
const signature = contractHookSignature(contract.hooks, hookName);
|
|
439
440
|
if (!signature) {
|
|
440
441
|
return {
|
|
441
442
|
success: false,
|
package/src/hooks/types.ts
CHANGED
|
@@ -104,25 +104,17 @@ export interface HookResult {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
/**
|
|
107
|
-
* Supported lifecycle hook names
|
|
107
|
+
* Supported lifecycle hook names — re-exported from `@celilo/capabilities`,
|
|
108
|
+
* which owns the one list (celilo#821).
|
|
108
109
|
*
|
|
109
|
-
*
|
|
110
|
-
* `
|
|
111
|
-
*
|
|
110
|
+
* This was a second hand-maintained copy, and it had already drifted: it was
|
|
111
|
+
* missing `reconcile_routes`, so caddy's public_web reconcile hook was a hook
|
|
112
|
+
* the celilo side of the codebase did not believe in.
|
|
112
113
|
*/
|
|
113
|
-
export type HookName
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
| 'health_check'
|
|
118
|
-
| 'validate_config'
|
|
119
|
-
| 'on_backup'
|
|
120
|
-
| 'on_backup_analyze'
|
|
121
|
-
| 'on_restore'
|
|
122
|
-
| 'on_system_event'
|
|
123
|
-
| 'refresh_registrations'
|
|
124
|
-
| 'reassert_dhcp_dns'
|
|
125
|
-
| 'reconcile_clients';
|
|
114
|
+
export type { HookName } from '@celilo/capabilities';
|
|
115
|
+
export { HOOK_NAMES } from '@celilo/capabilities';
|
|
116
|
+
|
|
117
|
+
import type { HookName } from '@celilo/capabilities';
|
|
126
118
|
|
|
127
119
|
/**
|
|
128
120
|
* Hook manifest section - maps hook names to definitions
|
|
@@ -26,7 +26,6 @@ describe('extractMachineProperties', () => {
|
|
|
26
26
|
zone: 'external',
|
|
27
27
|
role: 'host',
|
|
28
28
|
interfaces: [],
|
|
29
|
-
assignedModuleIds: [],
|
|
30
29
|
createdAt: new Date(),
|
|
31
30
|
updatedAt: new Date(),
|
|
32
31
|
};
|
|
@@ -51,7 +50,6 @@ describe('extractMachineProperties', () => {
|
|
|
51
50
|
zone: 'internal',
|
|
52
51
|
role: 'host',
|
|
53
52
|
interfaces: [],
|
|
54
|
-
assignedModuleIds: [],
|
|
55
53
|
createdAt: new Date(),
|
|
56
54
|
updatedAt: new Date(),
|
|
57
55
|
};
|
|
@@ -15,6 +15,26 @@ import type { ContractHookSignature, ContractHooks } from './v1';
|
|
|
15
15
|
|
|
16
16
|
export type { ContractHooks, ContractHookSignature };
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Look up a hook signature by an UNTRUSTED name.
|
|
20
|
+
*
|
|
21
|
+
* `ContractHooks` is keyed by `HookName` (celilo#821), which is what makes a
|
|
22
|
+
* missing or unknown entry a compile error. The executor's `hookName` is a
|
|
23
|
+
* plain `string` on purpose — it comes from a module manifest, and deciding
|
|
24
|
+
* whether it names a real hook is precisely what the caller is asking. This
|
|
25
|
+
* helper is the one place that crossing is expressed, so every other use of
|
|
26
|
+
* `ContractHooks` stays exact.
|
|
27
|
+
*
|
|
28
|
+
* Returns `undefined` for a name the contract does not define; the caller
|
|
29
|
+
* turns that into "Hook 'x' is not part of celilo_contract 1.0".
|
|
30
|
+
*/
|
|
31
|
+
export function contractHookSignature(
|
|
32
|
+
hooks: ContractHooks,
|
|
33
|
+
name: string,
|
|
34
|
+
): ContractHookSignature | undefined {
|
|
35
|
+
return (hooks as Record<string, ContractHookSignature | undefined>)[name];
|
|
36
|
+
}
|
|
37
|
+
|
|
18
38
|
/**
|
|
19
39
|
* The shape of a registered contract.
|
|
20
40
|
*/
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* change and requires a v2.0 contract.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
import type { HookName } from '@celilo/capabilities';
|
|
26
|
+
|
|
25
27
|
/**
|
|
26
28
|
* Per-input/output metadata.
|
|
27
29
|
*
|
|
@@ -47,8 +49,21 @@ export interface ContractHookSignature {
|
|
|
47
49
|
* Full contract for a version: a map of canonical hook name → signature.
|
|
48
50
|
* Only hook names listed here are valid; declaring a hook not in this map is
|
|
49
51
|
* a manifest validation error.
|
|
52
|
+
*
|
|
53
|
+
* Keyed by `HookName` rather than `string` (celilo#821). That single change is
|
|
54
|
+
* what turns this table from a fourth hand-maintained list into a derivation:
|
|
55
|
+
* a name added to `HOOK_NAMES` without an entry here is a type error, and an
|
|
56
|
+
* entry here for a name that is not a hook is one too. It was `Record<string,
|
|
57
|
+
* …>`, which is why the drift went unnoticed for months.
|
|
58
|
+
*
|
|
59
|
+
* `on_upstream_publish` is named explicitly because it is NOT a `HookName` — an
|
|
60
|
+
* array of build-bus match rules rather than an invokable hook. It has a
|
|
61
|
+
* signature here because the executor passes its payload as env vars and the
|
|
62
|
+
* contract is where that is written down.
|
|
50
63
|
*/
|
|
51
|
-
export type ContractHooks = Record<
|
|
64
|
+
export type ContractHooks = Record<HookName, ContractHookSignature> & {
|
|
65
|
+
on_upstream_publish: ContractHookSignature;
|
|
66
|
+
};
|
|
52
67
|
|
|
53
68
|
/**
|
|
54
69
|
* Contract v1.0 — current canonical hook signatures.
|
|
@@ -70,6 +85,23 @@ export const V1_HOOKS: ContractHooks = {
|
|
|
70
85
|
inputs: {},
|
|
71
86
|
outputs: {},
|
|
72
87
|
},
|
|
88
|
+
/**
|
|
89
|
+
* A module that CONSUMED one of this module's capabilities is being removed.
|
|
90
|
+
* The provider withdraws whatever it minted on that consumer's behalf
|
|
91
|
+
* (openspec/changes/consumer-removal-cleanup, D1).
|
|
92
|
+
*
|
|
93
|
+
* `consumer` is the only input and there are no outputs. Deliberately NOT
|
|
94
|
+
* accompanied by the list of capabilities the consumer used: a provider that
|
|
95
|
+
* cannot answer "what do I hold for this module" without being told has a
|
|
96
|
+
* different defect — the consumer's id was never recorded at the point of the
|
|
97
|
+
* call — and telling it at removal time only papers over that.
|
|
98
|
+
*/
|
|
99
|
+
on_consumer_removed: {
|
|
100
|
+
inputs: {
|
|
101
|
+
consumer: { required: true },
|
|
102
|
+
},
|
|
103
|
+
outputs: {},
|
|
104
|
+
},
|
|
73
105
|
health_check: {
|
|
74
106
|
inputs: {},
|
|
75
107
|
outputs: {},
|
package/src/manifest/schema.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { HookName } from '@celilo/capabilities';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import { NETWORK_ZONES } from '../db/schema';
|
|
3
4
|
import {
|
|
@@ -332,6 +333,52 @@ export const UpstreamPublishHookSchema = z.object({
|
|
|
332
333
|
timeout: z.number().positive().optional(),
|
|
333
334
|
});
|
|
334
335
|
|
|
336
|
+
/**
|
|
337
|
+
* The manifest schema for every invokable lifecycle hook, derived from the one
|
|
338
|
+
* list (celilo#821).
|
|
339
|
+
*
|
|
340
|
+
* `satisfies Record<HookName, …>` is doing the enforcement, in both directions:
|
|
341
|
+
* a name in `HOOK_NAMES` with no entry here is a type error, and an entry here
|
|
342
|
+
* that is not a `HookName` is a type error too. That is why this is a literal
|
|
343
|
+
* rather than something built with `Object.fromEntries` — a computed object
|
|
344
|
+
* would widen the keys to `string` and take `ModuleManifest['hooks']` down with
|
|
345
|
+
* it, trading one silent drift for another.
|
|
346
|
+
*
|
|
347
|
+
* Per-hook prose lives in `contracts/v1.ts`, which documents the same names.
|
|
348
|
+
* Two copies of that commentary is how they came to disagree.
|
|
349
|
+
*/
|
|
350
|
+
const LIFECYCLE_HOOK_SCHEMAS = {
|
|
351
|
+
container_created: LifecycleHookSchema.optional(),
|
|
352
|
+
on_install: LifecycleHookSchema.optional(),
|
|
353
|
+
on_uninstall: LifecycleHookSchema.optional(),
|
|
354
|
+
on_consumer_removed: LifecycleHookSchema.optional(),
|
|
355
|
+
health_check: HealthCheckHookSchema.optional().describe(
|
|
356
|
+
"Health check hook. `interval` is the module's SUGGESTED monitoring cadence; the operator's monitor row is the effective schedule and always wins.",
|
|
357
|
+
),
|
|
358
|
+
validate_config: LifecycleHookSchema.optional(),
|
|
359
|
+
on_backup: LifecycleHookSchema.optional(),
|
|
360
|
+
on_backup_analyze: LifecycleHookSchema.optional(),
|
|
361
|
+
on_restore: LifecycleHookSchema.optional(),
|
|
362
|
+
on_system_event: LifecycleHookSchema.optional(),
|
|
363
|
+
reconcile_routes: LifecycleHookSchema.optional(),
|
|
364
|
+
refresh_registrations: LifecycleHookSchema.optional(),
|
|
365
|
+
reassert_dhcp_dns: LifecycleHookSchema.optional(),
|
|
366
|
+
reconcile_clients: LifecycleHookSchema.optional(),
|
|
367
|
+
} satisfies Record<HookName, z.ZodTypeAny>;
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* What `manifest.hooks` accepts: every lifecycle hook, plus
|
|
371
|
+
* `on_upstream_publish`, which is deliberately not a `HookName` — an ARRAY of
|
|
372
|
+
* build-bus match rules dispatched by the receiver daemon rather than a hook
|
|
373
|
+
* `runNamedHook` can invoke. It is spread in here rather than living in
|
|
374
|
+
* `LIFECYCLE_HOOK_SCHEMAS` so the `satisfies` above stays exact.
|
|
375
|
+
* See [[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 4.
|
|
376
|
+
*/
|
|
377
|
+
const HOOK_SCHEMAS = {
|
|
378
|
+
...LIFECYCLE_HOOK_SCHEMAS,
|
|
379
|
+
on_upstream_publish: z.array(UpstreamPublishHookSchema).optional(),
|
|
380
|
+
};
|
|
381
|
+
|
|
335
382
|
/**
|
|
336
383
|
* Machine resource recommendations
|
|
337
384
|
* Module declares recommended machine resources (CPU, memory, disk, storage)
|
|
@@ -626,64 +673,7 @@ export const ModuleManifestSchema = z
|
|
|
626
673
|
})
|
|
627
674
|
.optional(),
|
|
628
675
|
|
|
629
|
-
hooks: z
|
|
630
|
-
.object({
|
|
631
|
-
container_created: LifecycleHookSchema.optional(),
|
|
632
|
-
on_install: LifecycleHookSchema.optional(),
|
|
633
|
-
on_uninstall: LifecycleHookSchema.optional(),
|
|
634
|
-
health_check: HealthCheckHookSchema.optional().describe(
|
|
635
|
-
"Health check hook. `interval` is the module's SUGGESTED monitoring cadence; the operator's monitor row is the effective schedule and always wins.",
|
|
636
|
-
),
|
|
637
|
-
validate_config: LifecycleHookSchema.optional(),
|
|
638
|
-
on_backup: LifecycleHookSchema.optional(),
|
|
639
|
-
on_backup_analyze: LifecycleHookSchema.optional(),
|
|
640
|
-
on_restore: LifecycleHookSchema.optional(),
|
|
641
|
-
/**
|
|
642
|
-
* Per-system lifecycle hook. A dns_internal provider declares this
|
|
643
|
-
* to (de)register a single host's A records when celilo's bridge
|
|
644
|
-
* delivers a system.created/destroyed event. Inputs (hostname,
|
|
645
|
-
* target_ip, op) come from the contract — see contracts/v1.ts and
|
|
646
|
-
* [[openspec/specs/internal-dns-split-horizon/spec.md]] D5.
|
|
647
|
-
*/
|
|
648
|
-
on_system_event: LifecycleHookSchema.optional(),
|
|
649
|
-
/**
|
|
650
|
-
* Reconcile the provider's running config from a celilo registry on a
|
|
651
|
-
* change event. The caddy `public_web` provider declares this to
|
|
652
|
-
* re-render its Caddyfile from web_routes when a consumer registers or
|
|
653
|
-
* unregisters a route (ISS-0035). See
|
|
654
|
-
* [[openspec/specs/public-web-provider-reconcile/spec.md]].
|
|
655
|
-
*/
|
|
656
|
-
reconcile_routes: LifecycleHookSchema.optional(),
|
|
657
|
-
/**
|
|
658
|
-
* Periodic re-assertion of a dns_registrar provider's registered
|
|
659
|
-
* records. The framework injects the provider's dns_registrations
|
|
660
|
-
* ledger rows as the `registrations` input; the hook re-sends each
|
|
661
|
-
* one to the underlying DNS API and fails loudly if any cannot be
|
|
662
|
-
* re-asserted. Providers subscribe it to a `timer.tick.*` event.
|
|
663
|
-
* Part of the dns_registrar capability contract — see
|
|
664
|
-
* designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md (B3).
|
|
665
|
-
*/
|
|
666
|
-
refresh_registrations: LifecycleHookSchema.optional(),
|
|
667
|
-
/**
|
|
668
|
-
* Periodic re-assertion of the resolver a dns_internal provider
|
|
669
|
-
* hands out over DHCP. Some routers regenerate that value from
|
|
670
|
-
* their own upstream list on a timer, silently undoing what
|
|
671
|
-
* on_install set. The hook reads the device before writing, so a
|
|
672
|
-
* quiet minute costs one query. Subscribe it to `timer.tick.1m` —
|
|
673
|
-
* the tick interval IS the worst-case window in which a renewing
|
|
674
|
-
* client can be handed the wrong resolver. See celilo#739.
|
|
675
|
-
*/
|
|
676
|
-
reassert_dhcp_dns: LifecycleHookSchema.optional(),
|
|
677
|
-
reconcile_clients: LifecycleHookSchema.optional(),
|
|
678
|
-
/**
|
|
679
|
-
* Build-bus upstream publish hooks. Array (a module can react
|
|
680
|
-
* to multiple upstream packages with different actions). See
|
|
681
|
-
* [[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 4.
|
|
682
|
-
*/
|
|
683
|
-
on_upstream_publish: z.array(UpstreamPublishHookSchema).optional(),
|
|
684
|
-
})
|
|
685
|
-
.strict()
|
|
686
|
-
.optional(),
|
|
676
|
+
hooks: z.object(HOOK_SCHEMAS).strict().optional(),
|
|
687
677
|
|
|
688
678
|
build: z
|
|
689
679
|
.object({
|
|
@@ -329,7 +329,11 @@ describe('runSweep', () => {
|
|
|
329
329
|
|
|
330
330
|
expect(report.notified).toBe(0);
|
|
331
331
|
expect(report.noPolicy).toEqual([]);
|
|
332
|
-
|
|
332
|
+
// The alert is named, not just counted — an operator asking "why was I
|
|
333
|
+
// not paged" is asking about a specific alert (#450).
|
|
334
|
+
expect(report.skipped).toEqual([
|
|
335
|
+
{ alertKey: 'module:homebridge/check:port', reason: 'within_grace' },
|
|
336
|
+
]);
|
|
333
337
|
});
|
|
334
338
|
|
|
335
339
|
test('a transport that cannot be loaded records the error, not just a count', async () => {
|
|
@@ -78,14 +78,20 @@ export interface SweepReport {
|
|
|
78
78
|
*/
|
|
79
79
|
noPolicy: { alertKey: string; monitor: string }[];
|
|
80
80
|
/**
|
|
81
|
-
* Deliveries escalation declined
|
|
82
|
-
* `no_eligible_route`, …).
|
|
81
|
+
* Deliveries escalation declined — the reason AND the alert it applies to.
|
|
83
82
|
*
|
|
84
|
-
* `notifyAlert` returns the reason precisely so the caller can record it
|
|
85
|
-
* own contract says a silent skip is indistinguishable from a bug
|
|
86
|
-
* here is what made a firing-but-undelivered alert undebuggable
|
|
83
|
+
* `notifyAlert` returns the reason precisely so the caller can record it: its
|
|
84
|
+
* own contract says a silent skip is indistinguishable from a bug, and
|
|
85
|
+
* dropping it here is what made a firing-but-undelivered alert undebuggable
|
|
86
|
+
* (#450).
|
|
87
|
+
*
|
|
88
|
+
* The alert key is carried too, because a bare `within_grace×2` still does not
|
|
89
|
+
* answer "why was I not paged" for the alert the operator is actually looking
|
|
90
|
+
* at — they cannot tell which of their live alerts each count refers to. Same
|
|
91
|
+
* reasoning `noPolicy` already applies, and the same failure it was fixing.
|
|
92
|
+
* Counts are derived at render time so there is one source for both.
|
|
87
93
|
*/
|
|
88
|
-
skipped:
|
|
94
|
+
skipped: { alertKey: string; reason: string }[];
|
|
89
95
|
/**
|
|
90
96
|
* Why each failed delivery failed, as `<alert key>: <error>`.
|
|
91
97
|
*
|
|
@@ -121,7 +127,7 @@ export async function runSweep(
|
|
|
121
127
|
deferredDelivered: 0,
|
|
122
128
|
failed: 0,
|
|
123
129
|
noPolicy: [],
|
|
124
|
-
skipped:
|
|
130
|
+
skipped: [],
|
|
125
131
|
failures: [],
|
|
126
132
|
};
|
|
127
133
|
|
|
@@ -282,7 +288,7 @@ export async function runSweep(
|
|
|
282
288
|
report.failed++;
|
|
283
289
|
report.failures.push(`${alert.key}: ${outcome.error}`);
|
|
284
290
|
} else if (outcome.result === 'skipped') {
|
|
285
|
-
report.skipped
|
|
291
|
+
report.skipped.push({ alertKey: alert.key, reason: outcome.reason });
|
|
286
292
|
}
|
|
287
293
|
}
|
|
288
294
|
|
|
@@ -19,6 +19,17 @@ export interface UndeployedModule {
|
|
|
19
19
|
id: string;
|
|
20
20
|
/** Lifecycle state from the modules table (IMPORTED, VALIDATED, …). */
|
|
21
21
|
state: string;
|
|
22
|
+
/**
|
|
23
|
+
* Why it is in ERROR, verbatim from the modules table.
|
|
24
|
+
*
|
|
25
|
+
* Surfaced because the headline used to be the hardcoded "previous deploy
|
|
26
|
+
* failed", and that became a FALSE statement once a provider could be marked
|
|
27
|
+
* ERROR for failing to withdraw a removed consumer's state
|
|
28
|
+
* (openspec/changes/consumer-removal-cleanup, D6) — no deploy was involved,
|
|
29
|
+
* and the operator reading the finding has no other way to learn which
|
|
30
|
+
* consumer it was.
|
|
31
|
+
*/
|
|
32
|
+
errorMessage?: string | null;
|
|
22
33
|
}
|
|
23
34
|
|
|
24
35
|
export interface UndeployedModulesAuditDeps {
|
|
@@ -40,11 +51,17 @@ export async function auditUndeployedModules(
|
|
|
40
51
|
if (TRANSIENT_STATES.has(m.state)) continue;
|
|
41
52
|
|
|
42
53
|
if (m.state === 'ERROR') {
|
|
54
|
+
const reason = m.errorMessage?.trim();
|
|
43
55
|
findings.push({
|
|
44
56
|
category: 'undeployed_modules',
|
|
45
57
|
severity: 'blocked',
|
|
46
58
|
code: 'module_in_error_state',
|
|
47
|
-
|
|
59
|
+
// The recorded reason when there is one. A module reaches ERROR from
|
|
60
|
+
// more than one place now, and naming the wrong cause sends the
|
|
61
|
+
// operator to the wrong evidence.
|
|
62
|
+
message: reason
|
|
63
|
+
? `${m.id}: ${reason} (state: ERROR)`
|
|
64
|
+
: `${m.id}: previous deploy failed (state: ERROR)`,
|
|
48
65
|
details:
|
|
49
66
|
'Investigate the prior failure (check `celilo module status` and the' +
|
|
50
67
|
' module logs) before retrying — re-deploying without diagnosing the' +
|