@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.
@@ -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 approvalStatus = checkAspectApproval(moduleId, moduleRow.version, aspect, db);
521
-
522
- // Already-approved → run. Already-DENIED → skip silently: the operator
523
- // made a durable decision; re-prompting every deploy would be nagging
524
- // (ISS-0027). Only the undecided ('no_approval') and stale ('scope_changed')
525
- // states warrant an interview.
526
- if (approvalStatus === 'denied') {
527
- return { ran: false, reason: 'denied' };
528
- }
529
- if (approvalStatus === 'no_approval' || approvalStatus === 'scope_changed') {
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
+ }