@celilo/cli 4.0.0 → 5.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/AGENTS.md +2 -2
- package/CELILO_SUBSYSTEMS.md +1 -0
- package/drizzle/0034_private_routes.sql +41 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +5 -4
- package/src/api/sessions.test.ts +1 -1
- package/src/capabilities/registration.test.ts +11 -11
- package/src/capabilities/secrets.test.ts +18 -18
- package/src/capabilities/validation.test.ts +24 -24
- package/src/cli/cli.test.ts +3 -3
- package/src/cli/command-tree-parser.test.ts +18 -0
- package/src/cli/command-tree-parser.ts +12 -0
- package/src/cli/commands/api.ts +67 -14
- package/src/cli/commands/events.ts +5 -2
- package/src/cli/commands/machine-reclassify.ts +79 -0
- package/src/cli/commands/module-changeset.test.ts +1 -1
- package/src/cli/commands/module-generate.test.ts +2 -2
- package/src/cli/commands/module-import-aspect.test.ts +2 -2
- package/src/cli/commands/module-update.test.ts +14 -14
- package/src/cli/commands/module-upgrade-gate.test.ts +26 -1
- package/src/cli/commands/module-upgrade.test.ts +3 -3
- package/src/cli/commands/module-upgrade.ts +12 -0
- package/src/cli/commands/module-version.test.ts +1 -1
- package/src/cli/commands/system-audit.ts +22 -0
- package/src/cli/commands/system-reboot.ts +126 -0
- package/src/cli/commands/system-update.ts +5 -0
- package/src/cli/completion.ts +3 -2
- package/src/cli/index.ts +41 -2
- package/src/cli/restore-migration-failure.test.ts +1 -1
- package/src/cli/stdout-pipe-flush.test.ts +35 -0
- package/src/cli/tui/audit-state.ts +4 -0
- package/src/db/private-routes.test.ts +159 -0
- package/src/db/schema.ts +49 -0
- package/src/hooks/broker.ts +31 -0
- package/src/hooks/capability-loader.ts +185 -12
- package/src/hooks/executor.test.ts +13 -13
- package/src/hooks/executor.ts +20 -2
- package/src/hooks/hook-state-dir.test.ts +2 -2
- package/src/hooks/test-fixtures/slow-capability-hook.ts +28 -0
- package/src/hooks/test-fixtures/store-backed.ts +5 -3
- package/src/hooks/timeout-names-provider.test.ts +87 -0
- package/src/manifest/contracts/index.ts +28 -7
- package/src/manifest/contracts/v2.ts +32 -0
- package/src/manifest/icon-schema.test.ts +1 -1
- package/src/manifest/schema.ts +13 -2
- package/src/manifest/template-validator.test.ts +1 -1
- package/src/manifest/validate-privileged.test.ts +1 -1
- package/src/manifest/validate.test.ts +68 -29
- package/src/manifest/validate.ts +14 -1
- package/src/module/import.test.ts +7 -7
- package/src/module/packaging/build.test.ts +1 -1
- package/src/packaging/nfpm-depends.test.ts +42 -0
- package/src/policy/capability-shape-baseline.ts +7 -1
- package/src/policy/fixture-capability-coverage.test.ts +5 -5
- package/src/policy/module-business-baseline.ts +33 -4
- package/src/services/api-access.test.ts +44 -0
- package/src/services/aspect-approvals.test.ts +2 -2
- package/src/services/aspect-reconcile.test.ts +2 -2
- package/src/services/aspect-runner.test.ts +4 -4
- package/src/services/audit/backups.test.ts +1 -1
- package/src/services/audit/capability-abi.test.ts +1 -1
- package/src/services/audit/index.test.ts +7 -0
- package/src/services/audit/index.ts +15 -0
- package/src/services/audit/machine-interface-zones.test.ts +30 -0
- package/src/services/audit/machine-interface-zones.ts +43 -0
- package/src/services/audit/module-configs.test.ts +1 -1
- package/src/services/audit/reboot-pending.test.ts +63 -0
- package/src/services/audit/reboot-pending.ts +85 -0
- package/src/services/audit/recurrence-gate.test.ts +21 -0
- package/src/services/audit/server-bun-pin.test.ts +44 -0
- package/src/services/audit/server-bun-pin.ts +95 -0
- package/src/services/audit/types.ts +2 -0
- package/src/services/build-bus/hook-dispatch-executor.test.ts +3 -3
- package/src/services/bus-interview-park.test.ts +1 -1
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/capability-compat.test.ts +22 -0
- package/src/services/capability-compat.ts +15 -2
- package/src/services/capability-table-rows.test.ts +20 -2
- package/src/services/celilo-events.test.ts +119 -6
- package/src/services/celilo-events.ts +156 -16
- package/src/services/celilo-mgmt-hooks.test.ts +6 -3
- package/src/services/cross-module-read.test.ts +2 -2
- package/src/services/deploy-preflight.ts +12 -3
- package/src/services/deploy-validation.test.ts +86 -7
- package/src/services/deploy-validation.ts +19 -0
- package/src/services/dns-provider-backfill.test.ts +2 -2
- package/src/services/dns-registrations.test.ts +1 -1
- package/src/services/fleet-checks.test.ts +2 -2
- package/src/services/fleet-checks.ts +40 -10
- package/src/services/health-runner.test.ts +1 -1
- package/src/services/infrastructure-variable-resolver.test.ts +11 -11
- package/src/services/instance-ops.test.ts +2 -2
- package/src/services/machine-interface-zones.test.ts +73 -0
- package/src/services/machine-interface-zones.ts +101 -0
- package/src/services/module-deploy.dns-repoint.test.ts +1 -1
- package/src/services/module-deploy.stale-provider-guard.test.ts +98 -0
- package/src/services/module-deploy.ts +31 -10
- package/src/services/module-pause-quiescence.test.ts +1 -1
- package/src/services/module-pause.test.ts +1 -1
- package/src/services/module-subscriptions.test.ts +2 -2
- package/src/services/module-types-generator.test.ts +1 -1
- package/src/services/module-validator/contract-version.test.ts +3 -3
- package/src/services/network-ensure.test.ts +1 -1
- package/src/services/provider-arrival.test.ts +62 -46
- package/src/services/provider-arrival.ts +41 -10
- package/src/services/proxmox-reconcile.test.ts +2 -2
- package/src/services/remove-guard.test.ts +1 -1
- package/src/services/system-reboot.test.ts +231 -0
- package/src/services/system-reboot.ts +357 -0
- package/src/services/update/dep-graph.test.ts +1 -1
- package/src/services/update/orchestrator.test.ts +165 -1
- package/src/services/update/orchestrator.ts +43 -12
- package/src/services/update/types.ts +10 -1
- package/src/templates/generator.test.ts +15 -4
- package/src/templates/generator.ts +12 -4
- package/src/variables/capability-self-ref.test.ts +2 -2
- package/src/variables/computed/computed-integration.test.ts +1 -1
- package/src/variables/context.ts +14 -1
- package/src/variables/declarative-derivation.test.ts +19 -19
|
@@ -121,7 +121,7 @@ describe('the dispatcher dispatches on_upstream_publish through the ordinary exe
|
|
|
121
121
|
id: 'upstream-fixture',
|
|
122
122
|
name: 'upstream-fixture',
|
|
123
123
|
version: '1.0.0',
|
|
124
|
-
celilo_contract: '
|
|
124
|
+
celilo_contract: '2.0',
|
|
125
125
|
provides: { capabilities: [] },
|
|
126
126
|
requires: { capabilities: [] },
|
|
127
127
|
hooks: { on_upstream_publish: [hookEntry] },
|
|
@@ -178,7 +178,7 @@ describe('the dispatcher dispatches on_upstream_publish through the ordinary exe
|
|
|
178
178
|
id: 'upstream-fixture',
|
|
179
179
|
name: 'upstream-fixture',
|
|
180
180
|
version: '1.0.0',
|
|
181
|
-
celilo_contract: '
|
|
181
|
+
celilo_contract: '2.0',
|
|
182
182
|
provides: { capabilities: [] },
|
|
183
183
|
requires: { capabilities: [] },
|
|
184
184
|
hooks: { on_upstream_publish: [hookEntry] },
|
|
@@ -232,7 +232,7 @@ test('a failing hook run lands in the build_bus_hook_runs ledger', async () => {
|
|
|
232
232
|
id: 'upstream-fixture',
|
|
233
233
|
name: 'upstream-fixture',
|
|
234
234
|
version: '1.0.0',
|
|
235
|
-
celilo_contract: '
|
|
235
|
+
celilo_contract: '2.0',
|
|
236
236
|
provides: { capabilities: [] },
|
|
237
237
|
requires: { capabilities: [] },
|
|
238
238
|
hooks: { on_upstream_publish: [hookEntry] },
|
|
@@ -71,7 +71,7 @@ beforeEach(() => {
|
|
|
71
71
|
name: 'iptables',
|
|
72
72
|
sourcePath: join(dir, 'installed'),
|
|
73
73
|
version: '1.0.2+9',
|
|
74
|
-
manifestData: { celilo_contract: '
|
|
74
|
+
manifestData: { celilo_contract: '2.0', id: 'iptables', name: 'iptables', version: '1.0.2' },
|
|
75
75
|
})
|
|
76
76
|
.run();
|
|
77
77
|
|
|
@@ -24,6 +24,28 @@ function provider(
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
describe('unservedCapabilityRequirements', () => {
|
|
27
|
+
/**
|
|
28
|
+
* celilo#1384. Optional in PRESENCE, not in contract: with no provider the
|
|
29
|
+
* module is fine, but an installed provider on another major is called by
|
|
30
|
+
* the module's hooks and fails there — as a warning, silently.
|
|
31
|
+
*/
|
|
32
|
+
test('an optional capability with no provider is not a blocker', () => {
|
|
33
|
+
expect(
|
|
34
|
+
unservedCapabilityRequirements([], [], [{ name: 'external_web', version: '1.0.0' }]),
|
|
35
|
+
).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('an optional capability whose installed provider is another major IS a blocker', () => {
|
|
39
|
+
const result = unservedCapabilityRequirements(
|
|
40
|
+
[],
|
|
41
|
+
[provider('external_web', 'generic-cpanel', '2.0.0')],
|
|
42
|
+
[{ name: 'external_web', version: '1.0.0' }],
|
|
43
|
+
);
|
|
44
|
+
expect(result).toHaveLength(1);
|
|
45
|
+
expect(result[0]?.capability).toBe('external_web');
|
|
46
|
+
expect(result[0]?.providerModuleId).toBe('generic-cpanel');
|
|
47
|
+
});
|
|
48
|
+
|
|
27
49
|
test('a requirement any deployed provider serves is not a blocker', () => {
|
|
28
50
|
const providers = [provider('public_web', 'caddy', '4.0.0')];
|
|
29
51
|
expect(
|
|
@@ -53,15 +53,27 @@ export interface UnservedCapability {
|
|
|
53
53
|
export function unservedCapabilityRequirements(
|
|
54
54
|
requirements: CapabilityRequirement[] | undefined,
|
|
55
55
|
providers: DeployedCapabilityProvider[],
|
|
56
|
+
/**
|
|
57
|
+
* Capabilities the module uses only if present. Their provider may be
|
|
58
|
+
* absent, but an installed provider on another major breaks at runtime
|
|
59
|
+
* exactly as a required one would (celilo#1384), so they take the version
|
|
60
|
+
* check and skip the presence check.
|
|
61
|
+
*/
|
|
62
|
+
optional: CapabilityRequirement[] | undefined = undefined,
|
|
56
63
|
): UnservedCapability[] {
|
|
57
64
|
const unserved: UnservedCapability[] = [];
|
|
58
|
-
|
|
65
|
+
const consumed = [
|
|
66
|
+
...(requirements ?? []).map((cap) => ({ cap, required: true })),
|
|
67
|
+
...(optional ?? []).map((cap) => ({ cap, required: false })),
|
|
68
|
+
];
|
|
69
|
+
for (const { cap, required } of consumed) {
|
|
59
70
|
// Framework-granted privileges (e.g. cross_module_read) are not
|
|
60
71
|
// provider-backed — the same skip `deploy-preflight` makes.
|
|
61
72
|
if (isPrivilegedCapability(cap.name)) continue;
|
|
62
73
|
|
|
63
74
|
const installed = providers.filter((p) => p.capabilityName === cap.name);
|
|
64
75
|
if (installed.length === 0) {
|
|
76
|
+
if (!required) continue;
|
|
65
77
|
unserved.push({
|
|
66
78
|
capability: cap.name,
|
|
67
79
|
required: cap.version,
|
|
@@ -119,10 +131,11 @@ export function readDeployedProviders(db: DbClient): DeployedCapabilityProvider[
|
|
|
119
131
|
*/
|
|
120
132
|
export function capabilityBlockersForManifest(
|
|
121
133
|
db: DbClient,
|
|
122
|
-
manifest: Pick<ModuleManifest, 'requires' | 'id'>,
|
|
134
|
+
manifest: Pick<ModuleManifest, 'requires' | 'optional' | 'id'>,
|
|
123
135
|
): UnservedCapability[] {
|
|
124
136
|
return unservedCapabilityRequirements(
|
|
125
137
|
manifest.requires?.capabilities as CapabilityRequirement[] | undefined,
|
|
126
138
|
readDeployedProviders(db),
|
|
139
|
+
manifest.optional?.capabilities as CapabilityRequirement[] | undefined,
|
|
127
140
|
);
|
|
128
141
|
}
|
|
@@ -18,7 +18,14 @@ import { tmpdir } from 'node:os';
|
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { allDeclaredTables } from '@celilo/capabilities';
|
|
20
20
|
import type { DbClient } from '../db/client';
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
dnsInternalRecords,
|
|
23
|
+
modules,
|
|
24
|
+
portForwards,
|
|
25
|
+
privateRoutes,
|
|
26
|
+
trustedSources,
|
|
27
|
+
webRoutes,
|
|
28
|
+
} from '../db/schema';
|
|
22
29
|
import { setupTestDatabaseAt } from '../test-utils/database';
|
|
23
30
|
import { resetTestDbPath } from '../test-utils/db-path';
|
|
24
31
|
import { deleteClaimedRows, planClaimedRowDeletion } from './capability-table-rows';
|
|
@@ -89,6 +96,15 @@ describe('deleteClaimedRows', () => {
|
|
|
89
96
|
hostname,
|
|
90
97
|
})
|
|
91
98
|
.run();
|
|
99
|
+
db.insert(privateRoutes)
|
|
100
|
+
.values({
|
|
101
|
+
slug: `${consumer}-private-slug`,
|
|
102
|
+
moduleId: consumer,
|
|
103
|
+
type: 'reverse_proxy',
|
|
104
|
+
path: `/${consumer}`,
|
|
105
|
+
hostname: `private-${hostname}`,
|
|
106
|
+
})
|
|
107
|
+
.run();
|
|
92
108
|
db.insert(portForwards)
|
|
93
109
|
.values({
|
|
94
110
|
firewallIp: FW,
|
|
@@ -109,7 +125,7 @@ describe('deleteClaimedRows', () => {
|
|
|
109
125
|
.run();
|
|
110
126
|
}
|
|
111
127
|
|
|
112
|
-
it('clears the departing consumer from
|
|
128
|
+
it('clears the departing consumer from every declared table at once', () => {
|
|
113
129
|
seed('caddy', 'a.example.org', 443, '10.1.0.0/24');
|
|
114
130
|
|
|
115
131
|
const cleared = deleteClaimedRows(db, 'caddy');
|
|
@@ -117,10 +133,12 @@ describe('deleteClaimedRows', () => {
|
|
|
117
133
|
expect(cleared.map((c) => c.table).sort()).toEqual([
|
|
118
134
|
'dns_internal_records',
|
|
119
135
|
'port_forwards',
|
|
136
|
+
'private_routes',
|
|
120
137
|
'trusted_sources',
|
|
121
138
|
'web_routes',
|
|
122
139
|
]);
|
|
123
140
|
expect(db.select().from(webRoutes).all()).toHaveLength(0);
|
|
141
|
+
expect(db.select().from(privateRoutes).all()).toHaveLength(0);
|
|
124
142
|
expect(db.select().from(portForwards).all()).toHaveLength(0);
|
|
125
143
|
expect(db.select().from(trustedSources).all()).toHaveLength(0);
|
|
126
144
|
expect(db.select().from(dnsInternalRecords).all()).toHaveLength(0);
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
emitWebRoutesChanged,
|
|
17
17
|
emitWebRoutesChangedAndWait,
|
|
18
18
|
routesChangedHighWater,
|
|
19
|
+
subscriberBudgetMs,
|
|
19
20
|
waitForRouteReconcile,
|
|
20
21
|
} from './celilo-events';
|
|
21
22
|
|
|
@@ -171,12 +172,13 @@ describe('celilo lifecycle events', () => {
|
|
|
171
172
|
});
|
|
172
173
|
|
|
173
174
|
describe('deploy-waits for web-route reconcile (ISS-0035)', () => {
|
|
174
|
-
function subscribeReconciler(): void {
|
|
175
|
+
function subscribeReconciler(budget?: { maxAttempts: number; timeoutMs: number }): void {
|
|
175
176
|
const bus = openBus({ dbPath, events: defineEvents({}) });
|
|
176
177
|
bus.subscribe({
|
|
177
178
|
name: 'caddy.reconcile-web-routes',
|
|
178
179
|
pattern: 'public_web.routes_changed',
|
|
179
180
|
handler: 'unused',
|
|
181
|
+
...budget,
|
|
180
182
|
});
|
|
181
183
|
bus.close();
|
|
182
184
|
}
|
|
@@ -208,6 +210,30 @@ describe('celilo lifecycle events', () => {
|
|
|
208
210
|
bus.close();
|
|
209
211
|
}
|
|
210
212
|
|
|
213
|
+
function settleAllDeliveries(): void {
|
|
214
|
+
const bus = openBus({ dbPath, events: defineEvents({}) });
|
|
215
|
+
const sub = bus.getSubscriberByName('caddy.reconcile-web-routes');
|
|
216
|
+
if (!sub) throw new Error('expected a subscriber');
|
|
217
|
+
for (const event of bus.recentEvents({ type: 'public_web.routes_changed', limit: 100 })) {
|
|
218
|
+
bus.markSucceeded({ eventId: event.id, subscriberId: sub.id });
|
|
219
|
+
}
|
|
220
|
+
bus.close();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** One failed attempt that will be retried — the delivery stays pending. */
|
|
224
|
+
function recordRetryingFailure(message: string): void {
|
|
225
|
+
const bus = openBus({ dbPath, events: defineEvents({}) });
|
|
226
|
+
const sub = bus.getSubscriberByName('caddy.reconcile-web-routes');
|
|
227
|
+
const event = bus.recentEvents({ type: 'public_web.routes_changed', limit: 1 })[0];
|
|
228
|
+
if (!sub || !event) {
|
|
229
|
+
throw new Error('expected a subscriber and an emitted event');
|
|
230
|
+
}
|
|
231
|
+
bus.markFailed({ eventId: event.id, subscriberId: sub.id }, message, {
|
|
232
|
+
retryAfter: Date.now() + 60_000,
|
|
233
|
+
});
|
|
234
|
+
bus.close();
|
|
235
|
+
}
|
|
236
|
+
|
|
211
237
|
it('routesChangedHighWater is 0 with no events, then the latest id', () => {
|
|
212
238
|
expect(routesChangedHighWater()).toBe(0);
|
|
213
239
|
emitWebRoutesChanged('celilo-website');
|
|
@@ -217,11 +243,12 @@ describe('celilo lifecycle events', () => {
|
|
|
217
243
|
it('returns immediately when the deploy changed no routes', async () => {
|
|
218
244
|
const since = routesChangedHighWater();
|
|
219
245
|
const result = await waitForRouteReconcile(since, { timeoutMs: 1000 });
|
|
220
|
-
expect(result).
|
|
246
|
+
expect(result).toMatchObject({
|
|
221
247
|
events: 0,
|
|
222
248
|
succeeded: 0,
|
|
223
249
|
failed: 0,
|
|
224
250
|
timedOut: false,
|
|
251
|
+
lastError: null,
|
|
225
252
|
noDispatcher: false,
|
|
226
253
|
});
|
|
227
254
|
});
|
|
@@ -232,11 +259,12 @@ describe('celilo lifecycle events', () => {
|
|
|
232
259
|
emitWebRoutesChanged('celilo-website');
|
|
233
260
|
// No heartbeat stamped → health() is no_dispatcher.
|
|
234
261
|
const result = await waitForRouteReconcile(since, { timeoutMs: 5000, pollMs: 50 });
|
|
235
|
-
expect(result).
|
|
262
|
+
expect(result).toMatchObject({
|
|
236
263
|
events: 1,
|
|
237
264
|
succeeded: 0,
|
|
238
265
|
failed: 0,
|
|
239
266
|
timedOut: false,
|
|
267
|
+
lastError: null,
|
|
240
268
|
noDispatcher: true,
|
|
241
269
|
});
|
|
242
270
|
});
|
|
@@ -246,11 +274,12 @@ describe('celilo lifecycle events', () => {
|
|
|
246
274
|
const since = routesChangedHighWater();
|
|
247
275
|
emitWebRoutesChanged('celilo-website');
|
|
248
276
|
const result = await waitForRouteReconcile(since, { timeoutMs: 1000, pollMs: 50 });
|
|
249
|
-
expect(result).
|
|
277
|
+
expect(result).toMatchObject({
|
|
250
278
|
events: 1,
|
|
251
279
|
succeeded: 0,
|
|
252
280
|
failed: 0,
|
|
253
281
|
timedOut: false,
|
|
282
|
+
lastError: null,
|
|
254
283
|
noDispatcher: false,
|
|
255
284
|
});
|
|
256
285
|
});
|
|
@@ -265,6 +294,26 @@ describe('celilo lifecycle events', () => {
|
|
|
265
294
|
expect(result.timedOut).toBe(true);
|
|
266
295
|
});
|
|
267
296
|
|
|
297
|
+
/**
|
|
298
|
+
* celilo#1401. A handler that CRASHES is retried, so between attempts its
|
|
299
|
+
* delivery is `pending` with the error already recorded. The deadline hits
|
|
300
|
+
* in that window and the caller used to report a bare timeout — `0 ok, 0
|
|
301
|
+
* failed of 1` — which is what hid a caddy crash for days.
|
|
302
|
+
*/
|
|
303
|
+
it('carries the crashing handler error out of a timeout', async () => {
|
|
304
|
+
markDispatcherAlive();
|
|
305
|
+
subscribeReconciler();
|
|
306
|
+
const since = routesChangedHighWater();
|
|
307
|
+
emitWebRoutesChanged('celilo-website');
|
|
308
|
+
// Failed but NOT abandoned: it goes back to pending for the next attempt.
|
|
309
|
+
recordRetryingFailure('{} is not iterable');
|
|
310
|
+
|
|
311
|
+
const result = await waitForRouteReconcile(since, { timeoutMs: 300, pollMs: 50 });
|
|
312
|
+
expect(result.timedOut).toBe(true);
|
|
313
|
+
expect(result.failed).toBe(0);
|
|
314
|
+
expect(result.lastError).toContain('{} is not iterable');
|
|
315
|
+
});
|
|
316
|
+
|
|
268
317
|
it('completes once the provider delivery succeeds', async () => {
|
|
269
318
|
markDispatcherAlive();
|
|
270
319
|
subscribeReconciler();
|
|
@@ -273,11 +322,12 @@ describe('celilo lifecycle events', () => {
|
|
|
273
322
|
settleDelivery('succeed');
|
|
274
323
|
|
|
275
324
|
const result = await waitForRouteReconcile(since, { timeoutMs: 1000, pollMs: 50 });
|
|
276
|
-
expect(result).
|
|
325
|
+
expect(result).toMatchObject({
|
|
277
326
|
events: 1,
|
|
278
327
|
succeeded: 1,
|
|
279
328
|
failed: 0,
|
|
280
329
|
timedOut: false,
|
|
330
|
+
lastError: null,
|
|
281
331
|
noDispatcher: false,
|
|
282
332
|
});
|
|
283
333
|
});
|
|
@@ -295,6 +345,68 @@ describe('celilo lifecycle events', () => {
|
|
|
295
345
|
expect(result.timedOut).toBe(false);
|
|
296
346
|
});
|
|
297
347
|
|
|
348
|
+
/**
|
|
349
|
+
* celilo#1416. The waiter's deadline was the constant 90s while caddy's
|
|
350
|
+
* subscription is `timeout_ms: 90000, max_attempts: 3` — so the ENTIRE wait
|
|
351
|
+
* equalled ONE attempt and a successful retry could never be observed. The
|
|
352
|
+
* budget must come from the subscriber's own registration.
|
|
353
|
+
*/
|
|
354
|
+
it('budgets for the subscriber whole retry allowance, not one attempt', async () => {
|
|
355
|
+
markDispatcherAlive();
|
|
356
|
+
subscribeReconciler({ maxAttempts: 3, timeoutMs: 90_000 });
|
|
357
|
+
const since = routesChangedHighWater();
|
|
358
|
+
emitWebRoutesChanged('celilo-website');
|
|
359
|
+
settleDelivery('succeed');
|
|
360
|
+
|
|
361
|
+
const result = await waitForRouteReconcile(since, { pollMs: 50 });
|
|
362
|
+
expect(result.succeeded).toBe(1);
|
|
363
|
+
expect(result.budgetMs).toBeGreaterThanOrEqual(subscriberBudgetMs(3, 90_000));
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
/** A deploy emitting N change events waits for N of them, not for one. */
|
|
367
|
+
it('scales the budget with the number of change events', async () => {
|
|
368
|
+
markDispatcherAlive();
|
|
369
|
+
subscribeReconciler({ maxAttempts: 2, timeoutMs: 60_000 });
|
|
370
|
+
const since = routesChangedHighWater();
|
|
371
|
+
emitWebRoutesChanged('celilo-website');
|
|
372
|
+
emitWebRoutesChanged('celilo-website');
|
|
373
|
+
settleAllDeliveries();
|
|
374
|
+
|
|
375
|
+
const result = await waitForRouteReconcile(since, { pollMs: 50 });
|
|
376
|
+
expect(result.events).toBe(2);
|
|
377
|
+
expect(result.budgetMs).toBeGreaterThanOrEqual(2 * subscriberBudgetMs(2, 60_000));
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
/** The end-to-end shape: one failed attempt, then a success, reports SUCCESS. */
|
|
381
|
+
it('reports success when the provider fails once and then succeeds', async () => {
|
|
382
|
+
markDispatcherAlive();
|
|
383
|
+
subscribeReconciler({ maxAttempts: 3, timeoutMs: 400 });
|
|
384
|
+
const since = routesChangedHighWater();
|
|
385
|
+
emitWebRoutesChanged('celilo-website');
|
|
386
|
+
recordRetryingFailure('transient boom');
|
|
387
|
+
|
|
388
|
+
const pending = waitForRouteReconcile(since, { pollMs: 50 });
|
|
389
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
390
|
+
settleDelivery('succeed');
|
|
391
|
+
|
|
392
|
+
const result = await pending;
|
|
393
|
+
expect(result.timedOut).toBe(false);
|
|
394
|
+
expect(result.succeeded).toBe(1);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
/** An expiry with deliveries in flight is not "nobody consumed it". */
|
|
398
|
+
it('separates deliveries still running from settled failures on expiry', async () => {
|
|
399
|
+
markDispatcherAlive();
|
|
400
|
+
subscribeReconciler();
|
|
401
|
+
const since = routesChangedHighWater();
|
|
402
|
+
emitWebRoutesChanged('celilo-website');
|
|
403
|
+
|
|
404
|
+
const result = await waitForRouteReconcile(since, { timeoutMs: 300, pollMs: 50 });
|
|
405
|
+
expect(result.timedOut).toBe(true);
|
|
406
|
+
expect(result.stillRunning).toBe(1);
|
|
407
|
+
expect(result.failed).toBe(0);
|
|
408
|
+
});
|
|
409
|
+
|
|
298
410
|
it('emitWebRoutesChangedAndWait emits then waits for the reconcile to settle', async () => {
|
|
299
411
|
markDispatcherAlive();
|
|
300
412
|
subscribeReconciler();
|
|
@@ -308,11 +420,12 @@ describe('celilo lifecycle events', () => {
|
|
|
308
420
|
settleDelivery('succeed');
|
|
309
421
|
|
|
310
422
|
const result = await pending;
|
|
311
|
-
expect(result).
|
|
423
|
+
expect(result).toMatchObject({
|
|
312
424
|
events: 1,
|
|
313
425
|
succeeded: 1,
|
|
314
426
|
failed: 0,
|
|
315
427
|
timedOut: false,
|
|
428
|
+
lastError: null,
|
|
316
429
|
noDispatcher: false,
|
|
317
430
|
});
|
|
318
431
|
});
|
|
@@ -184,12 +184,36 @@ export function emitSystemDestroyed(payload: SystemDestroyedPayload): void {
|
|
|
184
184
|
* Best-effort — a failed emit never breaks register_route; the route is already
|
|
185
185
|
* persisted in web_routes and the provider's next deploy reconciles it anyway.
|
|
186
186
|
*/
|
|
187
|
-
export function emitWebRoutesChanged(
|
|
188
|
-
|
|
187
|
+
export function emitWebRoutesChanged(
|
|
188
|
+
triggeredBy: string,
|
|
189
|
+
type: RoutesChangedType = ROUTES_CHANGED_TYPE,
|
|
190
|
+
): void {
|
|
191
|
+
emitBest(type, { triggeredBy });
|
|
189
192
|
}
|
|
190
193
|
|
|
191
194
|
const ROUTES_CHANGED_TYPE = 'public_web.routes_changed';
|
|
192
195
|
|
|
196
|
+
/**
|
|
197
|
+
* The private ingress's twin of {@link ROUTES_CHANGED_TYPE}
|
|
198
|
+
* (`providers-converge-declared-state` slice 2). The two ingresses are
|
|
199
|
+
* deliberately SEPARATE event types rather than one shared signal: a private
|
|
200
|
+
* route lives in `private_routes`, a public one in `web_routes` (celilo#846),
|
|
201
|
+
* and waking the wrong provider would make it re-render from a table it does
|
|
202
|
+
* not serve.
|
|
203
|
+
*/
|
|
204
|
+
const PRIVATE_ROUTES_CHANGED_TYPE = 'private_web.routes_changed';
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Which ingress's route table changed. Every function below defaults to the
|
|
208
|
+
* public type, so existing callers are unaffected by construction.
|
|
209
|
+
*/
|
|
210
|
+
export type RoutesChangedType = typeof ROUTES_CHANGED_TYPE | typeof PRIVATE_ROUTES_CHANGED_TYPE;
|
|
211
|
+
|
|
212
|
+
/** Emit `private_web.routes_changed` — the private ingress's coarse signal. */
|
|
213
|
+
export function emitPrivateRoutesChanged(triggeredBy: string): void {
|
|
214
|
+
emitWebRoutesChanged(triggeredBy, PRIVATE_ROUTES_CHANGED_TYPE);
|
|
215
|
+
}
|
|
216
|
+
|
|
193
217
|
/**
|
|
194
218
|
* Emit `public_web.routes_changed` and block until the provider's reconcile
|
|
195
219
|
* delivery settles (ISS-0035). This is what `register_route` / `unregister_routes`
|
|
@@ -205,10 +229,30 @@ const ROUTES_CHANGED_TYPE = 'public_web.routes_changed';
|
|
|
205
229
|
export async function emitWebRoutesChangedAndWait(
|
|
206
230
|
triggeredBy: string,
|
|
207
231
|
opts?: { timeoutMs?: number; pollMs?: number },
|
|
232
|
+
type: RoutesChangedType = ROUTES_CHANGED_TYPE,
|
|
208
233
|
): Promise<RouteReconcileWaitResult> {
|
|
209
|
-
const since = routesChangedHighWater();
|
|
210
|
-
emitWebRoutesChanged(triggeredBy);
|
|
211
|
-
return waitForRouteReconcile(since, opts);
|
|
234
|
+
const since = routesChangedHighWater(type);
|
|
235
|
+
emitWebRoutesChanged(triggeredBy, type);
|
|
236
|
+
return waitForRouteReconcile(since, opts, type);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The private ingress's twin of {@link emitWebRoutesChangedAndWait}
|
|
241
|
+
* (`providers-converge-declared-state` slice 2, D7).
|
|
242
|
+
*
|
|
243
|
+
* `registerReverseProxy` awaits THIS, so it still returns only once the route
|
|
244
|
+
* is actually live — the synchronous guarantee the in-call reconcile used to
|
|
245
|
+
* give, now provider-agnostic and on one path with the deploy.
|
|
246
|
+
*
|
|
247
|
+
* ⚠️ Callers must honour `noDispatcher` (ISS-0081): it means the event was
|
|
248
|
+
* persisted but never DELIVERED, so the route is NOT live. Treating it as a
|
|
249
|
+
* benign zero is the silent-success shape this change exists to remove.
|
|
250
|
+
*/
|
|
251
|
+
export async function emitPrivateRoutesChangedAndWait(
|
|
252
|
+
triggeredBy: string,
|
|
253
|
+
opts?: { timeoutMs?: number; pollMs?: number },
|
|
254
|
+
): Promise<RouteReconcileWaitResult> {
|
|
255
|
+
return emitWebRoutesChangedAndWait(triggeredBy, opts, PRIVATE_ROUTES_CHANGED_TYPE);
|
|
212
256
|
}
|
|
213
257
|
|
|
214
258
|
/**
|
|
@@ -222,6 +266,17 @@ export interface RouteReconcileWaitResult {
|
|
|
222
266
|
succeeded: number;
|
|
223
267
|
failed: number;
|
|
224
268
|
timedOut: boolean;
|
|
269
|
+
/**
|
|
270
|
+
* The handler's own error text from the most recent settled-or-retrying
|
|
271
|
+
* delivery, or null when no delivery has reported one.
|
|
272
|
+
*
|
|
273
|
+
* A handler that CRASHES is retried, so for `max_attempts` retries its
|
|
274
|
+
* delivery is back in `pending` with an error already recorded. The deadline
|
|
275
|
+
* can hit in that window, and the caller then reported a timeout for what was
|
|
276
|
+
* a crash — `0 ok, 0 failed of 1` with the real cause nowhere in sight
|
|
277
|
+
* (celilo#1401, which hid a caddy crash for days behind a deadline message).
|
|
278
|
+
*/
|
|
279
|
+
lastError: string | null;
|
|
225
280
|
/**
|
|
226
281
|
* True when no event dispatcher was running, so the `routes_changed` event
|
|
227
282
|
* was persisted but never DELIVERED to the provider (caddy). The route is
|
|
@@ -229,6 +284,19 @@ export interface RouteReconcileWaitResult {
|
|
|
229
284
|
* failure, not a benign "0 deliveries" success.
|
|
230
285
|
*/
|
|
231
286
|
noDispatcher: boolean;
|
|
287
|
+
/**
|
|
288
|
+
* Deliveries still `pending`/`running` when the deadline hit. Non-zero means
|
|
289
|
+
* the provider had NOT given up — its retries were still in flight — which is
|
|
290
|
+
* a different thing from a failure and must not be reported as one
|
|
291
|
+
* (celilo#1416: "0 ok, 0 failed of 1" read as "nobody consumed it").
|
|
292
|
+
*/
|
|
293
|
+
stillRunning: number;
|
|
294
|
+
/**
|
|
295
|
+
* The deadline actually used, in ms. Derived from the subscribers' own retry
|
|
296
|
+
* budgets unless the caller passed `timeoutMs`, so the message can say what
|
|
297
|
+
* it waited for rather than quoting a constant.
|
|
298
|
+
*/
|
|
299
|
+
budgetMs: number;
|
|
232
300
|
}
|
|
233
301
|
|
|
234
302
|
/**
|
|
@@ -236,11 +304,11 @@ export interface RouteReconcileWaitResult {
|
|
|
236
304
|
* exist yet. A deploy captures this before it runs so {@link waitForRouteReconcile}
|
|
237
305
|
* can tell which route-change events the deploy itself produced.
|
|
238
306
|
*/
|
|
239
|
-
export function routesChangedHighWater(): number {
|
|
307
|
+
export function routesChangedHighWater(type: RoutesChangedType = ROUTES_CHANGED_TYPE): number {
|
|
240
308
|
let bus: ReturnType<typeof openBus> | undefined;
|
|
241
309
|
try {
|
|
242
310
|
bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
|
|
243
|
-
return bus.recentEvents({ type
|
|
311
|
+
return bus.recentEvents({ type, limit: 1 })[0]?.id ?? 0;
|
|
244
312
|
} catch (err) {
|
|
245
313
|
const msg = err instanceof Error ? err.message : String(err);
|
|
246
314
|
console.warn(`[celilo] failed to read routes-changed high-water: ${msg}`);
|
|
@@ -250,6 +318,27 @@ export function routesChangedHighWater(): number {
|
|
|
250
318
|
}
|
|
251
319
|
}
|
|
252
320
|
|
|
321
|
+
/**
|
|
322
|
+
* A subscriber's whole retry budget: every attempt's handler timeout plus the
|
|
323
|
+
* dispatcher's exponential backoff between them (1s, 2s, 4s..., capped at 5m —
|
|
324
|
+
* `backoffMs` in the event bus's dispatcher).
|
|
325
|
+
*
|
|
326
|
+
* Exported so the manifest gate that keeps consumer hook timeouts above this
|
|
327
|
+
* can compute the same number from the same formula rather than a second copy
|
|
328
|
+
* of it (celilo#1416).
|
|
329
|
+
*/
|
|
330
|
+
export function subscriberBudgetMs(maxAttempts: number, timeoutMs: number): number {
|
|
331
|
+
const attempts = Math.max(1, maxAttempts);
|
|
332
|
+
let budget = attempts * timeoutMs;
|
|
333
|
+
for (let attempt = 1; attempt < attempts; attempt++) {
|
|
334
|
+
budget += Math.min(1000 * 2 ** (attempt - 1), 5 * 60 * 1000);
|
|
335
|
+
}
|
|
336
|
+
return budget;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Slack over the derived budget, so the wait outlives the last attempt. */
|
|
340
|
+
const RECONCILE_BUDGET_SLACK_MS = 5_000;
|
|
341
|
+
|
|
253
342
|
function sleep(ms: number): Promise<void> {
|
|
254
343
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
255
344
|
}
|
|
@@ -273,10 +362,15 @@ function sleep(ms: number): Promise<void> {
|
|
|
273
362
|
export async function waitForRouteReconcile(
|
|
274
363
|
sinceEventId: number,
|
|
275
364
|
opts: { timeoutMs?: number; pollMs?: number } = {},
|
|
365
|
+
type: RoutesChangedType = ROUTES_CHANGED_TYPE,
|
|
276
366
|
): Promise<RouteReconcileWaitResult> {
|
|
277
|
-
const timeoutMs = opts.timeoutMs ?? 90_000;
|
|
278
367
|
const pollMs = opts.pollMs ?? 250;
|
|
279
|
-
|
|
368
|
+
// The deadline is DERIVED below, once we know which subscribers are on the
|
|
369
|
+
// hook and what retry budget each was registered with. A constant here is
|
|
370
|
+
// what celilo#1416 was: 90s, exactly ONE attempt of a subscriber configured
|
|
371
|
+
// for three, so a successful retry could never be observed.
|
|
372
|
+
let budgetMs = opts.timeoutMs ?? 0;
|
|
373
|
+
let deadline = opts.timeoutMs !== undefined ? Date.now() + opts.timeoutMs : 0;
|
|
280
374
|
|
|
281
375
|
let bus: ReturnType<typeof openBus> | undefined;
|
|
282
376
|
try {
|
|
@@ -285,24 +379,44 @@ export async function waitForRouteReconcile(
|
|
|
285
379
|
|
|
286
380
|
// The deploy's emits already happened, so the set of route-change events
|
|
287
381
|
// is fixed; only their deliveries transition as the dispatcher works.
|
|
288
|
-
const events = b
|
|
289
|
-
.recentEvents({ type: ROUTES_CHANGED_TYPE, limit: 100 })
|
|
290
|
-
.filter((e) => e.id > sinceEventId);
|
|
382
|
+
const events = b.recentEvents({ type, limit: 100 }).filter((e) => e.id > sinceEventId);
|
|
291
383
|
|
|
292
384
|
if (events.length === 0) {
|
|
293
|
-
return {
|
|
385
|
+
return {
|
|
386
|
+
events: 0,
|
|
387
|
+
succeeded: 0,
|
|
388
|
+
failed: 0,
|
|
389
|
+
timedOut: false,
|
|
390
|
+
lastError: null,
|
|
391
|
+
noDispatcher: false,
|
|
392
|
+
stillRunning: 0,
|
|
393
|
+
budgetMs,
|
|
394
|
+
};
|
|
294
395
|
}
|
|
295
396
|
|
|
296
|
-
|
|
397
|
+
// Confirmed, not instant: a dispatcher starved by the deploy's own load looks
|
|
398
|
+
// exactly like a dead one for a single sample, and abandoning the reconcile on
|
|
399
|
+
// that reading is celilo#1399 — it failed consumers' re-registration during the
|
|
400
|
+
// one operation that generates route changes. `lagging` waits like any healthy
|
|
401
|
+
// bus; the deadline below still bounds it.
|
|
402
|
+
const health = await b.healthConfirmed();
|
|
403
|
+
if (health.status === 'no_dispatcher' || health.status === 'wedged') {
|
|
404
|
+
const why =
|
|
405
|
+
health.status === 'wedged'
|
|
406
|
+
? `dispatcher heartbeat stale with nothing running (wedged, ${Math.round((health.lastHeartbeatAgeMs ?? 0) / 1000)}s)`
|
|
407
|
+
: 'no dispatcher running';
|
|
297
408
|
console.warn(
|
|
298
|
-
`[celilo] route-reconcile:
|
|
409
|
+
`[celilo] route-reconcile: ${why} — not waiting; ${events.length} change event(s) persisted, the provider will reconcile on its next run`,
|
|
299
410
|
);
|
|
300
411
|
return {
|
|
301
412
|
events: events.length,
|
|
302
413
|
succeeded: 0,
|
|
303
414
|
failed: 0,
|
|
304
415
|
timedOut: false,
|
|
416
|
+
lastError: null,
|
|
305
417
|
noDispatcher: true,
|
|
418
|
+
stillRunning: 0,
|
|
419
|
+
budgetMs,
|
|
306
420
|
};
|
|
307
421
|
}
|
|
308
422
|
|
|
@@ -313,13 +427,30 @@ export async function waitForRouteReconcile(
|
|
|
313
427
|
(d) => d.status === 'pending' || d.status === 'running',
|
|
314
428
|
).length;
|
|
315
429
|
|
|
430
|
+
if (deadline === 0) {
|
|
431
|
+
// Every subscriber gets its whole registered retry budget, and a deploy
|
|
432
|
+
// that emitted N change events is waiting on N of them — budgeting for
|
|
433
|
+
// one is how a two-event deploy charged both against a single 90s wall.
|
|
434
|
+
const subscriberIds = [...new Set(deliveries.map((d) => d.subscriberId))];
|
|
435
|
+
const worst = subscriberIds.reduce((max, id) => {
|
|
436
|
+
const sub = b.getSubscriber(id);
|
|
437
|
+
if (!sub) return max;
|
|
438
|
+
return Math.max(max, subscriberBudgetMs(sub.maxAttempts, sub.timeoutMs));
|
|
439
|
+
}, 0);
|
|
440
|
+
budgetMs = worst * events.length + RECONCILE_BUDGET_SLACK_MS;
|
|
441
|
+
deadline = Date.now() + budgetMs;
|
|
442
|
+
}
|
|
443
|
+
|
|
316
444
|
if (pending === 0 || Date.now() >= deadline) {
|
|
317
445
|
return {
|
|
318
446
|
events: events.length,
|
|
319
447
|
succeeded: settled('succeeded'),
|
|
320
448
|
failed: settled('failed') + settled('abandoned'),
|
|
321
449
|
timedOut: pending > 0,
|
|
450
|
+
lastError: deliveries.find((d) => d.lastError)?.lastError ?? null,
|
|
322
451
|
noDispatcher: false,
|
|
452
|
+
stillRunning: pending,
|
|
453
|
+
budgetMs,
|
|
323
454
|
};
|
|
324
455
|
}
|
|
325
456
|
|
|
@@ -328,7 +459,16 @@ export async function waitForRouteReconcile(
|
|
|
328
459
|
} catch (err) {
|
|
329
460
|
const msg = err instanceof Error ? err.message : String(err);
|
|
330
461
|
console.warn(`[celilo] route-reconcile wait errored: ${msg}`);
|
|
331
|
-
return {
|
|
462
|
+
return {
|
|
463
|
+
events: 0,
|
|
464
|
+
succeeded: 0,
|
|
465
|
+
failed: 0,
|
|
466
|
+
timedOut: false,
|
|
467
|
+
lastError: null,
|
|
468
|
+
noDispatcher: false,
|
|
469
|
+
stillRunning: 0,
|
|
470
|
+
budgetMs,
|
|
471
|
+
};
|
|
332
472
|
} finally {
|
|
333
473
|
bus?.close();
|
|
334
474
|
}
|