@celilo/cli 1.2.0 → 1.4.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 -2
- package/CELILO_SUBSYSTEMS.md +4 -1
- package/package.json +4 -4
- package/src/cli/commands/ipam.ts +93 -0
- package/src/cli/commands/machine-add.ts +22 -0
- package/src/cli/commands/system-doctor.ts +118 -4
- package/src/cli/completion.ts +38 -5
- package/src/cli/index.ts +10 -1
- package/src/hooks/types.ts +20 -74
- package/src/ipam/allocator.test.ts +38 -0
- package/src/ipam/allocator.ts +63 -1
- package/src/ipam/auto-allocator.ts +7 -0
- package/src/manifest/contracts/v1.ts +35 -0
- package/src/manifest/schema.ts +2 -0
- package/src/policy/module-business-baseline.ts +51 -35
- package/src/services/aspect-reconcile.test.ts +460 -0
- package/src/services/aspect-runner.test.ts +1 -0
- package/src/services/aspect-runner.ts +408 -37
- package/src/services/deploy-ansible-recap.test.ts +76 -0
- package/src/services/deploy-ansible.ts +56 -1
- package/src/services/module-deploy.ts +66 -12
- package/src/templates/ingress-ip.test.ts +108 -0
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
EVENT_TYPES,
|
|
48
48
|
busInterviewGuarded,
|
|
49
49
|
} from './bus-interview';
|
|
50
|
-
import { executeAnsible } from './deploy-ansible';
|
|
50
|
+
import { type AnsibleHostRecap, executeAnsible, parseAnsibleRecap } from './deploy-ansible';
|
|
51
51
|
import { getContainerSystemsInZones } from './deployed-systems';
|
|
52
52
|
import { getSystemsByZone } from './machine-pool';
|
|
53
53
|
import { executeProxmoxReconcile, planProxmoxReconcile } from './proxmox-reconcile';
|
|
@@ -89,6 +89,12 @@ export interface AspectRunResult {
|
|
|
89
89
|
error?: string;
|
|
90
90
|
/** The plan that was executed, for caller logging / event emission. */
|
|
91
91
|
plan: AspectFanOutPlan;
|
|
92
|
+
/**
|
|
93
|
+
* Per-host `PLAY RECAP` counters. Empty when Ansible produced no recap — a
|
|
94
|
+
* state callers must NOT read as success. `verifyAspectCoverage` classifies
|
|
95
|
+
* from this.
|
|
96
|
+
*/
|
|
97
|
+
recap: AnsibleHostRecap[];
|
|
92
98
|
}
|
|
93
99
|
|
|
94
100
|
export interface AspectRunOptions {
|
|
@@ -101,6 +107,21 @@ export interface AspectRunOptions {
|
|
|
101
107
|
* they want it, the framework doesn't force a skip).
|
|
102
108
|
*/
|
|
103
109
|
excludeHostnames?: string[];
|
|
110
|
+
/**
|
|
111
|
+
* Restrict the fan-out to exactly these hostnames, intersected with the
|
|
112
|
+
* zone-derived target set. Used by the inbound reconcile
|
|
113
|
+
* (`reconcileAspectsForSystems`), which applies an aspect to the systems a
|
|
114
|
+
* deploy just created rather than to the whole fleet. Narrows only — a
|
|
115
|
+
* hostname the aspect's zones do not cover is still not a target.
|
|
116
|
+
*/
|
|
117
|
+
onlyHostnames?: string[];
|
|
118
|
+
/**
|
|
119
|
+
* Run Ansible in CHECK mode: evaluate the role against each target without
|
|
120
|
+
* changing anything. Used by `verifyAspectCoverage` to ask the HOST whether
|
|
121
|
+
* an aspect is applied, rather than consulting a stored claim that it once
|
|
122
|
+
* ran (celilo#902 design D6).
|
|
123
|
+
*/
|
|
124
|
+
check?: boolean;
|
|
104
125
|
/**
|
|
105
126
|
* Override for noInteractive mode passed to executeAnsible.
|
|
106
127
|
* Defaults to `true` because aspect fan-outs run as part of
|
|
@@ -117,8 +138,12 @@ export interface AspectRunOptions {
|
|
|
117
138
|
*/
|
|
118
139
|
export async function planAspectFanOut(
|
|
119
140
|
aspect: BaseModuleAspect,
|
|
120
|
-
options: Pick<AspectRunOptions, 'excludeHostnames'> = {},
|
|
141
|
+
options: Pick<AspectRunOptions, 'excludeHostnames' | 'onlyHostnames'> = {},
|
|
121
142
|
): Promise<AspectFanOutPlan> {
|
|
143
|
+
// `onlyHostnames` narrows, never widens: it is intersected with the
|
|
144
|
+
// zone-derived set below rather than replacing it, so an inbound reconcile
|
|
145
|
+
// cannot apply an aspect to a system outside its `applicable_zones`.
|
|
146
|
+
const only = options.onlyHostnames ? new Set(options.onlyHostnames) : undefined;
|
|
122
147
|
// Machine-pool systems in the zones (api_only recorded as skips for
|
|
123
148
|
// observability). excludeHostnames is applied by getSystemsByZone.
|
|
124
149
|
const allInZones = await getSystemsByZone(aspect.applicable_zones, {
|
|
@@ -134,6 +159,7 @@ export async function planAspectFanOut(
|
|
|
134
159
|
skipped.push({ machine: m, reason: 'api_only' });
|
|
135
160
|
continue;
|
|
136
161
|
}
|
|
162
|
+
if (only && !only.has(m.hostname)) continue;
|
|
137
163
|
targetSystems.push({
|
|
138
164
|
hostname: m.hostname,
|
|
139
165
|
ipAddress: m.ipAddress,
|
|
@@ -151,6 +177,7 @@ export async function planAspectFanOut(
|
|
|
151
177
|
const exclude = new Set(options.excludeHostnames ?? []);
|
|
152
178
|
for (const sys of getContainerSystemsInZones(aspect.applicable_zones, getDb())) {
|
|
153
179
|
if (exclude.has(sys.hostname) || seen.has(sys.hostname)) continue;
|
|
180
|
+
if (only && !only.has(sys.hostname)) continue;
|
|
154
181
|
targetSystems.push({
|
|
155
182
|
hostname: sys.hostname,
|
|
156
183
|
ipAddress: sys.ipv4_address,
|
|
@@ -326,13 +353,14 @@ export async function runAspectFanOut(args: {
|
|
|
326
353
|
|
|
327
354
|
const plan = await planAspectFanOut(aspect, {
|
|
328
355
|
excludeHostnames: options.excludeHostnames,
|
|
356
|
+
onlyHostnames: options.onlyHostnames,
|
|
329
357
|
});
|
|
330
358
|
|
|
331
359
|
if (plan.targetSystems.length === 0) {
|
|
332
360
|
log.info(
|
|
333
361
|
`Aspect fan-out for '${moduleId}' (${options.trigger}): no eligible systems in zones [${aspect.applicable_zones.join(', ')}]`,
|
|
334
362
|
);
|
|
335
|
-
return { success: true, output: '', plan };
|
|
363
|
+
return { success: true, output: '', plan, recap: [] };
|
|
336
364
|
}
|
|
337
365
|
|
|
338
366
|
log.info(
|
|
@@ -351,6 +379,7 @@ export async function runAspectFanOut(args: {
|
|
|
351
379
|
|
|
352
380
|
const result = await executeAnsible(workDir, {
|
|
353
381
|
noInteractive: options.noInteractive ?? true,
|
|
382
|
+
check: options.check,
|
|
354
383
|
});
|
|
355
384
|
|
|
356
385
|
// Proxmox reconciliation (D5): if the aspect declares
|
|
@@ -386,6 +415,7 @@ export async function runAspectFanOut(args: {
|
|
|
386
415
|
output: result.output,
|
|
387
416
|
error: result.error,
|
|
388
417
|
plan,
|
|
418
|
+
recap: parseAnsibleRecap(result.output),
|
|
389
419
|
};
|
|
390
420
|
} finally {
|
|
391
421
|
if (workDir) {
|
|
@@ -458,6 +488,61 @@ export interface AspectGlueResult {
|
|
|
458
488
|
runResult?: AspectRunResult;
|
|
459
489
|
}
|
|
460
490
|
|
|
491
|
+
/**
|
|
492
|
+
* The consent gate, shared by both fan-out directions.
|
|
493
|
+
*
|
|
494
|
+
* Already-approved → run. Already-DENIED → skip silently: the operator made a
|
|
495
|
+
* durable decision and re-prompting every deploy would be nagging (ISS-0027).
|
|
496
|
+
* Only the undecided ('no_approval') and stale ('scope_changed') states warrant
|
|
497
|
+
* an interview, and the decision is persisted either way so a denial isn't
|
|
498
|
+
* re-asked next deploy.
|
|
499
|
+
*
|
|
500
|
+
* Extracted so `reconcileAspectsForSystems` cannot drift from
|
|
501
|
+
* `maybeRunAspectForTrigger`: an inbound reconcile that skipped this would
|
|
502
|
+
* apply an aspect the operator never approved, or one they refused.
|
|
503
|
+
*/
|
|
504
|
+
async function ensureAspectConsent(args: {
|
|
505
|
+
moduleId: string;
|
|
506
|
+
version: string;
|
|
507
|
+
aspect: BaseModuleAspect;
|
|
508
|
+
trigger: BaseModuleAspectTrigger;
|
|
509
|
+
db: DbClient;
|
|
510
|
+
requestConsent?: AspectConsentRequest;
|
|
511
|
+
}): Promise<{ consented: true } | { consented: false; reason: AspectSkipReason }> {
|
|
512
|
+
const { moduleId, version, aspect, trigger, db } = args;
|
|
513
|
+
const approvalStatus = checkAspectApproval(moduleId, version, aspect, db);
|
|
514
|
+
|
|
515
|
+
if (approvalStatus === 'denied') {
|
|
516
|
+
return { consented: false, reason: 'denied' };
|
|
517
|
+
}
|
|
518
|
+
if (approvalStatus === 'no_approval' || approvalStatus === 'scope_changed') {
|
|
519
|
+
// ISS-0027: don't silently skip a declared aspect. Interview the operator
|
|
520
|
+
// for consent on the bus and WAIT. A responder (terminal, `events reply`,
|
|
521
|
+
// the celilo-deploy skill) approves or denies.
|
|
522
|
+
const requestConsent = args.requestConsent ?? requestAspectConsentViaBus;
|
|
523
|
+
const consented = await requestConsent({
|
|
524
|
+
moduleId,
|
|
525
|
+
version,
|
|
526
|
+
aspect,
|
|
527
|
+
trigger,
|
|
528
|
+
reason: approvalStatus,
|
|
529
|
+
});
|
|
530
|
+
recordAspectConsent({
|
|
531
|
+
moduleId,
|
|
532
|
+
version,
|
|
533
|
+
scopeHash: computeAspectScopeHash(aspect),
|
|
534
|
+
approver: process.env.USER ?? null,
|
|
535
|
+
consented,
|
|
536
|
+
db,
|
|
537
|
+
});
|
|
538
|
+
if (!consented) {
|
|
539
|
+
log.warn(`Aspect for '${moduleId}' consent refused; aspect skipped (will not re-prompt).`);
|
|
540
|
+
return { consented: false, reason: 'denied' };
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return { consented: true };
|
|
544
|
+
}
|
|
545
|
+
|
|
461
546
|
/**
|
|
462
547
|
* Deploy-flow glue (SC4): consulted by `module-deploy.ts` after a
|
|
463
548
|
* primary deploy successfully completes. Decides whether to fan an
|
|
@@ -517,40 +602,16 @@ export async function maybeRunAspectForTrigger(args: {
|
|
|
517
602
|
return { ran: false, reason: 'no_approval' };
|
|
518
603
|
}
|
|
519
604
|
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
// ISS-0027: don't silently skip a declared aspect. Interview the operator
|
|
531
|
-
// for consent on the bus and WAIT. A responder (terminal, `events reply`,
|
|
532
|
-
// the celilo-deploy skill) approves or denies. We persist the decision —
|
|
533
|
-
// either way — so a denial isn't re-asked next deploy.
|
|
534
|
-
const requestConsent = args.requestConsent ?? requestAspectConsentViaBus;
|
|
535
|
-
const consented = await requestConsent({
|
|
536
|
-
moduleId,
|
|
537
|
-
version: moduleRow.version,
|
|
538
|
-
aspect,
|
|
539
|
-
trigger,
|
|
540
|
-
reason: approvalStatus,
|
|
541
|
-
});
|
|
542
|
-
recordAspectConsent({
|
|
543
|
-
moduleId,
|
|
544
|
-
version: moduleRow.version,
|
|
545
|
-
scopeHash: computeAspectScopeHash(aspect),
|
|
546
|
-
approver: process.env.USER ?? null,
|
|
547
|
-
consented,
|
|
548
|
-
db,
|
|
549
|
-
});
|
|
550
|
-
if (!consented) {
|
|
551
|
-
log.warn(`Aspect for '${moduleId}' consent refused; aspect skipped (will not re-prompt).`);
|
|
552
|
-
return { ran: false, reason: 'denied' };
|
|
553
|
-
}
|
|
605
|
+
const consent = await ensureAspectConsent({
|
|
606
|
+
moduleId,
|
|
607
|
+
version: moduleRow.version,
|
|
608
|
+
aspect,
|
|
609
|
+
trigger,
|
|
610
|
+
db,
|
|
611
|
+
requestConsent: args.requestConsent,
|
|
612
|
+
});
|
|
613
|
+
if (!consent.consented) {
|
|
614
|
+
return { ran: false, reason: consent.reason };
|
|
554
615
|
}
|
|
555
616
|
|
|
556
617
|
const runner = args.runner ?? runAspectFanOut;
|
|
@@ -566,3 +627,313 @@ export async function maybeRunAspectForTrigger(args: {
|
|
|
566
627
|
});
|
|
567
628
|
return { ran: true, success: runResult.success, runResult };
|
|
568
629
|
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* A system the inbound reconcile may apply aspects to. Deliberately just a
|
|
633
|
+
* hostname and a zone: the reconcile decides eligibility from the zone and
|
|
634
|
+
* hands the hostname to `planAspectFanOut`, which already knows how to reach
|
|
635
|
+
* both a pool machine and a container_service LXC.
|
|
636
|
+
*/
|
|
637
|
+
export interface AspectReconcileSystem {
|
|
638
|
+
hostname: string;
|
|
639
|
+
/**
|
|
640
|
+
* `string`, not `NetworkZone`, to match what everything on this path already
|
|
641
|
+
* uses: `aspect.applicable_zones` is `string[]` from the manifest schema,
|
|
642
|
+
* `getSystemsByZone` takes `string[]`, and `DeployedSystem.zone` — the
|
|
643
|
+
* capability contract this is fed from — is `string`. Narrowing here would
|
|
644
|
+
* only add a cast at every call site.
|
|
645
|
+
*/
|
|
646
|
+
zone: string;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** One provider's aspect, applied (or not) to the systems it covered. */
|
|
650
|
+
export interface AspectReconcileOutcome {
|
|
651
|
+
/** The module whose aspect this is — NOT the module being deployed. */
|
|
652
|
+
providerModuleId: string;
|
|
653
|
+
role: string;
|
|
654
|
+
/** The subset of the offered systems this aspect's zones actually cover. */
|
|
655
|
+
hostnames: string[];
|
|
656
|
+
ran: boolean;
|
|
657
|
+
success: boolean;
|
|
658
|
+
error?: string;
|
|
659
|
+
/** Set when `ran === false`. */
|
|
660
|
+
reason?: AspectSkipReason | 'paused' | 'not_deployed' | 'no_covered_systems';
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
export interface AspectReconcileResult {
|
|
664
|
+
outcomes: AspectReconcileOutcome[];
|
|
665
|
+
/** The subset that ran and failed — what a caller decides fatality on. */
|
|
666
|
+
failures: AspectReconcileOutcome[];
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* The INBOUND direction: given systems that have just come into existence,
|
|
671
|
+
* apply every approved aspect the fleet already provides that covers their
|
|
672
|
+
* zones.
|
|
673
|
+
*
|
|
674
|
+
* This is the fix for celilo#902. `maybeRunAspectForTrigger` is provider-scoped
|
|
675
|
+
* — one module's aspect across the whole fleet, enumerated at that instant —
|
|
676
|
+
* and nothing re-runs it, so a system provisioned later never receives an
|
|
677
|
+
* aspect and nothing reports the gap.
|
|
678
|
+
*
|
|
679
|
+
* TWO THINGS ARE DELIBERATELY NOT CONSULTED HERE, and both are load-bearing:
|
|
680
|
+
*
|
|
681
|
+
* 1. `aspect.triggers` (design D2). Eligibility is `applicable_zones` plus
|
|
682
|
+
* operator approval, full stop. `triggers` declares which PROVIDER-side
|
|
683
|
+
* events cause a fleet-wide fan-out; a system joining a zone is a framework
|
|
684
|
+
* event, not a provider event, and the spec's scenario does not condition
|
|
685
|
+
* on it either ("every approved aspect whose `applicable_zones` includes
|
|
686
|
+
* that zone SHALL run on the new system"). Requiring modules to declare
|
|
687
|
+
* `on_new_system_in_zone` would change every aspect's scope hash — raising
|
|
688
|
+
* a re-approval interview on the live fleet as a side effect of a bug fix —
|
|
689
|
+
* and would make correct convergence opt-in, so the next module that forgot
|
|
690
|
+
* the declaration would reintroduce this bug quietly.
|
|
691
|
+
* 2. The deploying module's own aspect. `on_install` already fans that one out
|
|
692
|
+
* across the whole fleet, so the caller passes it in `excludeModuleIds`.
|
|
693
|
+
*
|
|
694
|
+
* PAUSED PROVIDERS ARE SKIPPED (design D4a), and that is the documented escape
|
|
695
|
+
* hatch: an inbound aspect failure is fatal to the deploy that created the
|
|
696
|
+
* system, so an operator whose non-essential aspect is wedging every deploy
|
|
697
|
+
* pauses its provider, deploys, and unpauses.
|
|
698
|
+
*
|
|
699
|
+
* Consent is checked exactly as the outbound direction checks it, through the
|
|
700
|
+
* shared `ensureAspectConsent`.
|
|
701
|
+
*/
|
|
702
|
+
export async function reconcileAspectsForSystems(args: {
|
|
703
|
+
systems: AspectReconcileSystem[];
|
|
704
|
+
db: DbClient;
|
|
705
|
+
/** Providers to skip — the deploying module, whose own aspect already ran. */
|
|
706
|
+
excludeModuleIds?: string[];
|
|
707
|
+
runner?: typeof runAspectFanOut;
|
|
708
|
+
requestConsent?: AspectConsentRequest;
|
|
709
|
+
}): Promise<AspectReconcileResult> {
|
|
710
|
+
const { systems, db } = args;
|
|
711
|
+
const outcomes: AspectReconcileOutcome[] = [];
|
|
712
|
+
if (systems.length === 0) return { outcomes, failures: [] };
|
|
713
|
+
|
|
714
|
+
const exclude = new Set(args.excludeModuleIds ?? []);
|
|
715
|
+
const runner = args.runner ?? runAspectFanOut;
|
|
716
|
+
|
|
717
|
+
for (const moduleRow of db.select().from(modules).all()) {
|
|
718
|
+
if (exclude.has(moduleRow.id)) continue;
|
|
719
|
+
|
|
720
|
+
const manifest = moduleRow.manifestData as ModuleManifest | null;
|
|
721
|
+
const aspect = manifest?.base_module_aspect;
|
|
722
|
+
if (!aspect) continue;
|
|
723
|
+
|
|
724
|
+
const base = { providerModuleId: moduleRow.id, role: aspect.ansible_role };
|
|
725
|
+
|
|
726
|
+
// A module that never deployed has nothing to fan out FROM — its
|
|
727
|
+
// ansible_vars resolve against infrastructure that does not exist.
|
|
728
|
+
if (moduleRow.state !== 'INSTALLED' && moduleRow.state !== 'VERIFIED') {
|
|
729
|
+
if (moduleRow.state === 'PAUSED') {
|
|
730
|
+
// Reported rather than silent: pausing to get past a wedged aspect is
|
|
731
|
+
// legitimate, but it must not quietly become permanent.
|
|
732
|
+
log.warn(
|
|
733
|
+
`Aspect '${aspect.ansible_role}' from paused module '${moduleRow.id}' NOT applied to ${systems.map((s) => s.hostname).join(', ')}. Unpause and redeploy it to converge them.`,
|
|
734
|
+
);
|
|
735
|
+
outcomes.push({ ...base, hostnames: [], ran: false, success: true, reason: 'paused' });
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
outcomes.push({ ...base, hostnames: [], ran: false, success: true, reason: 'not_deployed' });
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const zones = new Set<string>(aspect.applicable_zones);
|
|
743
|
+
const covered = systems.filter((s) => zones.has(s.zone));
|
|
744
|
+
if (covered.length === 0) {
|
|
745
|
+
outcomes.push({
|
|
746
|
+
...base,
|
|
747
|
+
hostnames: [],
|
|
748
|
+
ran: false,
|
|
749
|
+
success: true,
|
|
750
|
+
reason: 'no_covered_systems',
|
|
751
|
+
});
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const consent = await ensureAspectConsent({
|
|
756
|
+
moduleId: moduleRow.id,
|
|
757
|
+
version: moduleRow.version,
|
|
758
|
+
aspect,
|
|
759
|
+
trigger: 'on_new_system_in_zone',
|
|
760
|
+
db,
|
|
761
|
+
requestConsent: args.requestConsent,
|
|
762
|
+
});
|
|
763
|
+
if (!consent.consented) {
|
|
764
|
+
outcomes.push({
|
|
765
|
+
...base,
|
|
766
|
+
hostnames: covered.map((s) => s.hostname),
|
|
767
|
+
ran: false,
|
|
768
|
+
success: true,
|
|
769
|
+
reason: consent.reason,
|
|
770
|
+
});
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const hostnames = covered.map((s) => s.hostname);
|
|
775
|
+
log.info(
|
|
776
|
+
`Applying aspect '${aspect.ansible_role}' from '${moduleRow.id}' to newly created system(s): ${hostnames.join(', ')}`,
|
|
777
|
+
);
|
|
778
|
+
const runResult = await runner({
|
|
779
|
+
moduleId: moduleRow.id,
|
|
780
|
+
aspect,
|
|
781
|
+
moduleSourcePath: moduleRow.sourcePath,
|
|
782
|
+
options: { trigger: 'on_new_system_in_zone', onlyHostnames: hostnames },
|
|
783
|
+
db,
|
|
784
|
+
});
|
|
785
|
+
outcomes.push({
|
|
786
|
+
...base,
|
|
787
|
+
hostnames,
|
|
788
|
+
ran: true,
|
|
789
|
+
success: runResult.success,
|
|
790
|
+
error: runResult.error,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return { outcomes, failures: outcomes.filter((o) => o.ran && !o.success) };
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* How an aspect stands on one system, as MEASURED against that system.
|
|
799
|
+
*
|
|
800
|
+
* `unknown` is not a hedge and must not be collapsed into `applied`. Ansible's
|
|
801
|
+
* check mode does not evaluate a task that cannot support it — it SKIPS it —
|
|
802
|
+
* so a role built from `command:` / `shell:` tasks can finish a check run with
|
|
803
|
+
* `changed=0` having never been applied to the host at all. A two-state answer
|
|
804
|
+
* would report that as applied: a confidently clean verdict about an
|
|
805
|
+
* unconverged system, which is the same failure this whole approach exists to
|
|
806
|
+
* avoid (celilo#902 design D6). Absence of a change is not evidence of
|
|
807
|
+
* convergence when nothing was assessed.
|
|
808
|
+
*/
|
|
809
|
+
export type AspectCoverageState = 'applied' | 'missing' | 'unknown' | 'unreachable';
|
|
810
|
+
|
|
811
|
+
export interface AspectCoverageFinding {
|
|
812
|
+
providerModuleId: string;
|
|
813
|
+
role: string;
|
|
814
|
+
hostname: string;
|
|
815
|
+
state: AspectCoverageState;
|
|
816
|
+
/** Why, in operator-facing words. Always set for anything but 'applied'. */
|
|
817
|
+
detail?: string;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Ask every system the fleet's approved aspects entitle it to whether it
|
|
822
|
+
* actually has them (celilo#902 design D6/D7).
|
|
823
|
+
*
|
|
824
|
+
* NOTHING IS STORED AND NOTHING IS READ FROM A RECORD. Both halves of the
|
|
825
|
+
* question are answered from live state:
|
|
826
|
+
*
|
|
827
|
+
* - "should have" is `planAspectFanOut(aspect)` — literally the code that
|
|
828
|
+
* performs the fan-out, so the entitlement set cannot disagree with it. No
|
|
829
|
+
* separate query to drift.
|
|
830
|
+
* - "does have" is the aspect evaluated against the host in check mode. A row
|
|
831
|
+
* saying "applied" would be a claim about a remote host, and celilo has been
|
|
832
|
+
* burned by trusting exactly that (celilo#626: `dns_registrations` rows held
|
|
833
|
+
* a pinned `ip` and the refresher republished a dead address over correct
|
|
834
|
+
* public DNS, with every in-fleet check green).
|
|
835
|
+
*
|
|
836
|
+
* This SSHes to every entitled system, so it is not cheap and must not run in a
|
|
837
|
+
* default `system doctor` pass — it is gated behind `--deep`.
|
|
838
|
+
*
|
|
839
|
+
* Consent is checked but never REQUESTED: a read-only diagnostic must not raise
|
|
840
|
+
* an interview. An aspect that is unapproved, denied, or whose scope changed is
|
|
841
|
+
* simply not verified.
|
|
842
|
+
*/
|
|
843
|
+
export async function verifyAspectCoverage(args: {
|
|
844
|
+
db: DbClient;
|
|
845
|
+
/** Restrict to one provider — `doctor --deep <module>`. */
|
|
846
|
+
onlyModuleId?: string;
|
|
847
|
+
/** Injected in tests, exactly as the other entry points here do it. */
|
|
848
|
+
runner?: typeof runAspectFanOut;
|
|
849
|
+
}): Promise<AspectCoverageFinding[]> {
|
|
850
|
+
const { db } = args;
|
|
851
|
+
const runner = args.runner ?? runAspectFanOut;
|
|
852
|
+
const findings: AspectCoverageFinding[] = [];
|
|
853
|
+
|
|
854
|
+
for (const moduleRow of db.select().from(modules).all()) {
|
|
855
|
+
if (args.onlyModuleId && moduleRow.id !== args.onlyModuleId) continue;
|
|
856
|
+
|
|
857
|
+
const manifest = moduleRow.manifestData as ModuleManifest | null;
|
|
858
|
+
const aspect = manifest?.base_module_aspect;
|
|
859
|
+
if (!aspect) continue;
|
|
860
|
+
if (moduleRow.state !== 'INSTALLED' && moduleRow.state !== 'VERIFIED') {
|
|
861
|
+
// A paused provider's entitled systems are still reported (D4a) — pausing
|
|
862
|
+
// to get past a wedged aspect is legitimate, but it must not quietly
|
|
863
|
+
// become permanent. Reported without a probe: the provider is not running
|
|
864
|
+
// and re-running its role against every host would be a change, not a read.
|
|
865
|
+
if (moduleRow.state === 'PAUSED') {
|
|
866
|
+
const plan = await planAspectFanOut(aspect);
|
|
867
|
+
for (const target of plan.targetSystems) {
|
|
868
|
+
findings.push({
|
|
869
|
+
providerModuleId: moduleRow.id,
|
|
870
|
+
role: aspect.ansible_role,
|
|
871
|
+
hostname: target.hostname,
|
|
872
|
+
state: 'unknown',
|
|
873
|
+
detail: `'${moduleRow.id}' is PAUSED, so its aspect is not being applied to new systems. Unpause and redeploy it to converge.`,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
if (checkAspectApproval(moduleRow.id, moduleRow.version, aspect, db) !== 'approved') continue;
|
|
880
|
+
|
|
881
|
+
const plan = await planAspectFanOut(aspect);
|
|
882
|
+
if (plan.targetSystems.length === 0) continue;
|
|
883
|
+
|
|
884
|
+
const result = await runner({
|
|
885
|
+
moduleId: moduleRow.id,
|
|
886
|
+
aspect,
|
|
887
|
+
moduleSourcePath: moduleRow.sourcePath,
|
|
888
|
+
options: { trigger: 'on_new_system_in_zone', check: true, noInteractive: true },
|
|
889
|
+
db,
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
const byHost = new Map(result.recap.map((r) => [r.host, r]));
|
|
893
|
+
for (const target of plan.targetSystems) {
|
|
894
|
+
const base = {
|
|
895
|
+
providerModuleId: moduleRow.id,
|
|
896
|
+
role: aspect.ansible_role,
|
|
897
|
+
hostname: target.hostname,
|
|
898
|
+
};
|
|
899
|
+
const recap = byHost.get(target.hostname);
|
|
900
|
+
if (!recap) {
|
|
901
|
+
findings.push({
|
|
902
|
+
...base,
|
|
903
|
+
state: 'unknown',
|
|
904
|
+
detail:
|
|
905
|
+
'Ansible produced no recap line for this host, so nothing was measured. Absence of a recap is not evidence the aspect is applied.',
|
|
906
|
+
});
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
if (recap.unreachable > 0 || recap.failed > 0) {
|
|
910
|
+
findings.push({
|
|
911
|
+
...base,
|
|
912
|
+
state: 'unreachable',
|
|
913
|
+
detail: 'The host could not be evaluated — it did not answer, or the role errored on it.',
|
|
914
|
+
});
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
917
|
+
if (recap.skipped > 0) {
|
|
918
|
+
// THE CASE THAT MUST NOT READ AS APPLIED. See AspectCoverageState.
|
|
919
|
+
findings.push({
|
|
920
|
+
...base,
|
|
921
|
+
state: 'unknown',
|
|
922
|
+
detail: `The role has ${recap.skipped} task(s) check mode cannot evaluate, so convergence was not measured. Prefer check-capable Ansible modules (copy, template, lineinfile, file, package, service) in aspect roles, or give a command/shell task an honest changed_when:.`,
|
|
923
|
+
});
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
if (recap.changed > 0) {
|
|
927
|
+
findings.push({
|
|
928
|
+
...base,
|
|
929
|
+
state: 'missing',
|
|
930
|
+
detail: `The aspect would change ${recap.changed} thing(s) on this host, so it is not applied. Run \`celilo module deploy ${moduleRow.id}\` to converge it.`,
|
|
931
|
+
});
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
findings.push({ ...base, state: 'applied' });
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
return findings;
|
|
939
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parsing Ansible's PLAY RECAP, and the one classification that must not be got
|
|
3
|
+
* wrong.
|
|
4
|
+
*
|
|
5
|
+
* `--check` is how `verifyAspectCoverage` asks a HOST whether an aspect is
|
|
6
|
+
* applied, instead of consulting a stored claim that it once ran (celilo#902
|
|
7
|
+
* design D6). The trap is that check mode does not evaluate a task it cannot
|
|
8
|
+
* support — it SKIPS it — so a role built from `command:` / `shell:` tasks can
|
|
9
|
+
* finish a check run reporting `changed=0` having never been applied at all.
|
|
10
|
+
*
|
|
11
|
+
* Read as a boolean ("no changes, therefore applied") that is a confidently
|
|
12
|
+
* clean answer about an unconverged host: the same failure shape as the stored
|
|
13
|
+
* verdict this approach exists to avoid, arriving by a different route. Hence
|
|
14
|
+
* three outcomes, and hence this file.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, it } from 'bun:test';
|
|
18
|
+
import { parseAnsibleRecap } from './deploy-ansible';
|
|
19
|
+
|
|
20
|
+
const RECAP = `
|
|
21
|
+
PLAY RECAP *********************************************************************
|
|
22
|
+
caddy-int : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
|
|
23
|
+
vpn : ok=3 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
|
|
24
|
+
legacy-box : ok=1 changed=0 unreachable=0 failed=0 skipped=2 rescued=0 ignored=0
|
|
25
|
+
dead-host : ok=0 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
describe('parseAnsibleRecap', () => {
|
|
29
|
+
it('reads every host line with its counters', () => {
|
|
30
|
+
const recaps = parseAnsibleRecap(RECAP);
|
|
31
|
+
expect(recaps.map((r) => r.host)).toEqual(['caddy-int', 'vpn', 'legacy-box', 'dead-host']);
|
|
32
|
+
expect(recaps[0]).toEqual({
|
|
33
|
+
host: 'caddy-int',
|
|
34
|
+
ok: 3,
|
|
35
|
+
changed: 0,
|
|
36
|
+
unreachable: 0,
|
|
37
|
+
failed: 0,
|
|
38
|
+
skipped: 0,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('SKIPPED IS NOT UNCHANGED — the case that must never read as applied', () => {
|
|
43
|
+
// THE assertion this file exists for. `legacy-box` reports zero changes,
|
|
44
|
+
// which a two-state reading calls "applied". It is not: two of its tasks
|
|
45
|
+
// were never evaluated, so nothing about its convergence was measured.
|
|
46
|
+
// If this ever passes while a caller treats it as applied, celilo is once
|
|
47
|
+
// again confidently reporting a host it has not looked at.
|
|
48
|
+
const legacy = parseAnsibleRecap(RECAP).find((r) => r.host === 'legacy-box');
|
|
49
|
+
expect(legacy).toBeDefined();
|
|
50
|
+
expect(legacy?.changed).toBe(0);
|
|
51
|
+
expect(legacy?.skipped).toBeGreaterThan(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('separates a host that could not be reached from one that was measured clean', () => {
|
|
55
|
+
const recaps = parseAnsibleRecap(RECAP);
|
|
56
|
+
expect(recaps.find((r) => r.host === 'dead-host')?.unreachable).toBe(1);
|
|
57
|
+
expect(recaps.find((r) => r.host === 'caddy-int')?.unreachable).toBe(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('survives ANSI colour, which the progress filter leaves in', () => {
|
|
61
|
+
const coloured =
|
|
62
|
+
'\x1b[0;32mweb-01\x1b[0m : ok=5 changed=2 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0';
|
|
63
|
+
const [recap] = parseAnsibleRecap(coloured);
|
|
64
|
+
expect(recap.host).toBe('web-01');
|
|
65
|
+
expect(recap.changed).toBe(2);
|
|
66
|
+
expect(recap.skipped).toBe(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns nothing for output with no recap, rather than inventing a clean one', () => {
|
|
70
|
+
// A run that produced no recap measured nothing. Callers must not read an
|
|
71
|
+
// empty array as "every host is fine" — verifyAspectCoverage classifies a
|
|
72
|
+
// host with no recap line as unknown for exactly this reason.
|
|
73
|
+
expect(parseAnsibleRecap('fatal: could not connect\n')).toEqual([]);
|
|
74
|
+
expect(parseAnsibleRecap('')).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -138,7 +138,7 @@ function parseAnsibleLine(line: string): string | null {
|
|
|
138
138
|
*/
|
|
139
139
|
export async function executeAnsible(
|
|
140
140
|
generatedPath: string,
|
|
141
|
-
options?: { noInteractive?: boolean },
|
|
141
|
+
options?: { noInteractive?: boolean; check?: boolean },
|
|
142
142
|
): Promise<AnsibleResult> {
|
|
143
143
|
const ansibleDir = join(generatedPath, 'ansible');
|
|
144
144
|
const inventoryPath = join(ansibleDir, 'inventory', 'hosts.ini');
|
|
@@ -161,6 +161,11 @@ export async function executeAnsible(
|
|
|
161
161
|
shellEscape(inventoryPath),
|
|
162
162
|
'--vault-password-file',
|
|
163
163
|
shellEscape(passwordPath),
|
|
164
|
+
// `--check` evaluates the play without changing anything, so a caller
|
|
165
|
+
// can ask "is this already applied?" of the HOST rather than of a
|
|
166
|
+
// stored record of the host (celilo#902 design D6). One argument, not a
|
|
167
|
+
// second execution path — everything else about the run is identical.
|
|
168
|
+
...(options?.check ? ['--check'] : []),
|
|
164
169
|
shellEscape(playbookPath),
|
|
165
170
|
],
|
|
166
171
|
cwd: ansibleDir,
|
|
@@ -200,3 +205,53 @@ export async function executeAnsible(
|
|
|
200
205
|
await rm(tempDir, { recursive: true, force: true });
|
|
201
206
|
}
|
|
202
207
|
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* One host's line from Ansible's `PLAY RECAP`.
|
|
211
|
+
*
|
|
212
|
+
* web-01 : ok=5 changed=2 unreachable=0 failed=0 skipped=1 …
|
|
213
|
+
*
|
|
214
|
+
* `skipped` is the field that matters and the one it is easy not to look at. A
|
|
215
|
+
* task that does NOT support check mode is not evaluated — it is skipped, and
|
|
216
|
+
* reported as skipped rather than changed. So a role built from `command:` /
|
|
217
|
+
* `shell:` tasks finishes a `--check` run with `changed=0` having never been
|
|
218
|
+
* applied to the host at all. Read as a boolean that says "applied", which is a
|
|
219
|
+
* confidently clean answer about an unconverged system. Callers must treat
|
|
220
|
+
* `skipped > 0` as NOT MEASURED rather than as applied.
|
|
221
|
+
*/
|
|
222
|
+
export interface AnsibleHostRecap {
|
|
223
|
+
host: string;
|
|
224
|
+
ok: number;
|
|
225
|
+
changed: number;
|
|
226
|
+
unreachable: number;
|
|
227
|
+
failed: number;
|
|
228
|
+
skipped: number;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const RECAP_LINE =
|
|
232
|
+
/^(\S+)\s*:\s*ok=(\d+)\s+changed=(\d+)\s+unreachable=(\d+)\s+failed=(\d+)\s+skipped=(\d+)/;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Parse every host line out of an Ansible run's `PLAY RECAP`.
|
|
236
|
+
*
|
|
237
|
+
* Tolerant of ANSI colour and of the recap appearing anywhere in the stream,
|
|
238
|
+
* because the output here has been through a progress filter. Lines that are
|
|
239
|
+
* not recap lines are ignored rather than throwing — a run that produced no
|
|
240
|
+
* recap at all returns an empty array, which callers must not read as success.
|
|
241
|
+
*/
|
|
242
|
+
export function parseAnsibleRecap(output: string): AnsibleHostRecap[] {
|
|
243
|
+
const recaps: AnsibleHostRecap[] = [];
|
|
244
|
+
for (const raw of output.split('\n')) {
|
|
245
|
+
const match = RECAP_LINE.exec(raw.replace(ANSI_ESCAPE, '').trim());
|
|
246
|
+
if (!match) continue;
|
|
247
|
+
recaps.push({
|
|
248
|
+
host: match[1],
|
|
249
|
+
ok: Number(match[2]),
|
|
250
|
+
changed: Number(match[3]),
|
|
251
|
+
unreachable: Number(match[4]),
|
|
252
|
+
failed: Number(match[5]),
|
|
253
|
+
skipped: Number(match[6]),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return recaps;
|
|
257
|
+
}
|