@celilo/cli 1.5.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.
- package/CELILO_CORE_MODULES.md +2 -1
- package/CELILO_SUBSYSTEMS.md +18 -2
- package/MODULE_PRIMITIVES.md +25 -7
- package/drizzle/0026_module_integrity_version.sql +20 -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 +187 -2
- package/src/capabilities/validation.ts +53 -1
- package/src/cli/commands/alerts-sweep.ts +18 -0
- package/src/cli/commands/module-audit.ts +5 -2
- package/src/cli/commands/module-remove.ts +34 -2
- package/src/cli/commands/module-update.test.ts +238 -3
- package/src/cli/commands/module-update.ts +206 -12
- package/src/cli/commands/module-verify.ts +77 -13
- 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-audit.ts +17 -0
- package/src/cli/commands/system-doctor.ts +78 -2
- package/src/cli/commands/system-migrate.ts +6 -4
- package/src/cli/commands/system-update.ts +33 -3
- package/src/cli/completion.ts +16 -1
- package/src/cli/index.ts +11 -2
- package/src/cli/tui/audit-state.ts +11 -3
- package/src/cli/tui/audit-tui.tsx +10 -4
- package/src/cli/tui/icons.ts +9 -2
- package/src/cli/tui/modals/analyzing.tsx +3 -0
- package/src/db/client.ts +10 -8
- package/src/db/migrate.test.ts +147 -0
- package/src/db/migrate.ts +69 -1
- package/src/db/schema.ts +5 -0
- package/src/hooks/capability-loader.test.ts +55 -0
- package/src/hooks/capability-loader.ts +16 -1
- package/src/manifest/json-schema-roundtrip.test.ts +12 -4
- package/src/manifest/schema.ts +23 -0
- package/src/module/import.ts +56 -40
- package/src/module/packaging/audit.ts +103 -28
- package/src/module/packaging/build.ts +12 -53
- package/src/module/packaging/classify-module-path.test.ts +104 -0
- package/src/module/packaging/extract.ts +31 -3
- package/src/module/packaging/generated-plane.test.ts +79 -0
- package/src/module/packaging/generated-plane.ts +134 -0
- package/src/module/packaging/host-plane.test.ts +132 -0
- package/src/module/packaging/host-plane.ts +135 -0
- package/src/module/packaging/package-rules.ts +62 -0
- package/src/policy/module-business-baseline.ts +0 -11
- package/src/services/alerting/monitors.ts +54 -2
- package/src/services/alerting/sweep-runner.ts +38 -1
- package/src/services/audit/cli-version.test.ts +6 -2
- package/src/services/audit/cli-version.ts +20 -6
- package/src/services/audit/detect-without-converge.test.ts +91 -0
- package/src/services/audit/detect-without-converge.ts +81 -0
- package/src/services/audit/disk-space.test.ts +5 -2
- package/src/services/audit/disk-space.ts +5 -3
- package/src/services/audit/health.test.ts +39 -0
- package/src/services/audit/index.test.ts +7 -1
- package/src/services/audit/index.ts +12 -0
- package/src/services/audit/module-integrity.test.ts +146 -0
- package/src/services/audit/module-integrity.ts +113 -0
- package/src/services/audit/module-versions.ts +4 -1
- package/src/services/audit/schema.test.ts +7 -2
- package/src/services/audit/schema.ts +19 -1
- package/src/services/audit/terraform-plan.ts +17 -2
- package/src/services/audit/types.test.ts +29 -0
- package/src/services/audit/types.ts +30 -4
- package/src/services/consumer-cleanup.ts +5 -3
- 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-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 +109 -41
- package/src/services/provider-arrival.test.ts +241 -0
- package/src/services/provider-arrival.ts +213 -0
- package/src/services/restore-from-file.ts +4 -0
- package/src/services/update/orchestrator.test.ts +2 -0
- 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 +10 -2
- package/src/variables/declarative-derivation.test.ts +47 -8
- package/src/variables/declarative-derivation.ts +6 -4
- 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
|
|
|
@@ -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
|
|
@@ -559,6 +638,27 @@ async function deployModuleImpl(
|
|
|
559
638
|
|
|
560
639
|
log.success('Templates generated');
|
|
561
640
|
|
|
641
|
+
// Pre-flight: refuse before contacting any system if the generated project
|
|
642
|
+
// does not match the module tree it was generated from (D6).
|
|
643
|
+
//
|
|
644
|
+
// This is the check that would have turned celilo#925 from a silent success
|
|
645
|
+
// into a refusal at the point of the fault. A verbatim role asset that
|
|
646
|
+
// failed to refresh is invisible downstream: `cp` reports no error, Ansible
|
|
647
|
+
// copies the stale bytes and reports `ok`, unchanged, and the module's
|
|
648
|
+
// version advances past code that never shipped. Local, milliseconds, no
|
|
649
|
+
// SSH. It deliberately does NOT verify after the deploy — Ansible's
|
|
650
|
+
// `changed` is truthful about what Ansible did, and the lie is upstream of
|
|
651
|
+
// Ansible.
|
|
652
|
+
const { refuseIfGeneratedIsStale } = await import('../module/packaging/generated-plane');
|
|
653
|
+
const staleGenerated = await refuseIfGeneratedIsStale(
|
|
654
|
+
moduleId,
|
|
655
|
+
module.sourcePath,
|
|
656
|
+
generatedPath,
|
|
657
|
+
);
|
|
658
|
+
if (staleGenerated) {
|
|
659
|
+
return { success: false, error: staleGenerated, phases };
|
|
660
|
+
}
|
|
661
|
+
|
|
562
662
|
// Run validate_config hook if defined (e.g., credential validation via Playwright)
|
|
563
663
|
if (manifest.hooks?.validate_config) {
|
|
564
664
|
const hookDef = manifest.hooks.validate_config;
|
|
@@ -815,26 +915,10 @@ async function deployModuleImpl(
|
|
|
815
915
|
log.success(`Base-module aspect fan-out for '${moduleId}' completed`);
|
|
816
916
|
}
|
|
817
917
|
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
|
|
822
|
-
// are logged, never fatal to the provider's own deploy.
|
|
823
|
-
const republish = await republishStaticWebConsumers({
|
|
824
|
-
providerModuleId: moduleId,
|
|
825
|
-
manifest,
|
|
826
|
-
db,
|
|
827
|
-
});
|
|
828
|
-
if (republish.republished.length > 0) {
|
|
829
|
-
log.success(
|
|
830
|
-
`Re-published ${republish.republished.length} static site(s) to '${moduleId}': ${republish.republished.join(', ')}`,
|
|
831
|
-
);
|
|
832
|
-
}
|
|
833
|
-
for (const failure of republish.failures) {
|
|
834
|
-
log.warn(
|
|
835
|
-
`Failed to re-publish '${failure.moduleId}' to '${moduleId}': ${failure.error}. Run \`celilo module deploy ${failure.moduleId}\` to restore its content.`,
|
|
836
|
-
);
|
|
837
|
-
}
|
|
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);
|
|
838
922
|
|
|
839
923
|
// Mirror the infrastructure-path success message at the end of
|
|
840
924
|
// a successful deploy. Without this, config-only deploys end
|
|
@@ -1493,26 +1577,10 @@ async function deployModuleImpl(
|
|
|
1493
1577
|
log.success(`Base-module aspect fan-out for '${moduleId}' completed`);
|
|
1494
1578
|
}
|
|
1495
1579
|
|
|
1496
|
-
//
|
|
1497
|
-
//
|
|
1498
|
-
//
|
|
1499
|
-
|
|
1500
|
-
// are logged, never fatal to the provider's own deploy.
|
|
1501
|
-
const republish = await republishStaticWebConsumers({
|
|
1502
|
-
providerModuleId: moduleId,
|
|
1503
|
-
manifest,
|
|
1504
|
-
db,
|
|
1505
|
-
});
|
|
1506
|
-
if (republish.republished.length > 0) {
|
|
1507
|
-
log.success(
|
|
1508
|
-
`Re-published ${republish.republished.length} static site(s) to '${moduleId}': ${republish.republished.join(', ')}`,
|
|
1509
|
-
);
|
|
1510
|
-
}
|
|
1511
|
-
for (const failure of republish.failures) {
|
|
1512
|
-
log.warn(
|
|
1513
|
-
`Failed to re-publish '${failure.moduleId}' to '${moduleId}': ${failure.error}. Run \`celilo module deploy ${failure.moduleId}\` to restore its content.`,
|
|
1514
|
-
);
|
|
1515
|
-
}
|
|
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);
|
|
1516
1584
|
|
|
1517
1585
|
log.success(`Module '${moduleId}' deployed successfully`);
|
|
1518
1586
|
return {
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import type { RunNamedHookResult } from '../hooks/run-named-hook';
|
|
3
|
+
import type { HookLogger } from '../hooks/types';
|
|
4
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
5
|
+
import {
|
|
6
|
+
type ConsumerCandidate,
|
|
7
|
+
planProviderBackfill,
|
|
8
|
+
runProviderBackfill,
|
|
9
|
+
} from './provider-arrival';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The pure half of provider arrival: which consumers get re-run, and which are
|
|
13
|
+
* skipped and why. All of it is decided without a database, a hook runner, or a
|
|
14
|
+
* deployed anything, which is the point of the split.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
function manifest(input: { requires?: string[]; optional?: string[] }): ModuleManifest {
|
|
18
|
+
return {
|
|
19
|
+
requires: { capabilities: (input.requires ?? []).map((name) => ({ name, version: '1.0.0' })) },
|
|
20
|
+
optional: { capabilities: (input.optional ?? []).map((name) => ({ name, version: '1.0.0' })) },
|
|
21
|
+
} as unknown as ModuleManifest;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const consumer = (
|
|
25
|
+
moduleId: string,
|
|
26
|
+
caps: { requires?: string[]; optional?: string[] },
|
|
27
|
+
state = 'VERIFIED',
|
|
28
|
+
): ConsumerCandidate => ({ moduleId, manifest: manifest(caps), state });
|
|
29
|
+
|
|
30
|
+
describe('planProviderBackfill', () => {
|
|
31
|
+
it('re-runs consumers of the capabilities the arriving provider actually provides', () => {
|
|
32
|
+
const plan = planProviderBackfill(
|
|
33
|
+
'iptables',
|
|
34
|
+
['firewall'],
|
|
35
|
+
[
|
|
36
|
+
consumer('caddy', { requires: ['firewall'] }),
|
|
37
|
+
consumer('forgejo', { requires: ['public_web'] }),
|
|
38
|
+
],
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(plan.map((t) => t.consumerId)).toEqual(['caddy']);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `optional` is the edge `remove-guard.ts` already counts, and it is the one
|
|
46
|
+
* the field was added to express — "this hook will use monitoring if it is
|
|
47
|
+
* there" describes exactly a module with something to gain the moment the
|
|
48
|
+
* provider appears. Counting only `requires` would leave it unregistered.
|
|
49
|
+
*/
|
|
50
|
+
it('includes an OPTIONAL dependent, not only a required one', () => {
|
|
51
|
+
const plan = planProviderBackfill(
|
|
52
|
+
'technitium',
|
|
53
|
+
['dns_internal'],
|
|
54
|
+
[consumer('caddy-internal', { optional: ['dns_internal'] })],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(plan.map((t) => t.consumerId)).toEqual(['caddy-internal']);
|
|
58
|
+
expect(plan[0]?.skip).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('never treats the provider as its own consumer', () => {
|
|
62
|
+
// A module that both provides and requires a capability would otherwise
|
|
63
|
+
// have its on_install run a second time inside the deploy that just ran it.
|
|
64
|
+
const plan = planProviderBackfill(
|
|
65
|
+
'caddy',
|
|
66
|
+
['public_web'],
|
|
67
|
+
[
|
|
68
|
+
consumer('caddy', { requires: ['public_web', 'firewall'] }),
|
|
69
|
+
consumer('forgejo', { requires: ['public_web'] }),
|
|
70
|
+
],
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(plan.map((t) => t.consumerId)).toEqual(['forgejo']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('skips a consumer in a pre-deploy state, and says so rather than dropping it', () => {
|
|
77
|
+
// Capabilities are registered at IMPORT, not deploy, so an imported module
|
|
78
|
+
// has never resolved anything and has nothing to re-register.
|
|
79
|
+
const plan = planProviderBackfill(
|
|
80
|
+
'iptables',
|
|
81
|
+
['firewall'],
|
|
82
|
+
[
|
|
83
|
+
consumer('imported', { requires: ['firewall'] }, 'IMPORTED'),
|
|
84
|
+
consumer('validated', { requires: ['firewall'] }, 'VALIDATED'),
|
|
85
|
+
consumer('configured', { requires: ['firewall'] }, 'CONFIGURED'),
|
|
86
|
+
],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
expect(plan.map((t) => t.skip)).toEqual(['not-deployed', 'not-deployed', 'not-deployed']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('skips a PAUSED consumer separately from a never-deployed one', () => {
|
|
93
|
+
const plan = planProviderBackfill(
|
|
94
|
+
'iptables',
|
|
95
|
+
['firewall'],
|
|
96
|
+
[consumer('paused', { requires: ['firewall'] }, 'PAUSED')],
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
expect(plan[0]?.skip).toBe('paused');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('names every capability the consumer takes from this provider, once', () => {
|
|
103
|
+
const plan = planProviderBackfill(
|
|
104
|
+
'greenwave',
|
|
105
|
+
['firewall', 'dhcp_server'],
|
|
106
|
+
[consumer('caddy', { requires: ['firewall'], optional: ['dhcp_server'] })],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(plan).toHaveLength(1);
|
|
110
|
+
expect(plan[0]?.capabilityNames).toEqual(['dhcp_server', 'firewall']);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('is empty for a module that provides nothing', () => {
|
|
114
|
+
expect(
|
|
115
|
+
planProviderBackfill('forgejo', [], [consumer('caddy', { requires: ['firewall'] })]),
|
|
116
|
+
).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('is ordered by consumer id, so a failure is reproducible', () => {
|
|
120
|
+
const plan = planProviderBackfill(
|
|
121
|
+
'iptables',
|
|
122
|
+
['firewall'],
|
|
123
|
+
[consumer('zulu', { requires: ['firewall'] }), consumer('alpha', { requires: ['firewall'] })],
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(plan.map((t) => t.consumerId)).toEqual(['alpha', 'zulu']);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const silentLogger = (): HookLogger & { warnings: string[] } => {
|
|
131
|
+
const warnings: string[] = [];
|
|
132
|
+
return {
|
|
133
|
+
warnings,
|
|
134
|
+
info: () => {},
|
|
135
|
+
warn: (m: string) => warnings.push(m),
|
|
136
|
+
error: () => {},
|
|
137
|
+
success: () => {},
|
|
138
|
+
debug: () => {},
|
|
139
|
+
} as unknown as HookLogger & { warnings: string[] };
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const ok = (): RunNamedHookResult => ({ success: true }) as RunNamedHookResult;
|
|
143
|
+
const fails = (error: string): RunNamedHookResult =>
|
|
144
|
+
({ success: false, error }) as RunNamedHookResult;
|
|
145
|
+
|
|
146
|
+
describe('runProviderBackfill', () => {
|
|
147
|
+
/**
|
|
148
|
+
* The property the whole thing exists for. Stopping at the first failure
|
|
149
|
+
* would leave the remaining consumers unregistered against a provider that is
|
|
150
|
+
* now live — the same gap, just narrower.
|
|
151
|
+
*/
|
|
152
|
+
it('attempts every consumer even after one fails, and names each failure', async () => {
|
|
153
|
+
const attempted: string[] = [];
|
|
154
|
+
const result = await runProviderBackfill(
|
|
155
|
+
'iptables',
|
|
156
|
+
[
|
|
157
|
+
{ consumerId: 'alpha', capabilityNames: ['firewall'] },
|
|
158
|
+
{ consumerId: 'bravo', capabilityNames: ['firewall'] },
|
|
159
|
+
{ consumerId: 'charlie', capabilityNames: ['firewall'] },
|
|
160
|
+
],
|
|
161
|
+
{} as never,
|
|
162
|
+
silentLogger(),
|
|
163
|
+
async (id) => {
|
|
164
|
+
attempted.push(id);
|
|
165
|
+
return id === 'bravo' ? fails('ssh timed out') : ok();
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
expect(attempted).toEqual(['alpha', 'bravo', 'charlie']);
|
|
170
|
+
expect(result.rerun).toEqual(['alpha', 'charlie']);
|
|
171
|
+
expect(result.failures).toEqual([{ consumerId: 'bravo', error: 'ssh timed out' }]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('names the consumer and the retry command in the warning an operator reads', async () => {
|
|
175
|
+
const logger = silentLogger();
|
|
176
|
+
await runProviderBackfill(
|
|
177
|
+
'iptables',
|
|
178
|
+
[{ consumerId: 'bravo', capabilityNames: ['firewall'] }],
|
|
179
|
+
{} as never,
|
|
180
|
+
logger,
|
|
181
|
+
async () => fails('ssh timed out'),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
expect(logger.warnings.join('\n')).toContain('bravo');
|
|
185
|
+
expect(logger.warnings.join('\n')).toContain('celilo module deploy bravo');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('does not run a skipped consumer at all', async () => {
|
|
189
|
+
const attempted: string[] = [];
|
|
190
|
+
const result = await runProviderBackfill(
|
|
191
|
+
'iptables',
|
|
192
|
+
[
|
|
193
|
+
{ consumerId: 'paused', capabilityNames: ['firewall'], skip: 'paused' },
|
|
194
|
+
{ consumerId: 'fresh', capabilityNames: ['firewall'], skip: 'not-deployed' },
|
|
195
|
+
],
|
|
196
|
+
{} as never,
|
|
197
|
+
silentLogger(),
|
|
198
|
+
async (id) => {
|
|
199
|
+
attempted.push(id);
|
|
200
|
+
return ok();
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(attempted).toEqual([]);
|
|
205
|
+
expect(result.skipped).toEqual([
|
|
206
|
+
{ consumerId: 'paused', reason: 'paused' },
|
|
207
|
+
{ consumerId: 'fresh', reason: 'not-deployed' },
|
|
208
|
+
]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* `runNamedHook` reports a paused module as a plain success, so trusting the
|
|
213
|
+
* flag alone would log a re-registration that never happened — the silence
|
|
214
|
+
* this change exists to end, reintroduced one layer down.
|
|
215
|
+
*/
|
|
216
|
+
it('reads skippedPaused rather than trusting success, for a module paused mid-dispatch', async () => {
|
|
217
|
+
const result = await runProviderBackfill(
|
|
218
|
+
'iptables',
|
|
219
|
+
[{ consumerId: 'racer', capabilityNames: ['firewall'] }],
|
|
220
|
+
{} as never,
|
|
221
|
+
silentLogger(),
|
|
222
|
+
async () => ({ success: true, skippedPaused: true }) as RunNamedHookResult,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
expect(result.rerun).toEqual([]);
|
|
226
|
+
expect(result.skipped).toEqual([{ consumerId: 'racer', reason: 'paused' }]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('does not count a consumer with no on_install as re-registered', async () => {
|
|
230
|
+
const result = await runProviderBackfill(
|
|
231
|
+
'iptables',
|
|
232
|
+
[{ consumerId: 'hookless', capabilityNames: ['firewall'] }],
|
|
233
|
+
{} as never,
|
|
234
|
+
silentLogger(),
|
|
235
|
+
async () => ({ success: true, notDefined: true }) as RunNamedHookResult,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
expect(result.rerun).toEqual([]);
|
|
239
|
+
expect(result.failures).toEqual([]);
|
|
240
|
+
});
|
|
241
|
+
});
|