@celilo/cli 1.6.0 → 1.7.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.
Files changed (46) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +2 -0
  3. package/MODULE_PRIMITIVES.md +6 -1
  4. package/package.json +3 -3
  5. package/src/capabilities/lookup.ts +39 -29
  6. package/src/capabilities/secret-ref.test.ts +24 -0
  7. package/src/capabilities/secret-validation.ts +50 -0
  8. package/src/capabilities/validation.test.ts +187 -2
  9. package/src/capabilities/validation.ts +53 -1
  10. package/src/cli/commands/alerts-sweep.ts +18 -0
  11. package/src/cli/commands/module-remove.ts +34 -2
  12. package/src/cli/commands/module-update.test.ts +149 -2
  13. package/src/cli/commands/module-update.ts +113 -25
  14. package/src/cli/commands/service-set-credentials.test.ts +108 -0
  15. package/src/cli/commands/service-set-credentials.ts +115 -0
  16. package/src/cli/commands/system-migrate.ts +6 -4
  17. package/src/cli/completion.ts +16 -1
  18. package/src/cli/index.ts +9 -0
  19. package/src/db/client.ts +10 -8
  20. package/src/db/migrate.test.ts +147 -0
  21. package/src/db/migrate.ts +69 -1
  22. package/src/hooks/capability-loader.test.ts +55 -0
  23. package/src/hooks/capability-loader.ts +16 -1
  24. package/src/module/import.ts +20 -5
  25. package/src/policy/module-business-baseline.ts +0 -11
  26. package/src/services/alerting/monitors.ts +54 -2
  27. package/src/services/alerting/sweep-runner.ts +38 -1
  28. package/src/services/consumer-cleanup.ts +5 -3
  29. package/src/services/container-service.test.ts +34 -0
  30. package/src/services/container-service.ts +44 -0
  31. package/src/services/deployed-systems.test.ts +101 -0
  32. package/src/services/deployed-systems.ts +43 -11
  33. package/src/services/dns-provider-backfill.ts +30 -0
  34. package/src/services/fleet-checks.test.ts +26 -0
  35. package/src/services/fleet-checks.ts +11 -1
  36. package/src/services/module-deploy.ts +88 -41
  37. package/src/services/provider-arrival.test.ts +241 -0
  38. package/src/services/provider-arrival.ts +213 -0
  39. package/src/templates/generator.test.ts +35 -0
  40. package/src/templates/generator.ts +29 -1
  41. package/src/variables/context.test.ts +63 -0
  42. package/src/variables/context.ts +10 -2
  43. package/src/variables/declarative-derivation.test.ts +47 -8
  44. package/src/variables/declarative-derivation.ts +6 -4
  45. package/src/services/public-web-republish.test.ts +0 -189
  46. package/src/services/public-web-republish.ts +0 -84
@@ -17,6 +17,8 @@ import {
17
17
  getServiceCredentials,
18
18
  listContainerServices,
19
19
  removeContainerService,
20
+ updateServiceCredentials,
21
+ updateVerificationStatus,
20
22
  } from './container-service';
21
23
 
22
24
  describe('container-service', () => {
@@ -270,6 +272,38 @@ describe('container-service', () => {
270
272
  /Container service not found/,
271
273
  );
272
274
  });
275
+
276
+ it('re-encrypts replacements and clears stale verification state', async () => {
277
+ const service = await addContainerService({
278
+ name: 'Moving Proxmox',
279
+ providerName: 'proxmox',
280
+ zones: ['internal'],
281
+ providerConfig: {},
282
+ apiCredentials: {
283
+ api_url: 'https://192.168.0.50:8006',
284
+ api_token_id: 'root@pam!celilo',
285
+ api_token_secret: 'existing-secret',
286
+ },
287
+ });
288
+ await updateVerificationStatus(service.id, { success: true, message: 'Connected' });
289
+
290
+ await updateServiceCredentials(service.id, {
291
+ api_url: 'https://10.77.20.50:8006',
292
+ api_token_id: 'root@pam!celilo',
293
+ api_token_secret: 'existing-secret',
294
+ });
295
+
296
+ const credentials = await getServiceCredentials(service.id);
297
+ const updated = await getContainerService(service.id);
298
+ expect(credentials).toEqual({
299
+ api_url: 'https://10.77.20.50:8006',
300
+ api_token_id: 'root@pam!celilo',
301
+ api_token_secret: 'existing-secret',
302
+ });
303
+ expect(updated?.verified).toBe(false);
304
+ expect(updated?.verifiedAt).toBeNull();
305
+ expect(updated?.verificationError).toBeNull();
306
+ });
273
307
  });
274
308
 
275
309
  describe('removeContainerService', () => {
@@ -251,6 +251,50 @@ export async function getServiceCredentials(serviceId: string): Promise<ServiceC
251
251
  );
252
252
  }
253
253
 
254
+ /**
255
+ * Replace a container service's encrypted API credentials.
256
+ *
257
+ * Credentials identify the remote provider endpoint as well as the principal
258
+ * used there, so changing either invalidates the previous verification result.
259
+ * Callers should explicitly re-run service verification after this update.
260
+ */
261
+ export async function updateServiceCredentials(
262
+ id: string,
263
+ credentials: ServiceCredentials,
264
+ ): Promise<void> {
265
+ const service = await getContainerService(id);
266
+ if (!service) {
267
+ throw new Error(`Container service not found: ${id}`);
268
+ }
269
+
270
+ const validated =
271
+ service.providerName === 'proxmox'
272
+ ? ProxmoxCredentialsSchema.parse(credentials)
273
+ : service.providerName === 'digitalocean'
274
+ ? DigitalOceanCredentialsSchema.parse(credentials)
275
+ : null;
276
+
277
+ if (!validated) {
278
+ throw new Error(
279
+ `Unsupported provider for credential validation: ${service.providerName}. Supported providers: proxmox, digitalocean`,
280
+ );
281
+ }
282
+
283
+ const masterKey = await getOrCreateMasterKey();
284
+ const encrypted = encryptSecret(JSON.stringify(validated), masterKey);
285
+
286
+ await getDb()
287
+ .update(containerServices)
288
+ .set({
289
+ apiCredentialsEncrypted: JSON.stringify(encrypted),
290
+ verified: false,
291
+ verifiedAt: null,
292
+ verificationError: null,
293
+ updatedAt: new Date(),
294
+ })
295
+ .where(eq(containerServices.id, id));
296
+ }
297
+
254
298
  /**
255
299
  * List container services with optional filters
256
300
  */
@@ -266,6 +266,57 @@ describe('backfillModuleSystems', () => {
266
266
  });
267
267
  });
268
268
 
269
+ test('backfill records a local machine by its interface in the resolved zone', () => {
270
+ db.insert(machines)
271
+ .values({
272
+ id: 'm-local-bf',
273
+ hostname: 'celilo-mgr',
274
+ // Local-execution sentinel, not the machine's network identity.
275
+ ipAddress: '127.0.0.1',
276
+ sshUser: 'jem',
277
+ sshKeyEncrypted: JSON.stringify({ encryptedValue: '', iv: '', authTag: '' }),
278
+ hardware: { cpu_cores: 4, memory_mb: 8192, disk_gb: 64 },
279
+ zone: 'secure-mgmt',
280
+ interfaces: [
281
+ { name: 'en0', ipAddress: '192.168.0.32', zone: 'external' },
282
+ { name: 'ens18', ipAddress: '10.77.20.32', zone: 'secure-mgmt' },
283
+ ],
284
+ })
285
+ .run();
286
+ db.insert(modules)
287
+ .values({
288
+ id: 'celilo-mgmt',
289
+ name: 'celilo-mgmt',
290
+ version: '1.0.0',
291
+ manifestData: { requires: { system: { zone: 'internal' } } },
292
+ sourcePath: '/tmp/celilo-mgmt',
293
+ state: 'VERIFIED',
294
+ })
295
+ .run();
296
+ db.insert(moduleInfrastructure)
297
+ .values({
298
+ id: 'infra-celilo-mgmt-local',
299
+ moduleId: 'celilo-mgmt',
300
+ infrastructureType: 'machine',
301
+ machineId: 'm-local-bf',
302
+ })
303
+ .run();
304
+ db.insert(moduleConfigs)
305
+ .values({
306
+ moduleId: 'celilo-mgmt',
307
+ key: 'hostname',
308
+ value: 'celilo-mgr',
309
+ valueJson: '"celilo-mgr"',
310
+ })
311
+ .run();
312
+
313
+ expect(backfillModuleSystems(db)).toEqual(['celilo-mgmt']);
314
+ expect(getModuleSystems('celilo-mgmt', db)[0]).toMatchObject({
315
+ zone: 'secure-mgmt',
316
+ ipv4_address: '10.77.20.32',
317
+ });
318
+ });
319
+
269
320
  // The control-plane case. celilo-mgmt declares `internal` (it bootstraps before
270
321
  // any firewall exists) but in a segmented fleet the operator earmarks a box on
271
322
  // `secure-mgmt`. What gets RECORDED must be where the system actually is, since
@@ -318,6 +369,56 @@ describe('backfillModuleSystems', () => {
318
369
  expect(getModuleSystems('celilo-mgmt', db)[0]).toMatchObject({ zone: 'secure-mgmt' });
319
370
  });
320
371
 
372
+ test('records a local machine by its interface in the resolved zone', async () => {
373
+ db.insert(machines)
374
+ .values({
375
+ id: 'm-local',
376
+ hostname: 'celilo-mgr',
377
+ // Local-execution sentinel, not the machine's network identity.
378
+ ipAddress: '127.0.0.1',
379
+ sshUser: 'jem',
380
+ sshKeyEncrypted: JSON.stringify({ encryptedValue: '', iv: '', authTag: '' }),
381
+ hardware: { cpu_cores: 4, memory_mb: 8192, disk_gb: 64 },
382
+ zone: 'secure-mgmt',
383
+ earmarkedModule: 'celilo-mgmt',
384
+ interfaces: [
385
+ { name: 'en0', ipAddress: '192.168.0.32', zone: 'external' },
386
+ { name: 'ens18', ipAddress: '10.77.20.32', zone: 'secure-mgmt' },
387
+ ],
388
+ })
389
+ .run();
390
+ db.insert(modules)
391
+ .values({
392
+ id: 'celilo-mgmt',
393
+ name: 'celilo-mgmt',
394
+ version: '1.0.0',
395
+ manifestData: { requires: { system: { zone: 'internal' } } },
396
+ sourcePath: '/tmp/celilo-mgmt',
397
+ state: 'VERIFIED',
398
+ })
399
+ .run();
400
+ db.insert(moduleConfigs)
401
+ .values({
402
+ moduleId: 'celilo-mgmt',
403
+ key: 'hostname',
404
+ value: 'celilo-mgr',
405
+ valueJson: '"celilo-mgr"',
406
+ })
407
+ .run();
408
+
409
+ const recorded = await recordDeployedSystemForModule(
410
+ 'celilo-mgmt',
411
+ { requires: { system: { zone: 'internal' } } } as ModuleManifest,
412
+ { type: 'machine', machineId: 'm-local' },
413
+ db,
414
+ );
415
+
416
+ expect(recorded[0]).toMatchObject({
417
+ zone: 'secure-mgmt',
418
+ ipv4_address: '10.77.20.32',
419
+ });
420
+ });
421
+
321
422
  test('skips an API-only module (no declared systems)', () => {
322
423
  ensureProxmoxService(db);
323
424
  db.insert(modules)
@@ -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
 
@@ -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({ variable: v.name, capability: m[1], path: m[2] });
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
- // A public_web provider deploy rebuilds its host and takes /srv/www with
840
- // it, while consumers' route rows survive so the fresh provider would
841
- // render site blocks pointing at directories that no longer exist. Re-run
842
- // each static consumer's on_install to refill them. Idempotent; failures
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
- // A public_web provider deploy rebuilds its host and takes /srv/www with
1518
- // it, while consumers' route rows survive so the fresh provider would
1519
- // render site blocks pointing at directories that no longer exist. Re-run
1520
- // each static consumer's on_install to refill them. Idempotent; failures
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 {