@celilo/cli 0.3.30 → 0.4.0-alpha.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/drizzle/0005_module_operations.sql +12 -0
- package/drizzle/0006_base_module_aspects.sql +15 -0
- package/drizzle/0007_module_systems.sql +17 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +6 -5
- package/schemas/system_config.json +14 -28
- package/src/ansible/inventory.test.ts +46 -62
- package/src/ansible/inventory.ts +48 -25
- package/src/capabilities/registration.ts +25 -7
- package/src/capabilities/validation.test.ts +30 -0
- package/src/capabilities/validation.ts +8 -0
- package/src/cli/backup-rename.test.ts +95 -0
- package/src/cli/cli.test.ts +17 -23
- package/src/cli/command-registry.ts +199 -0
- package/src/cli/commands/backup-list.ts +1 -1
- package/src/cli/commands/events.ts +96 -0
- package/src/cli/commands/machine-add.ts +103 -59
- package/src/cli/commands/module-import.ts +153 -4
- package/src/cli/commands/module-remove.ts +86 -17
- package/src/cli/commands/module-status.ts +6 -2
- package/src/cli/commands/publish/alpha.test.ts +185 -0
- package/src/cli/commands/publish/alpha.ts +226 -0
- package/src/cli/commands/publish/changesets.test.ts +89 -0
- package/src/cli/commands/publish/changesets.ts +144 -0
- package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
- package/src/cli/commands/publish/consumer-pins.ts +149 -0
- package/src/cli/commands/publish/execute.ts +131 -0
- package/src/cli/commands/publish/global-install.test.ts +154 -0
- package/src/cli/commands/publish/global-install.ts +171 -0
- package/src/cli/commands/publish/helpers.ts +227 -0
- package/src/cli/commands/publish/index.ts +365 -0
- package/src/cli/commands/publish/module-registry.test.ts +40 -0
- package/src/cli/commands/publish/module-registry.ts +64 -0
- package/src/cli/commands/publish/plan.ts +107 -0
- package/src/cli/commands/publish/preflight.ts +238 -0
- package/src/cli/commands/publish/types.ts +264 -0
- package/src/cli/commands/publish/workspace.test.ts +323 -0
- package/src/cli/commands/publish/workspace.ts +596 -0
- package/src/cli/commands/restore.ts +126 -0
- package/src/cli/commands/storage-add-local.ts +1 -1
- package/src/cli/commands/storage-add-s3.ts +1 -1
- package/src/cli/commands/subscribers-add.ts +68 -0
- package/src/cli/commands/subscribers-list.ts +48 -0
- package/src/cli/commands/subscribers-remove.ts +38 -0
- package/src/cli/commands/subscribers-serve.ts +77 -0
- package/src/cli/commands/subscribers-status.ts +33 -0
- package/src/cli/commands/subscribers-test.ts +71 -0
- package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
- package/src/cli/commands/system-apply-config.test.ts +70 -0
- package/src/cli/commands/system-apply-config.ts +130 -0
- package/src/cli/commands/system-audit.ts +2 -1
- package/src/cli/commands/system-init-deprecation.test.ts +90 -0
- package/src/cli/commands/system-init.ts +36 -70
- package/src/cli/commands/system-update.ts +3 -2
- package/src/cli/completion.ts +22 -1
- package/src/cli/index.ts +214 -6
- package/src/cli/interactive-config.test.ts +19 -0
- package/src/cli/restore-command.test.ts +131 -0
- package/src/db/client.ts +42 -0
- package/src/db/schema.test.ts +13 -16
- package/src/db/schema.ts +161 -9
- package/src/hooks/capability-loader-firewall.test.ts +6 -15
- package/src/hooks/capability-loader.test.ts +2 -3
- package/src/hooks/capability-loader.ts +36 -2
- package/src/hooks/define-hook.test.ts +4 -0
- package/src/hooks/executor.test.ts +18 -0
- package/src/hooks/executor.ts +21 -2
- package/src/hooks/load-hook-config.test.ts +26 -24
- package/src/hooks/load-hook-config.ts +11 -2
- package/src/hooks/run-named-hook.ts +16 -0
- package/src/hooks/types.ts +9 -1
- package/src/manifest/contracts/v1.ts +70 -0
- package/src/manifest/schema.ts +262 -16
- package/src/manifest/validate-privileged.test.ts +84 -0
- package/src/manifest/validate.test.ts +156 -0
- package/src/manifest/validate.ts +69 -0
- package/src/module/import.ts +12 -0
- package/src/services/aspect-approvals.test.ts +231 -0
- package/src/services/aspect-approvals.ts +120 -0
- package/src/services/aspect-runner.test.ts +493 -0
- package/src/services/aspect-runner.ts +438 -0
- package/src/services/aspect-template-resolver.test.ts +101 -0
- package/src/services/aspect-template-resolver.ts +122 -0
- package/src/services/backup-create.ts +104 -25
- package/src/services/backup-envelope-roundtrip.test.ts +199 -0
- package/src/services/backup-in-flight-refusal.test.ts +163 -0
- package/src/services/backup-manifest.test.ts +115 -0
- package/src/services/backup-manifest.ts +163 -0
- package/src/services/backup-restore.ts +154 -19
- package/src/services/build-bus/delivery-events.ts +92 -0
- package/src/services/build-bus/event-factory.ts +54 -0
- package/src/services/build-bus/fan-out.test.ts +279 -0
- package/src/services/build-bus/fan-out.ts +161 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
- package/src/services/build-bus/hook-dispatch.test.ts +207 -0
- package/src/services/build-bus/hook-dispatch.ts +198 -0
- package/src/services/build-bus/hook-dispatcher.ts +115 -0
- package/src/services/build-bus/index.ts +41 -0
- package/src/services/build-bus/receiver-server.test.ts +179 -0
- package/src/services/build-bus/receiver-server.ts +159 -0
- package/src/services/build-bus/status.test.ts +212 -0
- package/src/services/build-bus/status.ts +213 -0
- package/src/services/build-bus/subscriber-store.ts +113 -0
- package/src/services/celilo-events.test.ts +70 -0
- package/src/services/celilo-events.ts +92 -0
- package/src/services/celilo-mgmt-hooks.test.ts +296 -0
- package/src/services/config-interview.ts +13 -95
- package/src/services/cross-module-data-manager.ts +2 -31
- package/src/services/cross-module-read.test.ts +250 -0
- package/src/services/cross-module-read.ts +232 -0
- package/src/services/deploy-validation.ts +7 -0
- package/src/services/deployed-systems.test.ts +235 -0
- package/src/services/deployed-systems.ts +308 -0
- package/src/services/dns-provider-backfill.ts +75 -0
- package/src/services/health-runner.ts +19 -3
- package/src/services/infrastructure-variable-resolver.test.ts +6 -32
- package/src/services/infrastructure-variable-resolver.ts +3 -13
- package/src/services/machine-detector.ts +104 -48
- package/src/services/machine-pool.ts +145 -2
- package/src/services/module-config.ts +78 -120
- package/src/services/module-deploy.ts +113 -40
- package/src/services/module-operations.test.ts +154 -0
- package/src/services/module-operations.ts +154 -0
- package/src/services/module-subscriptions.test.ts +58 -0
- package/src/services/module-subscriptions.ts +24 -1
- package/src/services/module-types-generator.test.ts +3 -3
- package/src/services/module-types-generator.ts +7 -2
- package/src/services/proxmox-reconcile.test.ts +333 -0
- package/src/services/proxmox-reconcile.ts +156 -0
- package/src/services/proxmox-state-recovery.ts +3 -24
- package/src/services/restore-from-file.test.ts +177 -0
- package/src/services/restore-from-file.ts +355 -0
- package/src/services/restore-preflight.test.ts +127 -0
- package/src/services/restore-preflight.ts +118 -0
- package/src/services/storage-providers/s3.ts +10 -2
- package/src/services/system-identity.ts +30 -0
- package/src/services/system-init.test.ts +64 -21
- package/src/services/system-init.ts +28 -26
- package/src/templates/generator.test.ts +7 -16
- package/src/templates/generator.ts +28 -115
- package/src/test-utils/integration.ts +5 -2
- package/src/types/infrastructure.ts +8 -0
- package/src/variables/computed/computed-integration.test.ts +191 -0
- package/src/variables/computed/computed.test.ts +177 -0
- package/src/variables/computed/evaluate.ts +271 -0
- package/src/variables/computed/marker.ts +53 -0
- package/src/variables/computed/parse.ts +262 -0
- package/src/variables/computed/provider-lookup.ts +130 -0
- package/src/variables/context.test.ts +89 -28
- package/src/variables/context.ts +196 -191
- package/src/variables/parser.ts +3 -3
- package/src/variables/resolver.test.ts +61 -0
- package/src/variables/resolver.ts +81 -0
- package/src/variables/types.ts +23 -1
- package/src/services/dns-auto-register.ts +0 -211
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delivery-history aggregator for `celilo subscribers status`
|
|
3
|
+
* ([[v2/BUILD_BUS.md]] Phase 6).
|
|
4
|
+
*
|
|
5
|
+
* Reads recent `webhook.delivered` + `webhook.failed` events from
|
|
6
|
+
* the local event bus, groups by subscriber URL, and produces a
|
|
7
|
+
* per-subscriber summary the CLI can render directly. Subscribers
|
|
8
|
+
* that exist in the static-config store but have no delivery
|
|
9
|
+
* history yet are still listed (with "no deliveries recorded").
|
|
10
|
+
*
|
|
11
|
+
* The aggregation function is pure — takes already-fetched event
|
|
12
|
+
* payloads + the subscriber config, returns the summary. The disk
|
|
13
|
+
* wrapper reads from the bus and delegates.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
17
|
+
import type { Subscriber } from '@celilo/event-bus/build-bus';
|
|
18
|
+
import { getEventBusPath } from '../../config/paths';
|
|
19
|
+
import {
|
|
20
|
+
WEBHOOK_DELIVERED_EVENT,
|
|
21
|
+
WEBHOOK_FAILED_EVENT,
|
|
22
|
+
type WebhookDeliveryPayload,
|
|
23
|
+
} from './delivery-events';
|
|
24
|
+
import { loadSubscribers } from './subscriber-store';
|
|
25
|
+
|
|
26
|
+
const NO_SCHEMAS = defineEvents({});
|
|
27
|
+
|
|
28
|
+
const DEFAULT_HISTORY_LIMIT = 100;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Per-bus-event record the aggregator works with. Decoupled from
|
|
32
|
+
* BusEvent shape so the pure aggregator doesn't import bus types.
|
|
33
|
+
*/
|
|
34
|
+
export interface DeliveryRecord {
|
|
35
|
+
type: typeof WEBHOOK_DELIVERED_EVENT | typeof WEBHOOK_FAILED_EVENT;
|
|
36
|
+
emittedAt: number;
|
|
37
|
+
payload: WebhookDeliveryPayload;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface FailureSnapshot {
|
|
41
|
+
emittedAt: number;
|
|
42
|
+
eventId: string;
|
|
43
|
+
packageName: string;
|
|
44
|
+
packageVersion: string;
|
|
45
|
+
attempts: number;
|
|
46
|
+
/** Truncated to keep the status output readable. */
|
|
47
|
+
error: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SubscriberStatus {
|
|
51
|
+
url: string;
|
|
52
|
+
label: string;
|
|
53
|
+
/** Configured match rule, for the operator to compare against actual deliveries. */
|
|
54
|
+
match: Subscriber['match'];
|
|
55
|
+
totals: { delivered: number; failed: number };
|
|
56
|
+
successRatePct: number | null; // null when no deliveries yet
|
|
57
|
+
/** Most recent delivery (success or failure) — undefined when none. */
|
|
58
|
+
lastDelivery?: {
|
|
59
|
+
emittedAt: number;
|
|
60
|
+
ok: boolean;
|
|
61
|
+
eventId: string;
|
|
62
|
+
packageName: string;
|
|
63
|
+
packageVersion: string;
|
|
64
|
+
};
|
|
65
|
+
/** Most recent N failures, newest first. Capped at 5. */
|
|
66
|
+
recentFailures: FailureSnapshot[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const RECENT_FAILURES_LIMIT = 5;
|
|
70
|
+
const ERROR_TRUNCATE = 200;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Pure: combine the subscriber config with the bus's delivery
|
|
74
|
+
* history into a per-subscriber status list. The returned array is
|
|
75
|
+
* ordered the same way the subscriber config is (no implicit
|
|
76
|
+
* sorting — operator sees them in the order they were added).
|
|
77
|
+
*/
|
|
78
|
+
export function aggregateSubscriberStatus(
|
|
79
|
+
subscribers: Subscriber[],
|
|
80
|
+
records: DeliveryRecord[],
|
|
81
|
+
): SubscriberStatus[] {
|
|
82
|
+
// Group records by subscriber URL. Each subscriber gets its own
|
|
83
|
+
// chronologically-sorted-newest-first array.
|
|
84
|
+
const byUrl = new Map<string, DeliveryRecord[]>();
|
|
85
|
+
for (const r of records) {
|
|
86
|
+
const list = byUrl.get(r.payload.subscriberUrl) ?? [];
|
|
87
|
+
list.push(r);
|
|
88
|
+
byUrl.set(r.payload.subscriberUrl, list);
|
|
89
|
+
}
|
|
90
|
+
for (const list of byUrl.values()) {
|
|
91
|
+
list.sort((a, b) => b.emittedAt - a.emittedAt);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return subscribers.map((sub) => {
|
|
95
|
+
const records = byUrl.get(sub.url) ?? [];
|
|
96
|
+
const delivered = records.filter((r) => r.type === WEBHOOK_DELIVERED_EVENT).length;
|
|
97
|
+
const failed = records.filter((r) => r.type === WEBHOOK_FAILED_EVENT).length;
|
|
98
|
+
const total = delivered + failed;
|
|
99
|
+
const successRatePct = total === 0 ? null : Math.round((delivered / total) * 100);
|
|
100
|
+
const lastRecord = records[0];
|
|
101
|
+
const recentFailures: FailureSnapshot[] = records
|
|
102
|
+
.filter((r) => r.type === WEBHOOK_FAILED_EVENT)
|
|
103
|
+
.slice(0, RECENT_FAILURES_LIMIT)
|
|
104
|
+
.map((r) => ({
|
|
105
|
+
emittedAt: r.emittedAt,
|
|
106
|
+
eventId: r.payload.eventId,
|
|
107
|
+
packageName: r.payload.packageName,
|
|
108
|
+
packageVersion: r.payload.packageVersion,
|
|
109
|
+
attempts: r.payload.attempts,
|
|
110
|
+
error: (r.payload.error ?? '<no error message>').slice(0, ERROR_TRUNCATE),
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
url: sub.url,
|
|
115
|
+
label: sub.name ?? sub.url,
|
|
116
|
+
match: sub.match,
|
|
117
|
+
totals: { delivered, failed },
|
|
118
|
+
successRatePct,
|
|
119
|
+
lastDelivery: lastRecord
|
|
120
|
+
? {
|
|
121
|
+
emittedAt: lastRecord.emittedAt,
|
|
122
|
+
ok: lastRecord.type === WEBHOOK_DELIVERED_EVENT,
|
|
123
|
+
eventId: lastRecord.payload.eventId,
|
|
124
|
+
packageName: lastRecord.payload.packageName,
|
|
125
|
+
packageVersion: lastRecord.payload.packageVersion,
|
|
126
|
+
}
|
|
127
|
+
: undefined,
|
|
128
|
+
recentFailures,
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Disk wrapper: read recent delivery events from the bus, then
|
|
135
|
+
* delegate to the pure aggregator.
|
|
136
|
+
*/
|
|
137
|
+
export function loadSubscriberStatus(opts: { limit?: number } = {}): SubscriberStatus[] {
|
|
138
|
+
const subscribers = loadSubscribers();
|
|
139
|
+
if (subscribers.length === 0) return [];
|
|
140
|
+
|
|
141
|
+
const limit = opts.limit ?? DEFAULT_HISTORY_LIMIT;
|
|
142
|
+
const bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
|
|
143
|
+
let records: DeliveryRecord[];
|
|
144
|
+
try {
|
|
145
|
+
const delivered = bus.recentEvents({ type: WEBHOOK_DELIVERED_EVENT, limit });
|
|
146
|
+
const failed = bus.recentEvents({ type: WEBHOOK_FAILED_EVENT, limit });
|
|
147
|
+
records = [...delivered, ...failed].map((e) => ({
|
|
148
|
+
type: e.type as DeliveryRecord['type'],
|
|
149
|
+
emittedAt: e.emittedAt,
|
|
150
|
+
payload: e.payload as WebhookDeliveryPayload,
|
|
151
|
+
}));
|
|
152
|
+
} finally {
|
|
153
|
+
bus.close();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return aggregateSubscriberStatus(subscribers, records);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Render a SubscriberStatus list as multi-line text for the CLI.
|
|
161
|
+
* Pure — operates on the data the aggregator produces.
|
|
162
|
+
*/
|
|
163
|
+
export function formatStatus(items: SubscriberStatus[], now: number = Date.now()): string {
|
|
164
|
+
if (items.length === 0) {
|
|
165
|
+
return 'No subscribers configured.';
|
|
166
|
+
}
|
|
167
|
+
const lines: string[] = [];
|
|
168
|
+
for (const s of items) {
|
|
169
|
+
lines.push(` ${s.label}`);
|
|
170
|
+
lines.push(` url: ${s.url}`);
|
|
171
|
+
const matchParts: string[] = [];
|
|
172
|
+
if (s.match.registry) matchParts.push(`registry=${s.match.registry}`);
|
|
173
|
+
if (s.match.tag) matchParts.push(`tag=${s.match.tag}`);
|
|
174
|
+
if (s.match.packagePattern) matchParts.push(`pkg=${s.match.packagePattern}`);
|
|
175
|
+
lines.push(` match: ${matchParts.length > 0 ? matchParts.join(', ') : '(any event)'}`);
|
|
176
|
+
if (s.successRatePct === null) {
|
|
177
|
+
lines.push(' deliveries: none yet');
|
|
178
|
+
} else {
|
|
179
|
+
lines.push(
|
|
180
|
+
` deliveries: ${s.totals.delivered}/${s.totals.delivered + s.totals.failed} ok (${s.successRatePct}%)`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (s.lastDelivery) {
|
|
184
|
+
const ago = describeAgo(now - s.lastDelivery.emittedAt);
|
|
185
|
+
const marker = s.lastDelivery.ok ? '✓' : '✗';
|
|
186
|
+
lines.push(
|
|
187
|
+
` last delivery: ${marker} ${s.lastDelivery.packageName}@${s.lastDelivery.packageVersion} (${ago})`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (s.recentFailures.length > 0) {
|
|
191
|
+
lines.push(` recent failures (${s.recentFailures.length}):`);
|
|
192
|
+
for (const f of s.recentFailures) {
|
|
193
|
+
lines.push(
|
|
194
|
+
` ${describeAgo(now - f.emittedAt)} — ${f.packageName}@${f.packageVersion}: ${f.error}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
lines.push('');
|
|
199
|
+
}
|
|
200
|
+
return lines.join('\n').trimEnd();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Render a millisecond delta as a human-readable "Xm ago" /
|
|
205
|
+
* "Xh ago" / "Xd ago". Pure.
|
|
206
|
+
*/
|
|
207
|
+
export function describeAgo(deltaMs: number): string {
|
|
208
|
+
if (deltaMs < 0) return 'in the future';
|
|
209
|
+
if (deltaMs < 60_000) return `${Math.floor(deltaMs / 1000)}s ago`;
|
|
210
|
+
if (deltaMs < 60 * 60_000) return `${Math.floor(deltaMs / 60_000)}m ago`;
|
|
211
|
+
if (deltaMs < 24 * 60 * 60_000) return `${Math.floor(deltaMs / (60 * 60_000))}h ago`;
|
|
212
|
+
return `${Math.floor(deltaMs / (24 * 60 * 60_000))}d ago`;
|
|
213
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static-config subscriber store for the build bus
|
|
3
|
+
* ([[v2/BUILD_BUS.md]] Phase 2-lite).
|
|
4
|
+
*
|
|
5
|
+
* The first cut of cross-machine event distribution skips the
|
|
6
|
+
* registry-server switchboard described in §6 of the spec — instead
|
|
7
|
+
* the operator maintains a JSON file under their celilo data dir
|
|
8
|
+
* that lists each subscriber URL + secret + match rule. This file
|
|
9
|
+
* is the single source of truth for publishes that originate on
|
|
10
|
+
* this machine.
|
|
11
|
+
*
|
|
12
|
+
* File layout (default `~/Library/Application Support/celilo/build-bus-subscribers.json`
|
|
13
|
+
* on macOS, similar XDG path on Linux — see config/paths.ts):
|
|
14
|
+
*
|
|
15
|
+
* {
|
|
16
|
+
* "subscribers": [
|
|
17
|
+
* {
|
|
18
|
+
* "name": "lunacycle build box",
|
|
19
|
+
* "url": "https://lunacycle.lab/build-bus",
|
|
20
|
+
* "secret": "<hmac-secret>",
|
|
21
|
+
* "match": { "registry": "npm", "packagePattern": "@celilo/*" }
|
|
22
|
+
* }
|
|
23
|
+
* ]
|
|
24
|
+
* }
|
|
25
|
+
*
|
|
26
|
+
* Override the file path with the CELILO_BUILD_BUS_SUBSCRIBERS_PATH
|
|
27
|
+
* env var (used by integration tests).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
31
|
+
import { dirname, join } from 'node:path';
|
|
32
|
+
import type { Subscriber } from '@celilo/event-bus/build-bus';
|
|
33
|
+
import { getDataDir } from '../../config/paths';
|
|
34
|
+
|
|
35
|
+
const FILE_NAME = 'build-bus-subscribers.json';
|
|
36
|
+
|
|
37
|
+
interface StoreFile {
|
|
38
|
+
subscribers: Subscriber[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the subscriber-store path. Respects
|
|
43
|
+
* CELILO_BUILD_BUS_SUBSCRIBERS_PATH for testability; otherwise
|
|
44
|
+
* lives alongside the rest of celilo's per-machine state under
|
|
45
|
+
* getDataDir().
|
|
46
|
+
*/
|
|
47
|
+
export function subscriberStorePath(): string {
|
|
48
|
+
return process.env.CELILO_BUILD_BUS_SUBSCRIBERS_PATH ?? join(getDataDir(), FILE_NAME);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Load the subscriber list. Missing file → empty list (treat as
|
|
53
|
+
* "no subscribers configured"; this is the silent-no-op path that
|
|
54
|
+
* keeps the build-bus opt-in). Malformed JSON throws so the operator
|
|
55
|
+
* notices.
|
|
56
|
+
*/
|
|
57
|
+
export function loadSubscribers(): Subscriber[] {
|
|
58
|
+
const path = subscriberStorePath();
|
|
59
|
+
if (!existsSync(path)) return [];
|
|
60
|
+
const content = readFileSync(path, 'utf-8');
|
|
61
|
+
let parsed: StoreFile;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(content) as StoreFile;
|
|
64
|
+
} catch (err) {
|
|
65
|
+
throw new Error(`Could not parse ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
66
|
+
}
|
|
67
|
+
if (!Array.isArray(parsed.subscribers)) {
|
|
68
|
+
throw new Error(`${path} has no "subscribers" array at the top level.`);
|
|
69
|
+
}
|
|
70
|
+
return parsed.subscribers;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Replace the subscriber list. Creates the parent dir if needed,
|
|
75
|
+
* writes atomically (temp file + rename) so a failed write doesn't
|
|
76
|
+
* leave a corrupt store.
|
|
77
|
+
*/
|
|
78
|
+
export function saveSubscribers(subscribers: Subscriber[]): void {
|
|
79
|
+
const path = subscriberStorePath();
|
|
80
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
81
|
+
const tmp = `${path}.tmp`;
|
|
82
|
+
writeFileSync(tmp, `${JSON.stringify({ subscribers }, null, 2)}\n`);
|
|
83
|
+
// Bun supports rename via the standard Node fs API; this fails fast
|
|
84
|
+
// on EXDEV but the temp + path live in the same dir so cross-FS
|
|
85
|
+
// isn't a concern.
|
|
86
|
+
const { renameSync } = require('node:fs') as typeof import('node:fs');
|
|
87
|
+
renameSync(tmp, path);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Add a subscriber. If one with the same URL exists, replace it
|
|
92
|
+
* (idempotent re-adds with updated secret/match are common when an
|
|
93
|
+
* operator rotates credentials). Returns the prior subscriber, if any.
|
|
94
|
+
*/
|
|
95
|
+
export function addSubscriber(subscriber: Subscriber): { replaced?: Subscriber } {
|
|
96
|
+
const existing = loadSubscribers();
|
|
97
|
+
const prior = existing.find((s) => s.url === subscriber.url);
|
|
98
|
+
const next = existing.filter((s) => s.url !== subscriber.url);
|
|
99
|
+
next.push(subscriber);
|
|
100
|
+
saveSubscribers(next);
|
|
101
|
+
return prior ? { replaced: prior } : {};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Remove a subscriber by URL. Returns the removed entry, if any.
|
|
106
|
+
*/
|
|
107
|
+
export function removeSubscriberByUrl(url: string): Subscriber | undefined {
|
|
108
|
+
const existing = loadSubscribers();
|
|
109
|
+
const prior = existing.find((s) => s.url === url);
|
|
110
|
+
if (!prior) return undefined;
|
|
111
|
+
saveSubscribers(existing.filter((s) => s.url !== url));
|
|
112
|
+
return prior;
|
|
113
|
+
}
|
|
@@ -8,6 +8,11 @@ import {
|
|
|
8
8
|
emitDeployFailed,
|
|
9
9
|
emitDeployStarted,
|
|
10
10
|
emitHealthCheckFailed,
|
|
11
|
+
emitSystemCreated,
|
|
12
|
+
emitSystemDestroyed,
|
|
13
|
+
emitUninstallCompleted,
|
|
14
|
+
emitUninstallFailed,
|
|
15
|
+
emitUninstallStarted,
|
|
11
16
|
} from './celilo-events';
|
|
12
17
|
|
|
13
18
|
describe('celilo lifecycle events', () => {
|
|
@@ -70,6 +75,71 @@ describe('celilo lifecycle events', () => {
|
|
|
70
75
|
expect(events[0].type).toBe('health-check.failed.lunacycle');
|
|
71
76
|
});
|
|
72
77
|
|
|
78
|
+
it('emits uninstall.started.<module>', () => {
|
|
79
|
+
emitUninstallStarted({ module: 'caddy', startedAt: 2000 });
|
|
80
|
+
const events = recentEvents();
|
|
81
|
+
expect(events).toHaveLength(1);
|
|
82
|
+
expect(events[0].type).toBe('uninstall.started.caddy');
|
|
83
|
+
expect(events[0].payload).toEqual({ module: 'caddy', startedAt: 2000 });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('emits uninstall.completed.<module> with duration', () => {
|
|
87
|
+
emitUninstallCompleted({ module: 'caddy', startedAt: 2000, durationMs: 1500 });
|
|
88
|
+
const events = recentEvents();
|
|
89
|
+
expect(events[0].type).toBe('uninstall.completed.caddy');
|
|
90
|
+
expect((events[0].payload as { durationMs: number }).durationMs).toBe(1500);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('emits uninstall.failed.<module> with error', () => {
|
|
94
|
+
emitUninstallFailed({
|
|
95
|
+
module: 'caddy',
|
|
96
|
+
startedAt: 2000,
|
|
97
|
+
durationMs: 300,
|
|
98
|
+
error: 'terraform destroy failed',
|
|
99
|
+
});
|
|
100
|
+
const events = recentEvents();
|
|
101
|
+
expect(events[0].type).toBe('uninstall.failed.caddy');
|
|
102
|
+
expect((events[0].payload as { error: string }).error).toBe('terraform destroy failed');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('emits system.created.<module> with host identity', () => {
|
|
106
|
+
emitSystemCreated({ module: 'technitium', hostname: 'dns-int', targetIp: '192.168.0.53' });
|
|
107
|
+
const events = recentEvents();
|
|
108
|
+
expect(events).toHaveLength(1);
|
|
109
|
+
expect(events[0].type).toBe('system.created.technitium');
|
|
110
|
+
expect(events[0].payload).toEqual({
|
|
111
|
+
module: 'technitium',
|
|
112
|
+
hostname: 'dns-int',
|
|
113
|
+
targetIp: '192.168.0.53',
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('emits system.destroyed.<module> with host identity', () => {
|
|
118
|
+
emitSystemDestroyed({ module: 'caddy', hostname: 'www', targetIp: '10.0.10.10' });
|
|
119
|
+
const events = recentEvents();
|
|
120
|
+
expect(events[0].type).toBe('system.destroyed.caddy');
|
|
121
|
+
expect((events[0].payload as { hostname: string }).hostname).toBe('www');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('system.created.* subscriber receives a delivery (the D5 fan-out path)', () => {
|
|
125
|
+
const setupBus = openBus({ dbPath, events: defineEvents({}) });
|
|
126
|
+
setupBus.subscribe({
|
|
127
|
+
name: 'dns-internal.auto-register',
|
|
128
|
+
pattern: 'system.created.*',
|
|
129
|
+
handler: 'unused',
|
|
130
|
+
});
|
|
131
|
+
setupBus.close();
|
|
132
|
+
|
|
133
|
+
emitSystemCreated({ module: 'lunacycle', hostname: 'lunacycle', targetIp: '10.0.20.11' });
|
|
134
|
+
|
|
135
|
+
const bus = openBus({ dbPath, events: defineEvents({}) });
|
|
136
|
+
try {
|
|
137
|
+
expect(bus.pendingDeliveries()).toHaveLength(1);
|
|
138
|
+
} finally {
|
|
139
|
+
bus.close();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
73
143
|
it('triggers persistent subscribers and creates deliveries', () => {
|
|
74
144
|
// Pre-register a subscriber that targets all completed deploys.
|
|
75
145
|
const setupBus = openBus({ dbPath, events: defineEvents({}) });
|
|
@@ -41,6 +41,42 @@ export interface HealthCheckFailedPayload {
|
|
|
41
41
|
reason: string;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
export interface UninstallStartedPayload {
|
|
45
|
+
module: string;
|
|
46
|
+
startedAt: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface UninstallCompletedPayload {
|
|
50
|
+
module: string;
|
|
51
|
+
startedAt: number;
|
|
52
|
+
durationMs: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface UninstallFailedPayload {
|
|
56
|
+
module: string;
|
|
57
|
+
startedAt: number;
|
|
58
|
+
durationMs: number;
|
|
59
|
+
error: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A deployed system (a machine or container-service instance hosting a
|
|
64
|
+
* module) has come up / been torn down. Carries the system's identity so a
|
|
65
|
+
* subscriber (e.g. a dns_internal provider) can register/deregister internal
|
|
66
|
+
* DNS records without re-querying the deploying module's config.
|
|
67
|
+
* v2/INTERNAL_DNS_DHCP_AND_SPLIT_HORIZON.md D5.
|
|
68
|
+
*/
|
|
69
|
+
export interface SystemCreatedPayload {
|
|
70
|
+
/** The module whose deploy produced this system (lets handlers resolve zones, etc.). */
|
|
71
|
+
module: string;
|
|
72
|
+
/** Bare hostname (e.g. "dns-int"), no domain. */
|
|
73
|
+
hostname: string;
|
|
74
|
+
/** IPv4 address, CIDR stripped (e.g. "192.168.0.53"). */
|
|
75
|
+
targetIp: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type SystemDestroyedPayload = SystemCreatedPayload;
|
|
79
|
+
|
|
44
80
|
const DeployStartedSchema = z.object({
|
|
45
81
|
module: z.string().min(1),
|
|
46
82
|
startedAt: z.number().int().nonnegative(),
|
|
@@ -64,6 +100,32 @@ const HealthCheckFailedSchema = z.object({
|
|
|
64
100
|
reason: z.string(),
|
|
65
101
|
});
|
|
66
102
|
|
|
103
|
+
const UninstallStartedSchema = z.object({
|
|
104
|
+
module: z.string().min(1),
|
|
105
|
+
startedAt: z.number().int().nonnegative(),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const UninstallCompletedSchema = z.object({
|
|
109
|
+
module: z.string().min(1),
|
|
110
|
+
startedAt: z.number().int().nonnegative(),
|
|
111
|
+
durationMs: z.number().int().nonnegative(),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const UninstallFailedSchema = z.object({
|
|
115
|
+
module: z.string().min(1),
|
|
116
|
+
startedAt: z.number().int().nonnegative(),
|
|
117
|
+
durationMs: z.number().int().nonnegative(),
|
|
118
|
+
error: z.string(),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const SystemCreatedSchema = z.object({
|
|
122
|
+
module: z.string().min(1),
|
|
123
|
+
hostname: z.string().min(1),
|
|
124
|
+
targetIp: z.string().min(1),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const SystemDestroyedSchema = SystemCreatedSchema;
|
|
128
|
+
|
|
67
129
|
export function emitDeployStarted(payload: DeployStartedPayload): void {
|
|
68
130
|
DeployStartedSchema.parse(payload);
|
|
69
131
|
emitBest(`deploy.started.${payload.module}`, payload);
|
|
@@ -84,6 +146,36 @@ export function emitHealthCheckFailed(payload: HealthCheckFailedPayload): void {
|
|
|
84
146
|
emitBest(`health-check.failed.${payload.module}`, payload);
|
|
85
147
|
}
|
|
86
148
|
|
|
149
|
+
export function emitUninstallStarted(payload: UninstallStartedPayload): void {
|
|
150
|
+
UninstallStartedSchema.parse(payload);
|
|
151
|
+
emitBest(`uninstall.started.${payload.module}`, payload);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function emitUninstallCompleted(payload: UninstallCompletedPayload): void {
|
|
155
|
+
UninstallCompletedSchema.parse(payload);
|
|
156
|
+
emitBest(`uninstall.completed.${payload.module}`, payload);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function emitUninstallFailed(payload: UninstallFailedPayload): void {
|
|
160
|
+
UninstallFailedSchema.parse(payload);
|
|
161
|
+
emitBest(`uninstall.failed.${payload.module}`, payload);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Emit `system.created.<module>` (D5). Last segment is the module id so
|
|
166
|
+
* subscribers can fan out (`system.created.*`) or target one.
|
|
167
|
+
*/
|
|
168
|
+
export function emitSystemCreated(payload: SystemCreatedPayload): void {
|
|
169
|
+
SystemCreatedSchema.parse(payload);
|
|
170
|
+
emitBest(`system.created.${payload.module}`, payload);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Emit `system.destroyed.<module>` (D5). */
|
|
174
|
+
export function emitSystemDestroyed(payload: SystemDestroyedPayload): void {
|
|
175
|
+
SystemDestroyedSchema.parse(payload);
|
|
176
|
+
emitBest(`system.destroyed.${payload.module}`, payload);
|
|
177
|
+
}
|
|
178
|
+
|
|
87
179
|
/**
|
|
88
180
|
* Open the bus, emit, close. Errors are caught and logged so a
|
|
89
181
|
* misbehaving bus never wedges the caller. The empty-registry mode
|