@celilo/cli 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +3 -1
- package/CELILO_SUBSYSTEMS.md +7 -1
- package/MODULE_PRIMITIVES.md +6 -1
- package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/capabilities/lookup.ts +39 -29
- package/src/capabilities/secret-ref.test.ts +24 -0
- package/src/capabilities/secret-validation.ts +50 -0
- package/src/capabilities/validation.test.ts +238 -2
- package/src/capabilities/validation.ts +67 -1
- package/src/cli/commands/alerts-sweep.ts +18 -0
- package/src/cli/commands/module-remove.ts +34 -2
- package/src/cli/commands/module-update.test.ts +149 -2
- package/src/cli/commands/module-update.ts +113 -25
- package/src/cli/commands/service-set-credentials.test.ts +108 -0
- package/src/cli/commands/service-set-credentials.ts +115 -0
- package/src/cli/commands/system-migrate.ts +6 -4
- package/src/cli/completion.ts +16 -1
- package/src/cli/index.ts +9 -0
- package/src/db/client.ts +10 -8
- package/src/db/dns-internal-cascade-migration.test.ts +184 -0
- package/src/db/migrate.test.ts +147 -0
- package/src/db/migrate.ts +69 -1
- package/src/db/schema.ts +21 -4
- package/src/hooks/capability-loader.test.ts +55 -0
- package/src/hooks/capability-loader.ts +16 -1
- package/src/manifest/template-validator.test.ts +47 -0
- package/src/manifest/template-validator.ts +18 -1
- package/src/module/import.ts +39 -6
- package/src/policy/capability-shape-baseline.ts +88 -0
- package/src/policy/capability-shape-drift.test.ts +162 -0
- package/src/policy/capability-shape.ts +117 -0
- package/src/policy/dns-aspect-coverage.test.ts +100 -0
- package/src/policy/module-business-baseline.ts +32 -18
- package/src/services/alerting/monitors.ts +54 -2
- package/src/services/alerting/sweep-runner.ts +38 -1
- package/src/services/capability-table-rows.test.ts +191 -0
- package/src/services/capability-table-rows.ts +103 -0
- package/src/services/consumer-cleanup.ts +18 -10
- package/src/services/container-service.test.ts +34 -0
- package/src/services/container-service.ts +44 -0
- package/src/services/deployed-systems.test.ts +101 -0
- package/src/services/deployed-systems.ts +43 -11
- package/src/services/dns-internal-records.test.ts +72 -1
- package/src/services/dns-provider-backfill.ts +30 -0
- package/src/services/fleet-checks.test.ts +26 -0
- package/src/services/fleet-checks.ts +11 -1
- package/src/services/module-deploy.ts +88 -41
- package/src/services/module-validator/capability-versions.test.ts +6 -1
- package/src/services/port-forwards.test.ts +6 -2
- package/src/services/port-forwards.ts +0 -11
- package/src/services/provider-arrival.test.ts +241 -0
- package/src/services/provider-arrival.ts +213 -0
- package/src/services/trusted-sources.ts +0 -5
- package/src/templates/generator.test.ts +35 -0
- package/src/templates/generator.ts +29 -1
- package/src/variables/context.test.ts +63 -0
- package/src/variables/context.ts +85 -12
- package/src/variables/declarative-derivation.test.ts +47 -8
- package/src/variables/declarative-derivation.ts +6 -4
- package/src/variables/lxc-nameserver.test.ts +144 -0
- package/src/services/public-web-republish.test.ts +0 -189
- package/src/services/public-web-republish.ts +0 -84
|
@@ -236,6 +236,35 @@ function asZone(value: string | null | undefined): NetworkZone | null {
|
|
|
236
236
|
: null;
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
+
/** True for an IPv4 loopback address, including CIDR-form input. */
|
|
240
|
+
function isLoopbackIpv4(value: string): boolean {
|
|
241
|
+
return stripCidr(value).startsWith('127.');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The machine pool uses 127.0.0.1 as a transport sentinel for the machine
|
|
246
|
+
* running celilo itself: it selects Ansible's local connection rather than
|
|
247
|
+
* SSH. That value is not the machine's network identity and must never leak
|
|
248
|
+
* into module_systems (and, downstream, internal DNS).
|
|
249
|
+
*
|
|
250
|
+
* For an ordinary machine, its primary address remains canonical. For the
|
|
251
|
+
* local sentinel, use the catalogued non-loopback interface in the system's
|
|
252
|
+
* resolved zone. Deliberately do not fall back to an arbitrary interface: a
|
|
253
|
+
* control plane can also have an upstream/Wi-Fi address, and publishing that
|
|
254
|
+
* would be a quieter version of the same identity bug.
|
|
255
|
+
*/
|
|
256
|
+
export function resolveMachineIdentityAddress(
|
|
257
|
+
machine: typeof machines.$inferSelect | undefined,
|
|
258
|
+
zone: NetworkZone,
|
|
259
|
+
): string | undefined {
|
|
260
|
+
if (!machine) return undefined;
|
|
261
|
+
if (!isLoopbackIpv4(machine.ipAddress)) return machine.ipAddress;
|
|
262
|
+
|
|
263
|
+
return machine.interfaces.find(
|
|
264
|
+
(iface) => asZone(iface.zone) === zone && !isLoopbackIpv4(iface.ipAddress),
|
|
265
|
+
)?.ipAddress;
|
|
266
|
+
}
|
|
267
|
+
|
|
239
268
|
/**
|
|
240
269
|
* Resolve a module's deployed system(s) from the deploy state and persist them
|
|
241
270
|
* to `module_systems`. Called during deploy after infrastructure variables are
|
|
@@ -265,14 +294,9 @@ export async function recordDeployedSystemForModule(
|
|
|
265
294
|
// No hostname → not yet addressable. A modeled state, not an error.
|
|
266
295
|
if (!hostname) return [];
|
|
267
296
|
|
|
268
|
-
// IP: the module's resolved target_ip, else ip.primary, else the assigned
|
|
269
|
-
// machine's own IP (machine-pool deploys where neither was written).
|
|
270
|
-
let ip = cfg('target_ip') ?? cfg('ip.primary');
|
|
271
297
|
const machine = infrastructure?.machineId
|
|
272
298
|
? db.select().from(machines).where(eq(machines.id, infrastructure.machineId)).get()
|
|
273
299
|
: undefined;
|
|
274
|
-
if (!ip) ip = machine?.ipAddress;
|
|
275
|
-
if (!ip) return [];
|
|
276
300
|
|
|
277
301
|
// Single-system transition: take the first declared system's name + zone.
|
|
278
302
|
const decl = declared[0];
|
|
@@ -289,6 +313,13 @@ export async function recordDeployedSystemForModule(
|
|
|
289
313
|
);
|
|
290
314
|
}
|
|
291
315
|
|
|
316
|
+
// IP: the module's resolved target_ip, else ip.primary, else the assigned
|
|
317
|
+
// machine's network identity. A local machine's 127.0.0.1 primary address is
|
|
318
|
+
// an execution-transport sentinel, so resolve its identity from the
|
|
319
|
+
// catalogued interface in the system's actual zone instead.
|
|
320
|
+
const ip = cfg('target_ip') ?? cfg('ip.primary') ?? resolveMachineIdentityAddress(machine, zone);
|
|
321
|
+
if (!ip) return [];
|
|
322
|
+
|
|
292
323
|
const infraType = infrastructure?.type ?? 'machine';
|
|
293
324
|
const vmidStr = cfg('vmid');
|
|
294
325
|
const vmid = vmidStr ? Number.parseInt(vmidStr, 10) : null;
|
|
@@ -352,15 +383,9 @@ export function backfillModuleSystems(db: DbClient): string[] {
|
|
|
352
383
|
const hostname = cfg('hostname');
|
|
353
384
|
if (!hostname) continue;
|
|
354
385
|
|
|
355
|
-
// IP: resolved target_ip, else ip.primary, else the assigned machine's own
|
|
356
|
-
// IP (machine-pool deploy where neither was written). Mirrors
|
|
357
|
-
// recordDeployedSystemForModule. CIDR is stripped by upsertDeployedSystem.
|
|
358
|
-
let ip = cfg('target_ip') ?? cfg('ip.primary');
|
|
359
386
|
const machine = infra.machineId
|
|
360
387
|
? db.select().from(machines).where(eq(machines.id, infra.machineId)).get()
|
|
361
388
|
: undefined;
|
|
362
|
-
if (!ip) ip = machine?.ipAddress;
|
|
363
|
-
if (!ip) continue;
|
|
364
389
|
|
|
365
390
|
const decl = declared[0];
|
|
366
391
|
// Same precedence as recordDeployedSystemForModule: the machine a deploy
|
|
@@ -373,6 +398,13 @@ export function backfillModuleSystems(db: DbClient): string[] {
|
|
|
373
398
|
// backfill (a re-deploy will record it properly).
|
|
374
399
|
if (!zone) continue;
|
|
375
400
|
|
|
401
|
+
// Mirrors recordDeployedSystemForModule. For celilo's local machine,
|
|
402
|
+
// 127.0.0.1 is only a local-transport sentinel; the zone-matching
|
|
403
|
+
// catalogued interface is the deployed system's address.
|
|
404
|
+
const ip =
|
|
405
|
+
cfg('target_ip') ?? cfg('ip.primary') ?? resolveMachineIdentityAddress(machine, zone);
|
|
406
|
+
if (!ip) continue;
|
|
407
|
+
|
|
376
408
|
const vmidStr = cfg('vmid');
|
|
377
409
|
const vmid = vmidStr ? Number.parseInt(vmidStr, 10) : null;
|
|
378
410
|
|
|
@@ -4,8 +4,9 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import type { DnsRecordRequest } from '@celilo/capabilities';
|
|
6
6
|
import type { ViewOverride } from '@celilo/capabilities';
|
|
7
|
+
import { eq } from 'drizzle-orm';
|
|
7
8
|
import type { DbClient } from '../db/client';
|
|
8
|
-
import { modules } from '../db/schema';
|
|
9
|
+
import { dnsInternalRecords, modules } from '../db/schema';
|
|
9
10
|
import { setupTestDatabase } from '../test-utils/setup-test-db';
|
|
10
11
|
import {
|
|
11
12
|
listDnsInternalRecords,
|
|
@@ -197,3 +198,73 @@ describe('dns-internal-records ledger', () => {
|
|
|
197
198
|
});
|
|
198
199
|
});
|
|
199
200
|
});
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* celilo#1010 — swapping the internal DNS provider must not delete the ledger.
|
|
204
|
+
*
|
|
205
|
+
* `dns_internal_records` used to cascade on `provider_module_id` as well as on
|
|
206
|
+
* the consumer, so removing `technitium` to install `knot-unbound-internal` took
|
|
207
|
+
* the fleet's entire internal DNS ledger with it, `zone_routable_ip` view
|
|
208
|
+
* overrides included — the durable desired state the resolver's split-horizon
|
|
209
|
+
* config is reconciled from. `web_routes` cascades on its consumer only, and the
|
|
210
|
+
* two tables' docblocks claimed to be siblings, so the divergence read as intent
|
|
211
|
+
* and was not.
|
|
212
|
+
*
|
|
213
|
+
* The claim on a capability-owned table is the CONSUMER
|
|
214
|
+
* (openspec/changes/capability-owned-tables D3/D8). Migration 0027 makes the
|
|
215
|
+
* table agree with the declaration, which cannot express anything else.
|
|
216
|
+
*/
|
|
217
|
+
describe('provider cascade (celilo#1010)', () => {
|
|
218
|
+
let dir: string;
|
|
219
|
+
let db: DbClient;
|
|
220
|
+
|
|
221
|
+
beforeEach(async () => {
|
|
222
|
+
dir = mkdtempSync(join(tmpdir(), 'dns-cascade-'));
|
|
223
|
+
const dbPath = join(dir, 'celilo.db');
|
|
224
|
+
process.env.CELILO_DB_PATH = dbPath;
|
|
225
|
+
db = await setupTestDatabase(dbPath);
|
|
226
|
+
for (const id of ['technitium', 'knot-unbound-internal', 'caddy']) {
|
|
227
|
+
db.insert(modules)
|
|
228
|
+
.values({
|
|
229
|
+
id,
|
|
230
|
+
name: id,
|
|
231
|
+
version: '1.0.0',
|
|
232
|
+
state: 'VERIFIED',
|
|
233
|
+
sourcePath: `/tmp/${id}`,
|
|
234
|
+
manifestData: {},
|
|
235
|
+
})
|
|
236
|
+
.run();
|
|
237
|
+
}
|
|
238
|
+
db.insert(dnsInternalRecords)
|
|
239
|
+
.values({
|
|
240
|
+
providerModuleId: 'technitium',
|
|
241
|
+
consumerModuleId: 'caddy',
|
|
242
|
+
host: 'auth.example.org',
|
|
243
|
+
ip: '192.168.0.253',
|
|
244
|
+
zoneRoutableIp: '10.0.10.14',
|
|
245
|
+
})
|
|
246
|
+
.run();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
afterEach(() => {
|
|
250
|
+
db.$client.close();
|
|
251
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
252
|
+
rmSync(dir, { recursive: true, force: true });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('keeps the ledger when the PROVIDER is removed, view overrides included', () => {
|
|
256
|
+
db.delete(modules).where(eq(modules.id, 'technitium')).run();
|
|
257
|
+
|
|
258
|
+
const left = db.select().from(dnsInternalRecords).all();
|
|
259
|
+
expect(left).toHaveLength(1);
|
|
260
|
+
// The override is the part whose loss is silent: the resolver keeps
|
|
261
|
+
// answering, just with the wrong address for in-zone clients.
|
|
262
|
+
expect(left[0]?.zoneRoutableIp).toBe('10.0.10.14');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('still dies with its CONSUMER, which is the rule that did not change', () => {
|
|
266
|
+
db.delete(modules).where(eq(modules.id, 'caddy')).run();
|
|
267
|
+
|
|
268
|
+
expect(db.select().from(dnsInternalRecords).all()).toHaveLength(0);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
@@ -12,6 +12,36 @@
|
|
|
12
12
|
* All DNS mechanics (which zones, register vs delete) live in the module's
|
|
13
13
|
* hook; this file only decides WHICH hosts and invokes the hook. That is the
|
|
14
14
|
* D5 division of labour: celilo owns host inventory, the module owns DNS.
|
|
15
|
+
*
|
|
16
|
+
* WHY THIS SURVIVED THE GENERIC PROVIDER-ARRIVAL BACKFILL
|
|
17
|
+
* (openspec/changes/capability-owned-tables task 1.7). `provider-arrival.ts`
|
|
18
|
+
* replaced `public-web-republish.ts` and covers `firewall` for the first time.
|
|
19
|
+
* It does NOT cover either function here, and deleting this file over it would
|
|
20
|
+
* silently take internal DNS with it. Two independent reasons, both checked
|
|
21
|
+
* against the manifests rather than assumed:
|
|
22
|
+
*
|
|
23
|
+
* 1. `backfillProviderDns` replays celilo's HOST INVENTORY — one record per
|
|
24
|
+
* deployed system, across every module. Those systems are hosts, not
|
|
25
|
+
* consumers. There is no `requires`/`optional` edge to fan out along
|
|
26
|
+
* because none of them asked `dns_internal` for anything. It is not a
|
|
27
|
+
* capability registration set, so a mechanism defined over consumer
|
|
28
|
+
* declarations cannot reach it.
|
|
29
|
+
*
|
|
30
|
+
* 2. `backfillWebRouteDns` covers the FQDNs modules publish through
|
|
31
|
+
* `public_web`. Those modules declare `public_web` — `authentik`,
|
|
32
|
+
* `forgejo`, `celilo-registry`, `celilo-apt-repo`, `celilo-website`,
|
|
33
|
+
* `npm-cache-node` and the two hello fixtures. NOT ONE declares
|
|
34
|
+
* `dns_internal`. The only module that does is `caddy-internal`, and it
|
|
35
|
+
* declares it `optional`. So a `dns_internal` provider arriving has no
|
|
36
|
+
* edge to the modules whose records it needs to learn.
|
|
37
|
+
*
|
|
38
|
+
* Point 2 is still second-implementation debt, and it is already ledgered:
|
|
39
|
+
* `module-business-baseline.ts` carries this file under S3 (#945). Retiring it
|
|
40
|
+
* needs a `public_web` fan-out triggered by a `dns_internal` arrival — a
|
|
41
|
+
* CROSS-capability edge the pull-shaped design does not define — because
|
|
42
|
+
* `register_route` (packages/capabilities/src/public-web.ts) already writes
|
|
43
|
+
* exactly the record `backfillWebRouteDns` reconstructs by hand, with the same
|
|
44
|
+
* value and the same `zoneRoutableValue`.
|
|
15
45
|
*/
|
|
16
46
|
|
|
17
47
|
import type { DnsInternalCapability, HookLogger } from '@celilo/capabilities';
|
|
@@ -650,6 +650,32 @@ describe('findBrokenCapabilityDerivations (shared predicate)', () => {
|
|
|
650
650
|
expect(problems).toHaveLength(0);
|
|
651
651
|
});
|
|
652
652
|
|
|
653
|
+
it('accepts a broken optional derivation when the manifest declares a fallback', () => {
|
|
654
|
+
const withFallback = baseManifest({
|
|
655
|
+
id: 'secondary-dns',
|
|
656
|
+
variables: {
|
|
657
|
+
owns: [
|
|
658
|
+
{
|
|
659
|
+
name: 'managed_domains',
|
|
660
|
+
type: 'array',
|
|
661
|
+
required: false,
|
|
662
|
+
default: [],
|
|
663
|
+
source: 'capability',
|
|
664
|
+
derive_from: '$capability:dns_internal.dns.managed_domains',
|
|
665
|
+
},
|
|
666
|
+
],
|
|
667
|
+
imports: [],
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
expect(findBrokenCapabilityDerivations('secondary-dns', withFallback, {})).toEqual([]);
|
|
672
|
+
expect(
|
|
673
|
+
findBrokenCapabilityDerivations('secondary-dns', withFallback, {
|
|
674
|
+
dns_internal: { dns: { managed_domains: '$self:managed_domains' } },
|
|
675
|
+
}),
|
|
676
|
+
).toEqual([]);
|
|
677
|
+
});
|
|
678
|
+
|
|
653
679
|
it('walks dotted paths', () => {
|
|
654
680
|
const m = baseManifest({
|
|
655
681
|
id: 'consumer',
|
|
@@ -502,6 +502,8 @@ interface CapabilityRef {
|
|
|
502
502
|
variable: string;
|
|
503
503
|
capability: string;
|
|
504
504
|
path: string;
|
|
505
|
+
/** A manifest default makes an absent/broken optional derivation safe. */
|
|
506
|
+
hasFallback: boolean;
|
|
505
507
|
}
|
|
506
508
|
|
|
507
509
|
export type CapabilityDerivationReason = 'no-provider' | 'empty-value' | 'unresolved-ref';
|
|
@@ -531,7 +533,12 @@ function parseCapabilityRefs(manifest: ModuleManifest): CapabilityRef[] {
|
|
|
531
533
|
const re = /\$\{?capability:([\w-]+)\.([\w.]+)/g;
|
|
532
534
|
let m: RegExpExecArray | null = re.exec(v.derive_from);
|
|
533
535
|
while (m !== null) {
|
|
534
|
-
refs.push({
|
|
536
|
+
refs.push({
|
|
537
|
+
variable: v.name,
|
|
538
|
+
capability: m[1],
|
|
539
|
+
path: m[2],
|
|
540
|
+
hasFallback: Object.prototype.hasOwnProperty.call(v, 'default'),
|
|
541
|
+
});
|
|
535
542
|
m = re.exec(v.derive_from);
|
|
536
543
|
}
|
|
537
544
|
}
|
|
@@ -578,15 +585,18 @@ export function findBrokenCapabilityDerivations(
|
|
|
578
585
|
};
|
|
579
586
|
const data = capabilities[ref.capability];
|
|
580
587
|
if (!data) {
|
|
588
|
+
if (ref.hasFallback) continue;
|
|
581
589
|
problems.push({ ...base, reason: 'no-provider' });
|
|
582
590
|
continue;
|
|
583
591
|
}
|
|
584
592
|
const value = getNested(data, ref.path);
|
|
585
593
|
if (value === undefined || value === null || value === '') {
|
|
594
|
+
if (ref.hasFallback) continue;
|
|
586
595
|
problems.push({ ...base, reason: 'empty-value' });
|
|
587
596
|
continue;
|
|
588
597
|
}
|
|
589
598
|
if (typeof value === 'string' && UNRESOLVED_REF.test(value)) {
|
|
599
|
+
if (ref.hasFallback) continue;
|
|
590
600
|
problems.push({ ...base, reason: 'unresolved-ref', value });
|
|
591
601
|
}
|
|
592
602
|
}
|
|
@@ -40,8 +40,12 @@ import { E2E_CONFLICT_MESSAGE, runningE2eContainers } from './e2e-guard';
|
|
|
40
40
|
import { resolveInfrastructureVariables } from './infrastructure-variable-resolver';
|
|
41
41
|
import { findMachineForModule } from './machine-pool';
|
|
42
42
|
import { ensureRequiredNetworks } from './network-ensure';
|
|
43
|
+
import {
|
|
44
|
+
type ProviderBackfillResult,
|
|
45
|
+
loadProviderBackfillPlan,
|
|
46
|
+
runProviderBackfill,
|
|
47
|
+
} from './provider-arrival';
|
|
43
48
|
import { checkProxmoxReachable, formatProxmoxUnreachableError } from './proxmox-preflight';
|
|
44
|
-
import { republishStaticWebConsumers } from './public-web-republish';
|
|
45
49
|
import { LOCAL_MACHINE_IP, deleteTemporarySshKey, writeTemporarySshKey } from './ssh-key-manager';
|
|
46
50
|
import { buildTerraformEnvForService } from './terraform-env';
|
|
47
51
|
|
|
@@ -224,6 +228,81 @@ async function invokeHookWithEnsureRetry(
|
|
|
224
228
|
}
|
|
225
229
|
}
|
|
226
230
|
|
|
231
|
+
/**
|
|
232
|
+
* A provider just arrived — re-run the consumers that were already here.
|
|
233
|
+
*
|
|
234
|
+
* The generic mirror of `consumer-cleanup.ts`. It replaces
|
|
235
|
+
* `republishStaticWebConsumers`, which did this for `public_web` alone, and it
|
|
236
|
+
* covers `firewall` for the first time — three modules provide `firewall` and
|
|
237
|
+
* none of their consumers inherited anything on deploy (celilo#1011).
|
|
238
|
+
*
|
|
239
|
+
* Never fatal to the provider's own deploy. The provider deployed fine; what
|
|
240
|
+
* can fail is one consumer's re-registration, and the honest report is that
|
|
241
|
+
* consumer named with the command that retries it. Silence would report a clean
|
|
242
|
+
* deploy over a fleet where some consumers never re-registered.
|
|
243
|
+
*/
|
|
244
|
+
// ponytail: fans out on EVERY deploy of a module that provides anything, so a
|
|
245
|
+
// caddy redeploy re-runs all eight `public_web` consumers' `on_install` even
|
|
246
|
+
// though caddy was already their provider and they have nothing to gain. The
|
|
247
|
+
// hooks are idempotent by contract and a provider deploy is already heavyweight,
|
|
248
|
+
// so this is accepted rather than guessed at — every predicate for "this
|
|
249
|
+
// consumer has something to gain" that does not need new state is wrong in some
|
|
250
|
+
// case (a redeploy of the incumbent provider looks identical to a first
|
|
251
|
+
// deploy). If it measurably hurts, the upgrade is to record which provider a
|
|
252
|
+
// consumer last bound to and re-run only where that changed.
|
|
253
|
+
async function backfillArrivedProvider(moduleId: string, db: DbClient): Promise<void> {
|
|
254
|
+
const plan = loadProviderBackfillPlan(moduleId, db);
|
|
255
|
+
if (plan.length === 0) return;
|
|
256
|
+
|
|
257
|
+
const attempts = plan.filter((target) => !target.skip);
|
|
258
|
+
const gauge = new FuelGauge(`Re-registering ${attempts.length} consumer(s) with ${moduleId}`, {
|
|
259
|
+
skipAnimation: !process.stdout.isTTY,
|
|
260
|
+
});
|
|
261
|
+
gauge.start();
|
|
262
|
+
let result: ProviderBackfillResult;
|
|
263
|
+
try {
|
|
264
|
+
const logger = createGaugeLogger(gauge, moduleId, 'provider_arrival');
|
|
265
|
+
result = await runProviderBackfill(moduleId, plan, db, logger);
|
|
266
|
+
gauge.stop(result.failures.length === 0);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
gauge.stop(false);
|
|
269
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
270
|
+
log.warn(`Consumer re-registration against '${moduleId}' could not run: ${msg}`);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (result.rerun.length > 0) {
|
|
275
|
+
log.success(
|
|
276
|
+
`Re-registered ${result.rerun.length} consumer(s) with '${moduleId}': ${result.rerun.join(', ')}`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
// Re-surfaced outside the gauge: the gauge's own preview scrolls away, and a
|
|
280
|
+
// consumer that never re-registered is exactly what an operator must still be
|
|
281
|
+
// able to read once the deploy has finished.
|
|
282
|
+
//
|
|
283
|
+
// INFO, not warn, and the level is load-bearing. `unpause --cascade`
|
|
284
|
+
// redeploys in topological order and clears `pausedAt` only AFTER each
|
|
285
|
+
// redeploy succeeds (`module-pause.ts#executeUnpause`), so the provider's own
|
|
286
|
+
// redeploy runs while every consumer in the cascade is still PAUSED. Warning
|
|
287
|
+
// there would put one alarm per consumer immediately before the cascade
|
|
288
|
+
// redeploys each of them successfully — an alarm about the normal path.
|
|
289
|
+
//
|
|
290
|
+
// Nothing is at risk either way: a paused module cannot return to service
|
|
291
|
+
// without a redeploy, and that redeploy re-resolves its capabilities. That is
|
|
292
|
+
// the same guarantee `remove-guard.ts` relies on to treat a paused module as
|
|
293
|
+
// not a dependent. So this is worth saying and not worth warning about.
|
|
294
|
+
for (const paused of result.skipped.filter((s) => s.reason === 'paused')) {
|
|
295
|
+
log.info(
|
|
296
|
+
`'${paused.consumerId}' is paused, so it was not re-registered with '${moduleId}'. It rebinds when it is unpaused and redeployed.`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
for (const failure of result.failures) {
|
|
300
|
+
log.warn(
|
|
301
|
+
`'${failure.consumerId}' failed to re-register with '${moduleId}': ${failure.error}. Run \`celilo module deploy ${failure.consumerId}\` to retry.`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
227
306
|
/**
|
|
228
307
|
* Orchestrate module deployment workflow
|
|
229
308
|
* Orchestrator function - coordinates deployment phases
|
|
@@ -836,26 +915,10 @@ async function deployModuleImpl(
|
|
|
836
915
|
log.success(`Base-module aspect fan-out for '${moduleId}' completed`);
|
|
837
916
|
}
|
|
838
917
|
|
|
839
|
-
//
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
|
|
843
|
-
// are logged, never fatal to the provider's own deploy.
|
|
844
|
-
const republish = await republishStaticWebConsumers({
|
|
845
|
-
providerModuleId: moduleId,
|
|
846
|
-
manifest,
|
|
847
|
-
db,
|
|
848
|
-
});
|
|
849
|
-
if (republish.republished.length > 0) {
|
|
850
|
-
log.success(
|
|
851
|
-
`Re-published ${republish.republished.length} static site(s) to '${moduleId}': ${republish.republished.join(', ')}`,
|
|
852
|
-
);
|
|
853
|
-
}
|
|
854
|
-
for (const failure of republish.failures) {
|
|
855
|
-
log.warn(
|
|
856
|
-
`Failed to re-publish '${failure.moduleId}' to '${moduleId}': ${failure.error}. Run \`celilo module deploy ${failure.moduleId}\` to restore its content.`,
|
|
857
|
-
);
|
|
858
|
-
}
|
|
918
|
+
// Whatever this module provides, the consumers that were already here
|
|
919
|
+
// never got to ask it for anything. Re-run them so they register through
|
|
920
|
+
// the path that worked the first time (design D7).
|
|
921
|
+
await backfillArrivedProvider(moduleId, db);
|
|
859
922
|
|
|
860
923
|
// Mirror the infrastructure-path success message at the end of
|
|
861
924
|
// a successful deploy. Without this, config-only deploys end
|
|
@@ -1514,26 +1577,10 @@ async function deployModuleImpl(
|
|
|
1514
1577
|
log.success(`Base-module aspect fan-out for '${moduleId}' completed`);
|
|
1515
1578
|
}
|
|
1516
1579
|
|
|
1517
|
-
//
|
|
1518
|
-
//
|
|
1519
|
-
//
|
|
1520
|
-
|
|
1521
|
-
// are logged, never fatal to the provider's own deploy.
|
|
1522
|
-
const republish = await republishStaticWebConsumers({
|
|
1523
|
-
providerModuleId: moduleId,
|
|
1524
|
-
manifest,
|
|
1525
|
-
db,
|
|
1526
|
-
});
|
|
1527
|
-
if (republish.republished.length > 0) {
|
|
1528
|
-
log.success(
|
|
1529
|
-
`Re-published ${republish.republished.length} static site(s) to '${moduleId}': ${republish.republished.join(', ')}`,
|
|
1530
|
-
);
|
|
1531
|
-
}
|
|
1532
|
-
for (const failure of republish.failures) {
|
|
1533
|
-
log.warn(
|
|
1534
|
-
`Failed to re-publish '${failure.moduleId}' to '${moduleId}': ${failure.error}. Run \`celilo module deploy ${failure.moduleId}\` to restore its content.`,
|
|
1535
|
-
);
|
|
1536
|
-
}
|
|
1580
|
+
// Whatever this module provides, the consumers that were already here
|
|
1581
|
+
// never got to ask it for anything. Re-run them so they register through
|
|
1582
|
+
// the path that worked the first time (design D7).
|
|
1583
|
+
await backfillArrivedProvider(moduleId, db);
|
|
1537
1584
|
|
|
1538
1585
|
log.success(`Module '${moduleId}' deployed successfully`);
|
|
1539
1586
|
return {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { CAPABILITY_CONTRACT_VERSIONS } from '@celilo/capabilities';
|
|
8
9
|
import { validateCapabilityVersions } from './capability-versions';
|
|
9
10
|
|
|
10
11
|
describe('validateCapabilityVersions', () => {
|
|
@@ -31,7 +32,11 @@ describe('validateCapabilityVersions', () => {
|
|
|
31
32
|
expect(errors).toHaveLength(1);
|
|
32
33
|
expect(errors[0]).toContain('provides[public_web]');
|
|
33
34
|
expect(errors[0]).toContain('1.0.0');
|
|
34
|
-
|
|
35
|
+
// Read from the registry, not pinned to today's number. The assertion is
|
|
36
|
+
// "the message names the RUNTIME version so the author knows what to move
|
|
37
|
+
// to"; a literal here turns a behaviour test into a version tracker that
|
|
38
|
+
// goes red on every legitimate bump, which is what happened at 3.1.0 → 3.2.0.
|
|
39
|
+
expect(errors[0]).toContain(CAPABILITY_CONTRACT_VERSIONS.public_web);
|
|
35
40
|
});
|
|
36
41
|
|
|
37
42
|
test('error when provides[X].version is newer than runtime', () => {
|
|
@@ -7,7 +7,8 @@ import { eq } from 'drizzle-orm';
|
|
|
7
7
|
import type { DbClient } from '../db/client';
|
|
8
8
|
import { portForwards } from '../db/schema';
|
|
9
9
|
import { setupTestDatabase } from '../test-utils/setup-test-db';
|
|
10
|
-
import {
|
|
10
|
+
import { deleteClaimedRows } from './capability-table-rows';
|
|
11
|
+
import { buildPortForwardStore } from './port-forwards';
|
|
11
12
|
|
|
12
13
|
const FW = '192.168.0.254';
|
|
13
14
|
const CADDY = { internalIp: '10.0.20.5', protocol: 'TCP' as const, description: 'caddy' };
|
|
@@ -113,7 +114,10 @@ describe('port-forward store', () => {
|
|
|
113
114
|
other.replace(FW, { ...CADDY, description: 'also 443' }, [443]);
|
|
114
115
|
expect(store.list(FW)).toHaveLength(2);
|
|
115
116
|
|
|
116
|
-
|
|
117
|
+
// Through the GENERIC declaration-driven path, which replaced
|
|
118
|
+
// `deletePortForwardsForModule`. The property is unchanged; what changed is
|
|
119
|
+
// that core no longer names this capability's table to clear it.
|
|
120
|
+
deleteClaimedRows(db, 'caddy');
|
|
117
121
|
|
|
118
122
|
const left = store.list(FW);
|
|
119
123
|
expect(left).toHaveLength(1);
|
|
@@ -99,14 +99,3 @@ export function buildPortForwardStore(db: DbClient, registeredBy: string): PortF
|
|
|
99
99
|
},
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Drop every forward a departing module owns, across every firewall.
|
|
105
|
-
*
|
|
106
|
-
* `registered_by` is plain text rather than a FK, so unlike `web_routes` these
|
|
107
|
-
* rows do NOT die with the `modules` row — core deletes them explicitly, AFTER
|
|
108
|
-
* the provider has converged without them (D4).
|
|
109
|
-
*/
|
|
110
|
-
export function deletePortForwardsForModule(db: DbClient, moduleId: string): void {
|
|
111
|
-
db.delete(portForwards).where(eq(portForwards.registeredBy, moduleId)).run();
|
|
112
|
-
}
|