@celilo/cli 0.13.3 → 0.14.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 +3 -0
- package/CELILO_SUBSYSTEMS.md +71 -2
- package/docs/ALERTING.md +298 -0
- package/docs/INDEX.md +103 -0
- package/drizzle/0016_trusted_sources.sql +10 -0
- package/drizzle/0017_alerting.sql +127 -0
- package/drizzle/meta/_journal.json +15 -1
- package/package.json +3 -2
- package/schemas/system_config.json +9 -0
- package/src/capabilities/well-known.ts +11 -1
- package/src/cli/commands/alerts-act.ts +107 -0
- package/src/cli/commands/alerts-list.ts +62 -0
- package/src/cli/commands/alerts-poll.ts +129 -0
- package/src/cli/commands/alerts-sweep.ts +156 -0
- package/src/cli/commands/module-list.ts +50 -3
- package/src/cli/commands/module-show.ts +11 -3
- package/src/cli/commands/monitor.ts +178 -0
- package/src/cli/commands/notify-config.ts +453 -0
- package/src/cli/commands/system-audit.ts +2 -0
- package/src/cli/commands/system-update.ts +1 -0
- package/src/cli/completion.ts +26 -0
- package/src/cli/generate-zsh-completion.ts +2 -0
- package/src/cli/index.ts +58 -0
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/db/schema.ts +371 -2
- package/src/hooks/capability-loader.ts +158 -46
- package/src/hooks/capability-map-coverage.test.ts +101 -0
- package/src/manifest/schema.ts +77 -4
- package/src/services/alerting/ack.test.ts +212 -0
- package/src/services/alerting/ack.ts +119 -0
- package/src/services/alerting/builtin-monitors.test.ts +132 -0
- package/src/services/alerting/builtin-monitors.ts +84 -0
- package/src/services/alerting/builtin-source.ts +82 -0
- package/src/services/alerting/coverage-source.ts +38 -0
- package/src/services/alerting/deferral.test.ts +161 -0
- package/src/services/alerting/delivery-loop.test.ts +396 -0
- package/src/services/alerting/deploy-hooks.test.ts +125 -0
- package/src/services/alerting/deploy-hooks.ts +111 -0
- package/src/services/alerting/escalation.test.ts +207 -0
- package/src/services/alerting/escalation.ts +151 -0
- package/src/services/alerting/format.test.ts +193 -0
- package/src/services/alerting/format.ts +150 -0
- package/src/services/alerting/health-coverage.ts +81 -0
- package/src/services/alerting/inbound-poller.test.ts +298 -0
- package/src/services/alerting/inbound-poller.ts +236 -0
- package/src/services/alerting/inbound.test.ts +201 -0
- package/src/services/alerting/inbound.ts +112 -0
- package/src/services/alerting/interview-responder.test.ts +169 -0
- package/src/services/alerting/interview-responder.ts +158 -0
- package/src/services/alerting/keys.test.ts +155 -0
- package/src/services/alerting/keys.ts +190 -0
- package/src/services/alerting/monitors.ts +185 -0
- package/src/services/alerting/notification-responder.test.ts +290 -0
- package/src/services/alerting/notification-responder.ts +260 -0
- package/src/services/alerting/notifier.ts +219 -0
- package/src/services/alerting/people.ts +178 -0
- package/src/services/alerting/quiet-hours.test.ts +140 -0
- package/src/services/alerting/quiet-hours.ts +99 -0
- package/src/services/alerting/reconcile.test.ts +190 -0
- package/src/services/alerting/reconcile.ts +166 -0
- package/src/services/alerting/run-monitor.test.ts +185 -0
- package/src/services/alerting/run-monitor.ts +177 -0
- package/src/services/alerting/store.test.ts +222 -0
- package/src/services/alerting/store.ts +289 -0
- package/src/services/alerting/suppression.test.ts +228 -0
- package/src/services/alerting/suppression.ts +142 -0
- package/src/services/alerting/sweep-runner.test.ts +229 -0
- package/src/services/alerting/sweep-runner.ts +204 -0
- package/src/services/alerting/sweep.test.ts +61 -0
- package/src/services/alerting/sweep.ts +41 -0
- package/src/services/alerting/tokens.test.ts +152 -0
- package/src/services/alerting/tokens.ts +119 -0
- package/src/services/alerting/transport-loader.ts +48 -0
- package/src/services/audit/index.test.ts +1 -0
- package/src/services/audit/index.ts +3 -0
- package/src/services/audit/trusted-sources.test.ts +137 -0
- package/src/services/audit/trusted-sources.ts +124 -0
- package/src/services/audit/types.ts +2 -1
- package/src/services/firewall-reach.ts +83 -0
- package/src/services/health-runner.test.ts +50 -0
- package/src/services/health-runner.ts +116 -82
- package/src/services/machine-pool.ts +2 -1
- package/src/services/module-deploy.ts +17 -0
- package/src/services/system-config-validator.test.ts +31 -1
- package/src/services/trusted-sources.test.ts +221 -0
- package/src/services/trusted-sources.ts +159 -0
- package/src/services/update/orchestrator.test.ts +1 -0
- package/src/templates/generator.ts +6 -29
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
PortForwardStore,
|
|
25
25
|
RouteOps,
|
|
26
26
|
RouteReadView,
|
|
27
|
+
TrustedSourceStore,
|
|
27
28
|
} from '@celilo/capabilities';
|
|
28
29
|
import { and, eq } from 'drizzle-orm';
|
|
29
30
|
import type { DbClient } from '../db/client';
|
|
@@ -35,6 +36,14 @@ import { getModuleSystems } from '../services/deployed-systems';
|
|
|
35
36
|
import { withDnsInternalLedger } from '../services/dns-internal-records';
|
|
36
37
|
import { withDnsRegistrationLedger } from '../services/dns-registrations';
|
|
37
38
|
import { buildPortForwardStore } from '../services/port-forwards';
|
|
39
|
+
import {
|
|
40
|
+
TRUSTED_SUBNETS_CONFIG_KEY,
|
|
41
|
+
type TrustedSubnetEntry,
|
|
42
|
+
buildTrustedSourceStore,
|
|
43
|
+
composeTrustedSubnets,
|
|
44
|
+
listTrustedSourcesFor,
|
|
45
|
+
parseOperatorTrustedSubnets,
|
|
46
|
+
} from '../services/trusted-sources';
|
|
38
47
|
import { resolveComputedFields } from '../variables/computed/evaluate';
|
|
39
48
|
import { containsComputedMarker } from '../variables/computed/marker';
|
|
40
49
|
import { buildProviderLookup } from '../variables/computed/provider-lookup';
|
|
@@ -54,37 +63,46 @@ import { loadHookConfigMap } from './load-hook-config';
|
|
|
54
63
|
* upstream-chain wiring takes a second factory argument (`upstreamFirewall`)
|
|
55
64
|
* that doesn't fit the `defineCapabilityFunction` shape.
|
|
56
65
|
*/
|
|
57
|
-
const CAPABILITY_MODULE_MAP: Record<string, { script: string; legacyFactoryName: string }> =
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
66
|
+
export const CAPABILITY_MODULE_MAP: Record<string, { script: string; legacyFactoryName: string }> =
|
|
67
|
+
{
|
|
68
|
+
dns_registrar: {
|
|
69
|
+
script: 'scripts/register-host.ts',
|
|
70
|
+
legacyFactoryName: 'default',
|
|
71
|
+
},
|
|
72
|
+
firewall: {
|
|
73
|
+
script: 'scripts/firewall-functions.ts',
|
|
74
|
+
legacyFactoryName: 'createFirewall',
|
|
75
|
+
},
|
|
76
|
+
// public_web: handled via framework implementation (createPublicWeb from @celilo/capabilities)
|
|
77
|
+
idp: {
|
|
78
|
+
script: 'scripts/idp-functions.ts',
|
|
79
|
+
legacyFactoryName: 'createIdp',
|
|
80
|
+
},
|
|
81
|
+
source_forge: {
|
|
82
|
+
script: 'scripts/source-forge-functions.ts',
|
|
83
|
+
legacyFactoryName: 'createForgejoSourceForge',
|
|
84
|
+
},
|
|
85
|
+
registry_publish: {
|
|
86
|
+
script: 'scripts/registry-publish-functions.ts',
|
|
87
|
+
legacyFactoryName: 'default',
|
|
88
|
+
},
|
|
89
|
+
dhcp_server: {
|
|
90
|
+
script: 'scripts/dhcp-server-functions.ts',
|
|
91
|
+
legacyFactoryName: 'default',
|
|
92
|
+
},
|
|
93
|
+
dns_internal: {
|
|
94
|
+
script: 'scripts/dns-internal-functions.ts',
|
|
95
|
+
legacyFactoryName: 'default',
|
|
96
|
+
},
|
|
97
|
+
notification: {
|
|
98
|
+
script: 'scripts/notification.ts',
|
|
99
|
+
legacyFactoryName: 'default',
|
|
100
|
+
},
|
|
101
|
+
external_web: {
|
|
102
|
+
script: 'scripts/publish-functions.ts',
|
|
103
|
+
legacyFactoryName: 'default',
|
|
104
|
+
},
|
|
105
|
+
};
|
|
88
106
|
|
|
89
107
|
/**
|
|
90
108
|
* Load capability function interfaces for a consuming module
|
|
@@ -199,6 +217,7 @@ export async function loadCapabilityFunctions(
|
|
|
199
217
|
db,
|
|
200
218
|
logger,
|
|
201
219
|
debugLog,
|
|
220
|
+
consumingModuleId,
|
|
202
221
|
);
|
|
203
222
|
if (firewallInterface) {
|
|
204
223
|
result[capName] = firewallInterface;
|
|
@@ -277,7 +296,13 @@ export async function loadCapabilityFunctions(
|
|
|
277
296
|
systems: getModuleSystems(capability.moduleId, db),
|
|
278
297
|
logger,
|
|
279
298
|
});
|
|
280
|
-
|
|
299
|
+
// Stamp here too, not only on the legacy path: a consumer that cannot
|
|
300
|
+
// get what it needs must be able to name WHICH provider could not give
|
|
301
|
+
// it. Missing this made the wireguard module report "Firewall provider
|
|
302
|
+
// 'unknown' does not support trusted-source registration" against
|
|
303
|
+
// greenwave — true, useless, and a violation of the contract's
|
|
304
|
+
// requirement to name the provider.
|
|
305
|
+
result[capName] = withLedger(stampProvider(capabilityInterface, capability.moduleId));
|
|
281
306
|
debugLog(`${capName}: loaded via defineCapabilityFunction`);
|
|
282
307
|
continue;
|
|
283
308
|
}
|
|
@@ -294,6 +319,10 @@ export async function loadCapabilityFunctions(
|
|
|
294
319
|
// port-forward store — the chain path isn't taken when there's one provider.
|
|
295
320
|
capName === 'firewall' ? buildPortForwardStore(db) : undefined,
|
|
296
321
|
capName === 'firewall' ? loadFirewallZones(db) : undefined,
|
|
322
|
+
// Bound to the CONSUMING module so a trusted-source registration is
|
|
323
|
+
// attributable — reach into every tier must never be anonymous.
|
|
324
|
+
capName === 'firewall' ? buildTrustedSourceStore(db, consumingModuleId) : undefined,
|
|
325
|
+
capability.moduleId,
|
|
297
326
|
);
|
|
298
327
|
|
|
299
328
|
if (capabilityInterface) {
|
|
@@ -539,6 +568,16 @@ export async function loadCapabilityFunctions(
|
|
|
539
568
|
* factories are now branded `defineCapabilityFunction` outputs that the
|
|
540
569
|
* caller invokes directly with the new context.
|
|
541
570
|
*/
|
|
571
|
+
/**
|
|
572
|
+
* Stamp the provider module id onto a capability interface, so a consumer that
|
|
573
|
+
* cannot get what it needs can name WHICH provider could not supply it (see
|
|
574
|
+
* `requireTrustedSources`). Non-function properties survive `wrapWithLogging`.
|
|
575
|
+
*/
|
|
576
|
+
function stampProvider(iface: unknown, providerModuleId?: string): unknown {
|
|
577
|
+
if (!iface || typeof iface !== 'object' || !providerModuleId) return iface;
|
|
578
|
+
return Object.assign(iface, { providerModuleId });
|
|
579
|
+
}
|
|
580
|
+
|
|
542
581
|
function buildCapabilityInterface(
|
|
543
582
|
capabilityName: string,
|
|
544
583
|
factory: (...args: unknown[]) => unknown,
|
|
@@ -546,6 +585,8 @@ function buildCapabilityInterface(
|
|
|
546
585
|
_secrets: Record<string, string>,
|
|
547
586
|
store?: PortForwardStore,
|
|
548
587
|
zones?: FirewallZones,
|
|
588
|
+
trustedSourceStore?: TrustedSourceStore,
|
|
589
|
+
providerModuleId?: string,
|
|
549
590
|
): unknown {
|
|
550
591
|
if (capabilityName === 'firewall') {
|
|
551
592
|
// iptables firewall factory: NAT config + the injected port-forward store
|
|
@@ -557,15 +598,20 @@ function buildCapabilityInterface(
|
|
|
557
598
|
if (!store) {
|
|
558
599
|
throw new Error('firewall capability requires an injected port-forward store');
|
|
559
600
|
}
|
|
560
|
-
|
|
601
|
+
const iface = factory(
|
|
561
602
|
{
|
|
562
603
|
firewallIp: config.firewall_ip as string,
|
|
563
604
|
natIp: config.nat_ip as string,
|
|
564
605
|
zoneTiers: zones?.zoneTiers ?? [],
|
|
565
606
|
trustedSubnets: zones?.trustedSubnets ?? [],
|
|
607
|
+
controlPlaneSubnet: zones?.controlPlaneSubnet,
|
|
566
608
|
},
|
|
567
609
|
store,
|
|
610
|
+
undefined, // no upstream — the chain path handles that
|
|
611
|
+
undefined, // logger is applied by wrapWithLogging at the loader site
|
|
612
|
+
trustedSourceStore,
|
|
568
613
|
);
|
|
614
|
+
return stampProvider(iface, providerModuleId);
|
|
569
615
|
}
|
|
570
616
|
return null;
|
|
571
617
|
}
|
|
@@ -661,8 +707,10 @@ const ZONE_TIER_ORDER = ['dmz', 'app', 'secure'] as const;
|
|
|
661
707
|
interface FirewallZones {
|
|
662
708
|
/** Ordered segmented tiers (dmz→app→secure adjacency) for the data-plane matrix. */
|
|
663
709
|
zoneTiers: Array<{ name: string; subnet: string }>;
|
|
664
|
-
/** Subnets that reach EVERY tier —
|
|
710
|
+
/** Subnets that reach EVERY tier — the composed set (see loadTrustedSubnets). */
|
|
665
711
|
trustedSubnets: string[];
|
|
712
|
+
/** celilo's control-plane network, as a DESTINATION for trusted sources. */
|
|
713
|
+
controlPlaneSubnet?: string;
|
|
666
714
|
}
|
|
667
715
|
|
|
668
716
|
/** The module that IS celilo's control plane; its network is what we trust. */
|
|
@@ -699,10 +747,51 @@ export function loadControlPlaneSubnet(db: DbClient): string | undefined {
|
|
|
699
747
|
return undefined;
|
|
700
748
|
}
|
|
701
749
|
|
|
750
|
+
/**
|
|
751
|
+
* The composed trusted-subnet set for one firewall, with each subnet's ORIGIN —
|
|
752
|
+
* celilo's derived control plane, the sources modules registered, and any
|
|
753
|
+
* explicit operator override.
|
|
754
|
+
*
|
|
755
|
+
* This used to be the derived control-plane subnet and nothing else, with no
|
|
756
|
+
* contribution point: infrastructure whose need was zone-wide REACH rather than
|
|
757
|
+
* port exposure had no way into the registry, so a converge — correctly
|
|
758
|
+
* rebuilding the ruleset from what it knew — removed rules nothing had claimed.
|
|
759
|
+
*
|
|
760
|
+
* With nothing registered and no override the result is the derived subnet
|
|
761
|
+
* alone, so a fleet without such infrastructure renders exactly as before.
|
|
762
|
+
*
|
|
763
|
+
* `firewallIp` includes that firewall's REGISTRATIONS — the full composed view,
|
|
764
|
+
* for REPORTING (which networks hold zone-wide reach, and who claimed each).
|
|
765
|
+
* Omit it for the render input: there the provider unions the live registry at
|
|
766
|
+
* converge time, and folding a snapshot in here as well would give one value two
|
|
767
|
+
* sources of truth, one of which cannot shrink.
|
|
768
|
+
*/
|
|
769
|
+
export function loadTrustedSubnets(db: DbClient, firewallIp?: string): TrustedSubnetEntry[] {
|
|
770
|
+
// Fall back to the internal subnet when celilo-mgmt's location can't be
|
|
771
|
+
// determined (e.g. installs predating celilo-mgmt-as-a-module). Losing control-
|
|
772
|
+
// plane trust outright would brick celilo's own fleet management, so absent
|
|
773
|
+
// information preserves today's behaviour; the gap is REPORTED separately.
|
|
774
|
+
const controlPlaneSubnet = loadControlPlaneSubnet(db) ?? readZoneSubnet(db, 'internal');
|
|
775
|
+
// Registrations are keyed by firewall. With no firewall named, contribute
|
|
776
|
+
// NONE rather than unioning every firewall's registrations — trust registered
|
|
777
|
+
// against one firewall is not trust granted by another.
|
|
778
|
+
const registered = firewallIp ? listTrustedSourcesFor(db, firewallIp) : [];
|
|
779
|
+
const overrideRow = db
|
|
780
|
+
.select()
|
|
781
|
+
.from(systemConfig)
|
|
782
|
+
.where(eq(systemConfig.key, TRUSTED_SUBNETS_CONFIG_KEY))
|
|
783
|
+
.get();
|
|
784
|
+
return composeTrustedSubnets({
|
|
785
|
+
controlPlaneSubnet,
|
|
786
|
+
registered,
|
|
787
|
+
operatorOverride: parseOperatorTrustedSubnets(overrideRow?.value ?? undefined),
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
|
|
702
791
|
/**
|
|
703
792
|
* Read the firewall zone matrix inputs from system config (network.<zone>.subnet):
|
|
704
|
-
* the segmented tiers [dmz, app, secure] and the
|
|
705
|
-
*
|
|
793
|
+
* the segmented tiers [dmz, app, secure] and the trusted subnets that reach all
|
|
794
|
+
* of them. Zones with no configured subnet are omitted — their traffic stays
|
|
706
795
|
* denied (fail-closed).
|
|
707
796
|
*/
|
|
708
797
|
function loadFirewallZones(db: DbClient): FirewallZones {
|
|
@@ -711,12 +800,21 @@ function loadFirewallZones(db: DbClient): FirewallZones {
|
|
|
711
800
|
const subnet = readZoneSubnet(db, zone);
|
|
712
801
|
if (subnet) zoneTiers.push({ name: zone, subnet });
|
|
713
802
|
}
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
|
|
719
|
-
|
|
803
|
+
// The BASE set only — derived control plane + operator override. Module
|
|
804
|
+
// registrations are DELIBERATELY excluded: the provider unions them at
|
|
805
|
+
// converge time from the live store.
|
|
806
|
+
//
|
|
807
|
+
// Injecting them here too would give one value two sources of truth, and the
|
|
808
|
+
// snapshot is taken when the capability is built — before any hook runs. A
|
|
809
|
+
// hook that WITHDRAWS a registration then converges would have the stale
|
|
810
|
+
// snapshot put the subnet straight back, so the set could grow but never
|
|
811
|
+
// shrink. That is exactly what happened: `on_uninstall` withdrew the VPN's
|
|
812
|
+
// trusted source, logged success, and the reach rules were re-rendered anyway.
|
|
813
|
+
return {
|
|
814
|
+
zoneTiers,
|
|
815
|
+
trustedSubnets: loadTrustedSubnets(db).map((e) => e.subnet),
|
|
816
|
+
controlPlaneSubnet: loadControlPlaneSubnet(db) ?? readZoneSubnet(db, 'internal'),
|
|
817
|
+
};
|
|
720
818
|
}
|
|
721
819
|
|
|
722
820
|
async function buildFirewallChain(
|
|
@@ -732,13 +830,15 @@ async function buildFirewallChain(
|
|
|
732
830
|
db: DbClient,
|
|
733
831
|
logger: HookLogger,
|
|
734
832
|
debugLog: (msg: string) => void,
|
|
833
|
+
consumingModuleId: string,
|
|
735
834
|
): Promise<unknown> {
|
|
736
835
|
// The shared-core port-forward registry, injected into every firewall provider
|
|
737
836
|
// in the chain so exposeService/converge reconcile against the one canonical
|
|
738
837
|
// store (openspec/changes/unified-management-no-ssh/proposal.md).
|
|
739
838
|
const store = buildPortForwardStore(db);
|
|
740
|
-
//
|
|
741
|
-
|
|
839
|
+
// Trusted-source registry, bound to the CONSUMING module so a registration is
|
|
840
|
+
// attributable. Only the layers that render their own ruleset receive it.
|
|
841
|
+
const trustedSourceStore = buildTrustedSourceStore(db, consumingModuleId);
|
|
742
842
|
|
|
743
843
|
// Find the provider with has_external (the leaf — has direct internet access)
|
|
744
844
|
const hasExternal = allProviders.find((p) => {
|
|
@@ -783,6 +883,7 @@ async function buildFirewallChain(
|
|
|
783
883
|
systems: getModuleSystems(hasExternal.moduleId, db),
|
|
784
884
|
logger,
|
|
785
885
|
});
|
|
886
|
+
leafFirewall = stampProvider(leafFirewall, hasExternal.moduleId);
|
|
786
887
|
debugLog(
|
|
787
888
|
`firewall chain: built leaf via defineCapabilityFunction from ${hasExternal.moduleId}`,
|
|
788
889
|
);
|
|
@@ -794,7 +895,9 @@ async function buildFirewallChain(
|
|
|
794
895
|
leafConfig,
|
|
795
896
|
leafSecrets,
|
|
796
897
|
store,
|
|
797
|
-
|
|
898
|
+
loadFirewallZones(db),
|
|
899
|
+
trustedSourceStore,
|
|
900
|
+
hasExternal.moduleId,
|
|
798
901
|
);
|
|
799
902
|
if (leafFirewall) {
|
|
800
903
|
leafFirewall = wrapWithLogging(leafFirewall as object, logger, 'firewall');
|
|
@@ -850,6 +953,10 @@ async function buildFirewallChain(
|
|
|
850
953
|
// celilo's ProgressDisplay instead of dumping to stderr. Older
|
|
851
954
|
// iptables modules ignore it — the factory's signature is
|
|
852
955
|
// backward-compatible.
|
|
956
|
+
// Zone matrix for the default-DROP posture. The same BASE set for every
|
|
957
|
+
// layer; each layer's own registrations are unioned in by its provider at
|
|
958
|
+
// converge time, from the live store.
|
|
959
|
+
const zones = loadFirewallZones(db);
|
|
853
960
|
const downstreamFirewall = provFactory(
|
|
854
961
|
{
|
|
855
962
|
firewallIp,
|
|
@@ -857,15 +964,20 @@ async function buildFirewallChain(
|
|
|
857
964
|
dryRun,
|
|
858
965
|
zoneTiers: zones.zoneTiers,
|
|
859
966
|
trustedSubnets: zones.trustedSubnets,
|
|
967
|
+
controlPlaneSubnet: zones.controlPlaneSubnet,
|
|
860
968
|
},
|
|
861
969
|
store,
|
|
862
970
|
currentUpstream,
|
|
863
971
|
logger,
|
|
972
|
+
trustedSourceStore,
|
|
864
973
|
);
|
|
865
974
|
|
|
866
975
|
debugLog(`firewall chain: wired ${provider.moduleId} → ${hasExternal.moduleId}`);
|
|
867
976
|
// Wrap each downstream layer with auto-logging.
|
|
868
|
-
currentUpstream =
|
|
977
|
+
currentUpstream = stampProvider(
|
|
978
|
+
wrapWithLogging(downstreamFirewall as object, logger, 'firewall'),
|
|
979
|
+
provider.moduleId,
|
|
980
|
+
);
|
|
869
981
|
}
|
|
870
982
|
|
|
871
983
|
return currentUpstream;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recurrence gate: every capability with a FUNCTION implementation is loadable.
|
|
3
|
+
*
|
|
4
|
+
* `CAPABILITY_MODULE_MAP` in capability-loader.ts is a hand-maintained table of
|
|
5
|
+
* capability name → the script that implements it. A module can ship a perfectly
|
|
6
|
+
* good `defineCapabilityFunction`, register the capability on deploy — and still
|
|
7
|
+
* be unreachable, because the loader skips anything absent from that table.
|
|
8
|
+
*
|
|
9
|
+
* The failure is silent and inverted: consumers get "does not provide the
|
|
10
|
+
* notification capability" for a module that demonstrably provides it. It cost
|
|
11
|
+
* the notification transport a full round-trip to find, so it gets a gate.
|
|
12
|
+
*
|
|
13
|
+
* Scoped to FUNCTION capabilities on purpose. A capability that provides only
|
|
14
|
+
* `data:` (`apt_publish`, `celilo_event_bus`) is read through `$capability:`
|
|
15
|
+
* variables and has nothing to load — the map would be the wrong place for it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, expect, test } from 'bun:test';
|
|
19
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { parse } from 'yaml';
|
|
22
|
+
import { CAPABILITY_MODULE_MAP } from './capability-loader';
|
|
23
|
+
|
|
24
|
+
/** Capabilities the framework implements itself, with no provider script. */
|
|
25
|
+
const FRAMEWORK_OWNED = new Set(['public_web']);
|
|
26
|
+
|
|
27
|
+
/** Capability names a module implements as callable functions. */
|
|
28
|
+
function functionCapabilitiesIn(moduleDir: string): Set<string> {
|
|
29
|
+
const found = new Set<string>();
|
|
30
|
+
const scriptsDir = join(MODULES_DIR, moduleDir, 'scripts');
|
|
31
|
+
|
|
32
|
+
let entries: string[];
|
|
33
|
+
try {
|
|
34
|
+
entries = readdirSync(scriptsDir).filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'));
|
|
35
|
+
} catch {
|
|
36
|
+
return found;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const file of entries) {
|
|
40
|
+
const src = readFileSync(join(scriptsDir, file), 'utf-8');
|
|
41
|
+
// The declaration form is `capability: '<name>'` inside
|
|
42
|
+
// defineCapabilityFunction — matching it directly avoids inferring
|
|
43
|
+
// implementation from a filename.
|
|
44
|
+
if (!src.includes('defineCapabilityFunction')) continue;
|
|
45
|
+
for (const match of src.matchAll(/capability:\s*'([a-z_]+)'/g)) found.add(match[1]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return found;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const MODULES_DIR = join(import.meta.dir, '../../../../modules');
|
|
52
|
+
|
|
53
|
+
interface ManifestShape {
|
|
54
|
+
provides?: { capabilities?: { name?: string }[] };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function providedCapabilities(): Map<string, string[]> {
|
|
58
|
+
const byCapability = new Map<string, string[]>();
|
|
59
|
+
|
|
60
|
+
for (const entry of readdirSync(MODULES_DIR, { withFileTypes: true })) {
|
|
61
|
+
if (!entry.isDirectory()) continue;
|
|
62
|
+
let manifest: ManifestShape;
|
|
63
|
+
try {
|
|
64
|
+
manifest = parse(readFileSync(join(MODULES_DIR, entry.name, 'manifest.yml'), 'utf-8'));
|
|
65
|
+
} catch {
|
|
66
|
+
continue; // Not a module directory.
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const implemented = functionCapabilitiesIn(entry.name);
|
|
70
|
+
for (const capability of manifest.provides?.capabilities ?? []) {
|
|
71
|
+
if (!capability.name || !implemented.has(capability.name)) continue;
|
|
72
|
+
const providers = byCapability.get(capability.name) ?? [];
|
|
73
|
+
providers.push(entry.name);
|
|
74
|
+
byCapability.set(capability.name, providers);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return byCapability;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('recurrence gate: function capabilities are loadable', () => {
|
|
82
|
+
const provided = providedCapabilities();
|
|
83
|
+
|
|
84
|
+
test('scans a non-trivial set of modules (sanity — the scan actually ran)', () => {
|
|
85
|
+
expect(provided.size).toBeGreaterThan(2);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('every function capability a module implements is in CAPABILITY_MODULE_MAP', () => {
|
|
89
|
+
const missing: string[] = [];
|
|
90
|
+
for (const [capability, providers] of provided) {
|
|
91
|
+
if (FRAMEWORK_OWNED.has(capability)) continue;
|
|
92
|
+
if (capability in CAPABILITY_MODULE_MAP) continue;
|
|
93
|
+
missing.push(`${capability} (provided by ${providers.join(', ')})`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
expect(
|
|
97
|
+
missing,
|
|
98
|
+
`These capabilities ship a defineCapabilityFunction but are absent from CAPABILITY_MODULE_MAP, so loadCapabilityFunctions will never return them:\n ${missing.join('\n ')}`,
|
|
99
|
+
).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
});
|
package/src/manifest/schema.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { NETWORK_ZONES } from '../db/schema';
|
|
2
3
|
import { SUPPORTED_CONTRACT_VERSIONS } from './contracts';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -272,6 +273,75 @@ export const LifecycleHookSchema = z.object({
|
|
|
272
273
|
timeout: z.number().positive().optional(),
|
|
273
274
|
});
|
|
274
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Floor for a module's suggested `health_check` interval.
|
|
278
|
+
*
|
|
279
|
+
* The monitor sweep rides the event bus's fixed `timer.tick.5m`
|
|
280
|
+
* (packages/event-bus/src/timer.ts), so an interval below five minutes
|
|
281
|
+
* cannot be honoured — it would silently round up. Rejecting it at manifest
|
|
282
|
+
* validation is better than accepting a promise celilo can't keep.
|
|
283
|
+
*/
|
|
284
|
+
export const MONITOR_INTERVAL_FLOOR_MINUTES = 5;
|
|
285
|
+
|
|
286
|
+
const DURATION_PATTERN = /^(\d+)(m|h|d)$/;
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Parse a duration string (`15m`, `1h`, `1d`) to whole minutes.
|
|
290
|
+
* Returns null when the string is not a well-formed duration.
|
|
291
|
+
*/
|
|
292
|
+
export function parseIntervalMinutes(value: string): number | null {
|
|
293
|
+
const match = DURATION_PATTERN.exec(value);
|
|
294
|
+
if (!match) return null;
|
|
295
|
+
const amount = Number.parseInt(match[1], 10);
|
|
296
|
+
if (!Number.isFinite(amount) || amount <= 0) return null;
|
|
297
|
+
const unit = match[2];
|
|
298
|
+
if (unit === 'm') return amount;
|
|
299
|
+
if (unit === 'h') return amount * 60;
|
|
300
|
+
return amount * 60 * 24;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* `health_check` accepts everything a lifecycle hook does, plus an optional
|
|
305
|
+
* `interval` — the module author's SUGGESTED monitoring cadence.
|
|
306
|
+
*
|
|
307
|
+
* It is only a suggestion: the operator's `monitors` row is the effective
|
|
308
|
+
* schedule and survives module upgrades (openspec/changes/add-alerting
|
|
309
|
+
* design D3). A module that omits it is simply not monitored until an
|
|
310
|
+
* operator creates a monitor by hand.
|
|
311
|
+
*/
|
|
312
|
+
export const HealthCheckHookSchema = LifecycleHookSchema.extend({
|
|
313
|
+
// `.regex` duplicates the well-formedness half of the `superRefine` below on
|
|
314
|
+
// purpose: only `.regex` survives the export to JSON Schema, and that export is
|
|
315
|
+
// what validates `modules/*/manifest.yml` in the editor. The 5-minute floor
|
|
316
|
+
// cannot be expressed in JSON Schema at all, so it stays a refinement — which
|
|
317
|
+
// is why both exist rather than one. Keeping them in sync is the point of
|
|
318
|
+
// sharing DURATION_PATTERN.
|
|
319
|
+
interval: z
|
|
320
|
+
.string()
|
|
321
|
+
.regex(DURATION_PATTERN)
|
|
322
|
+
.optional()
|
|
323
|
+
.describe(
|
|
324
|
+
'Suggested monitoring cadence, e.g. "15m", "1h", "1d". Must be 5m or longer — the monitor sweep runs on a 5-minute grid.',
|
|
325
|
+
)
|
|
326
|
+
.superRefine((value, ctx) => {
|
|
327
|
+
if (value === undefined) return;
|
|
328
|
+
const minutes = parseIntervalMinutes(value);
|
|
329
|
+
if (minutes === null) {
|
|
330
|
+
ctx.addIssue({
|
|
331
|
+
code: z.ZodIssueCode.custom,
|
|
332
|
+
message: `Invalid health_check interval "${value}". Use a duration like "15m", "1h", or "1d".`,
|
|
333
|
+
});
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (minutes < MONITOR_INTERVAL_FLOOR_MINUTES) {
|
|
337
|
+
ctx.addIssue({
|
|
338
|
+
code: z.ZodIssueCode.custom,
|
|
339
|
+
message: `health_check interval "${value}" is below the ${MONITOR_INTERVAL_FLOOR_MINUTES}m floor — the monitor sweep runs on a ${MONITOR_INTERVAL_FLOOR_MINUTES}-minute grid and cannot run it more often. Use "${MONITOR_INTERVAL_FLOOR_MINUTES}m" or longer.`,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}),
|
|
343
|
+
});
|
|
344
|
+
|
|
275
345
|
/**
|
|
276
346
|
* Build-bus upstream-publish hook. Fires when a publish event lands
|
|
277
347
|
* on the local event bus from the receiver daemon
|
|
@@ -320,9 +390,10 @@ export const SystemResourceSchema = z.object({
|
|
|
320
390
|
'Proxmox provisioning type: lxc (default) or vm (qemu, for Docker / kernel-module workloads). ' +
|
|
321
391
|
'Modules declare this explicitly; celilo never infers it. Moot for machine-pool / external infra.',
|
|
322
392
|
),
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
393
|
+
// Derived from NETWORK_ZONES, not re-listed: a hand-written copy here would be
|
|
394
|
+
// a fourth place a new zone has to be remembered, and the copies in
|
|
395
|
+
// module-show and machine-pool had both already drifted.
|
|
396
|
+
zone: z.enum(NETWORK_ZONES).describe('Required security zone for this module'),
|
|
326
397
|
});
|
|
327
398
|
|
|
328
399
|
/**
|
|
@@ -535,7 +606,9 @@ export const ModuleManifestSchema = z
|
|
|
535
606
|
container_created: LifecycleHookSchema.optional(),
|
|
536
607
|
on_install: LifecycleHookSchema.optional(),
|
|
537
608
|
on_uninstall: LifecycleHookSchema.optional(),
|
|
538
|
-
health_check:
|
|
609
|
+
health_check: HealthCheckHookSchema.optional().describe(
|
|
610
|
+
"Health check hook. `interval` is the module's SUGGESTED monitoring cadence; the operator's monitor row is the effective schedule and always wins.",
|
|
611
|
+
),
|
|
539
612
|
validate_config: LifecycleHookSchema.optional(),
|
|
540
613
|
on_backup: LifecycleHookSchema.optional(),
|
|
541
614
|
on_backup_analyze: LifecycleHookSchema.optional(),
|