@intentius/chant-lexicon-k8s 0.18.19 → 0.18.21

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.
@@ -108,6 +108,11 @@
108
108
  "kind": "property",
109
109
  "lexicon": "k8s"
110
110
  },
111
+ "AppProject_Status": {
112
+ "resourceType": "K8s::Argo::AppProject.status",
113
+ "kind": "property",
114
+ "lexicon": "k8s"
115
+ },
111
116
  "AppProject_SyncWindow": {
112
117
  "resourceType": "K8s::Argo::AppProject.syncWindows",
113
118
  "kind": "property",
@@ -142,6 +147,11 @@
142
147
  "kind": "property",
143
148
  "lexicon": "k8s"
144
149
  },
150
+ "ApplicationSet_Status": {
151
+ "resourceType": "K8s::Argo::ApplicationSet.status",
152
+ "kind": "property",
153
+ "lexicon": "k8s"
154
+ },
145
155
  "ApplicationSet_Strategy": {
146
156
  "resourceType": "K8s::Argo::ApplicationSet.strategy",
147
157
  "kind": "property",
@@ -182,6 +192,11 @@
182
192
  "kind": "property",
183
193
  "lexicon": "k8s"
184
194
  },
195
+ "Application_Status": {
196
+ "resourceType": "K8s::Argo::Application.status",
197
+ "kind": "property",
198
+ "lexicon": "k8s"
199
+ },
185
200
  "Application_SyncPolicy": {
186
201
  "resourceType": "K8s::Argo::Application.syncPolicy",
187
202
  "kind": "property",
@@ -284,6 +299,11 @@
284
299
  "kind": "property",
285
300
  "lexicon": "k8s"
286
301
  },
302
+ "CertificateRequest_Status": {
303
+ "resourceType": "K8s::CertManager::CertificateRequest.status",
304
+ "kind": "property",
305
+ "lexicon": "k8s"
306
+ },
287
307
  "CertificateSigningRequest": {
288
308
  "resourceType": "K8s::Certificates::CertificateSigningRequest",
289
309
  "kind": "resource",
@@ -333,6 +353,11 @@
333
353
  "kind": "property",
334
354
  "lexicon": "k8s"
335
355
  },
356
+ "Certificate_Status": {
357
+ "resourceType": "K8s::CertManager::Certificate.status",
358
+ "kind": "property",
359
+ "lexicon": "k8s"
360
+ },
336
361
  "Certificate_Subject": {
337
362
  "resourceType": "K8s::CertManager::Certificate.subject",
338
363
  "kind": "property",
@@ -355,6 +380,11 @@
355
380
  "kind": "property",
356
381
  "lexicon": "k8s"
357
382
  },
383
+ "Challenge_Status": {
384
+ "resourceType": "K8s::Acme::Challenge.status",
385
+ "kind": "property",
386
+ "lexicon": "k8s"
387
+ },
358
388
  "ClusterIssuer": {
359
389
  "resourceType": "K8s::CertManager::ClusterIssuer",
360
390
  "kind": "resource",
@@ -377,6 +407,11 @@
377
407
  "kind": "property",
378
408
  "lexicon": "k8s"
379
409
  },
410
+ "ClusterIssuer_Status": {
411
+ "resourceType": "K8s::CertManager::ClusterIssuer.status",
412
+ "kind": "property",
413
+ "lexicon": "k8s"
414
+ },
380
415
  "ClusterIssuer_Vault": {
381
416
  "resourceType": "K8s::CertManager::ClusterIssuer.vault",
382
417
  "kind": "property",
@@ -553,6 +588,11 @@
553
588
  "kind": "property",
554
589
  "lexicon": "k8s"
555
590
  },
591
+ "CrdbCluster_Status": {
592
+ "resourceType": "K8s::Crdb::CrdbCluster.status",
593
+ "kind": "property",
594
+ "lexicon": "k8s"
595
+ },
556
596
  "CrdbCluster_Toleration": {
557
597
  "resourceType": "K8s::Crdb::CrdbCluster.tolerations",
558
598
  "kind": "property",
@@ -820,6 +860,11 @@
820
860
  "kind": "property",
821
861
  "lexicon": "k8s"
822
862
  },
863
+ "GRPCRoute_Status": {
864
+ "resourceType": "K8s::Gateway::GRPCRoute.status",
865
+ "kind": "property",
866
+ "lexicon": "k8s"
867
+ },
823
868
  "Gateway": {
824
869
  "resourceType": "K8s::Gateway::Gateway",
825
870
  "kind": "resource",
@@ -839,6 +884,11 @@
839
884
  "kind": "property",
840
885
  "lexicon": "k8s"
841
886
  },
887
+ "GatewayClass_Status": {
888
+ "resourceType": "K8s::Gateway::GatewayClass.status",
889
+ "kind": "property",
890
+ "lexicon": "k8s"
891
+ },
842
892
  "Gateway_Address": {
843
893
  "resourceType": "K8s::Gateway::Gateway.addresses",
844
894
  "kind": "property",
@@ -854,6 +904,11 @@
854
904
  "kind": "property",
855
905
  "lexicon": "k8s"
856
906
  },
907
+ "Gateway_Status": {
908
+ "resourceType": "K8s::Gateway::Gateway.status",
909
+ "kind": "property",
910
+ "lexicon": "k8s"
911
+ },
857
912
  "GcsFaultToleranceOptions": {
858
913
  "resourceType": "K8s::Ray::RayCluster.gcsFaultToleranceOptions",
859
914
  "kind": "property",
@@ -903,6 +958,11 @@
903
958
  "kind": "property",
904
959
  "lexicon": "k8s"
905
960
  },
961
+ "HTTPRoute_Status": {
962
+ "resourceType": "K8s::Gateway::HTTPRoute.status",
963
+ "kind": "property",
964
+ "lexicon": "k8s"
965
+ },
906
966
  "HeadGroupSpec": {
907
967
  "resourceType": "K8s::Ray::RayCluster.headGroupSpec",
908
968
  "kind": "property",
@@ -1048,6 +1108,11 @@
1048
1108
  "kind": "property",
1049
1109
  "lexicon": "k8s"
1050
1110
  },
1111
+ "Issuer_Status": {
1112
+ "resourceType": "K8s::CertManager::Issuer.status",
1113
+ "kind": "property",
1114
+ "lexicon": "k8s"
1115
+ },
1051
1116
  "Issuer_Vault": {
1052
1117
  "resourceType": "K8s::CertManager::Issuer.vault",
1053
1118
  "kind": "property",
@@ -1334,6 +1399,11 @@
1334
1399
  "kind": "property",
1335
1400
  "lexicon": "k8s"
1336
1401
  },
1402
+ "Order_Status": {
1403
+ "resourceType": "K8s::Acme::Order.status",
1404
+ "kind": "property",
1405
+ "lexicon": "k8s"
1406
+ },
1337
1407
  "OrphanedResources": {
1338
1408
  "resourceType": "K8s::Argo::AppProject.orphanedResources",
1339
1409
  "kind": "property",
@@ -1623,6 +1693,11 @@
1623
1693
  "kind": "property",
1624
1694
  "lexicon": "k8s"
1625
1695
  },
1696
+ "RayCluster_Status": {
1697
+ "resourceType": "K8s::Ray::RayCluster.status",
1698
+ "kind": "property",
1699
+ "lexicon": "k8s"
1700
+ },
1626
1701
  "RayCluster_WorkerGroupSpec": {
1627
1702
  "resourceType": "K8s::Ray::RayCluster.workerGroupSpecs",
1628
1703
  "kind": "property",
@@ -1640,6 +1715,11 @@
1640
1715
  "kind": "property",
1641
1716
  "lexicon": "k8s"
1642
1717
  },
1718
+ "RayJob_Status": {
1719
+ "resourceType": "K8s::Ray::RayJob.status",
1720
+ "kind": "property",
1721
+ "lexicon": "k8s"
1722
+ },
1643
1723
  "RayJob_SubmitterConfig": {
1644
1724
  "resourceType": "K8s::Ray::RayJob.submitterConfig",
1645
1725
  "kind": "property",
@@ -1667,6 +1747,11 @@
1667
1747
  "kind": "property",
1668
1748
  "lexicon": "k8s"
1669
1749
  },
1750
+ "RayService_Status": {
1751
+ "resourceType": "K8s::Ray::RayService.status",
1752
+ "kind": "property",
1753
+ "lexicon": "k8s"
1754
+ },
1670
1755
  "RayService_UpgradeStrategy": {
1671
1756
  "resourceType": "K8s::Ray::RayService.upgradeStrategy",
1672
1757
  "kind": "property",
@@ -1,6 +1,6 @@
1
1
  import { exec } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- import { safeHeartbeat, sleep } from "@intentius/chant/op";
3
+ import { readinessFor, waitForReady, ReadinessFailedError, type ResourceFetcher } from "./wait-for-ready";
4
4
 
5
5
  const execAsync = promisify(exec);
6
6
 
@@ -8,13 +8,16 @@ const execAsync = promisify(exec);
8
8
  * waitForArgoSync — block until an Argo CD Application reports
9
9
  * `health=Healthy && sync=Synced`.
10
10
  *
11
- * This activity is intentionally **dependency-light**: though it now lives in the
12
- * k8s lexicon (#809), it must not import the lexicon's generated Argo CRD types —
13
- * its signature is primitives-only (app name / namespace / server), so a Temporal
14
- * worker can load it without pulling in the declarable surface. It reads the
15
- * Application's status either via `kubectl get application` (default) or the Argo
16
- * CD REST API (when `server` is given), so an Op can gate procedural steps on a
17
- * declarative apply that Argo owns.
11
+ * As of #957 this is a thin wrapper over the generic `waitForReady`: the
12
+ * Healthy/Synced ready condition and the Degraded/Missing terminal condition
13
+ * live in the shared readiness registry (`argoproj.io/Application`), so there is
14
+ * a single poll loop. This activity keeps its own status **fetchers** because
15
+ * Argo exposes a REST API that `kubectl` can't cover; it adapts them into a
16
+ * `ResourceFetcher` for `waitForReady`.
17
+ *
18
+ * It stays **dependency-light** — primitives-only signature (app name /
19
+ * namespace / server), no generated Argo CRD types — so a Temporal worker loads
20
+ * it cheaply.
18
21
  */
19
22
 
20
23
  export interface WaitForArgoSyncArgs {
@@ -51,9 +54,6 @@ export type ArgoStatusFetcher = (
51
54
  signal?: AbortSignal,
52
55
  ) => Promise<ArgoAppStatus>;
53
56
 
54
- /** Health states that will never become Healthy without intervention. */
55
- const TERMINAL_UNHEALTHY = new Set(["Degraded", "Missing"]);
56
-
57
57
  /** Error thrown when the Application reaches a terminal unhealthy state. */
58
58
  export class ArgoSyncFailedError extends Error {
59
59
  constructor(message: string) {
@@ -111,8 +111,13 @@ export const defaultArgoStatusFetcher: ArgoStatusFetcher = (args, signal) =>
111
111
  /**
112
112
  * Poll until the Application is Healthy and Synced. Throws
113
113
  * `ArgoSyncFailedError` if it reaches a terminal unhealthy state (Degraded /
114
- * Missing). Heartbeats every poll so the `argoSync` profile's 60s heartbeat
115
- * timeout never trips.
114
+ * Missing).
115
+ *
116
+ * Delegates the poll loop, heartbeat, and ready/terminal evaluation to the
117
+ * generic `waitForReady` using the shared `argoproj.io/Application` readiness
118
+ * spec. The Argo `ArgoStatusFetcher` is adapted into a `ResourceFetcher` that
119
+ * shapes `{health, sync}` into the `status.health.status` / `status.sync.status`
120
+ * paths the spec reads — so the REST-API path is preserved.
116
121
  *
117
122
  * @param fetcher injectable status reader (defaults to kubectl/REST). Tests pass
118
123
  * a fake to drive Healthy/Progressing/Degraded transitions.
@@ -122,26 +127,37 @@ export async function waitForArgoSync(
122
127
  signal?: AbortSignal,
123
128
  fetcher: ArgoStatusFetcher = defaultArgoStatusFetcher,
124
129
  ): Promise<ArgoAppStatus> {
125
- const interval = args.intervalMs ?? 15_000;
126
- let attempt = 0;
127
-
128
- while (true) {
129
- if (signal?.aborted) throw new Error("waitForArgoSync aborted");
130
- attempt++;
130
+ const spec = readinessFor("argoproj.io", "Application");
131
131
 
132
- const status = await fetcher(args, signal);
133
- safeHeartbeat({ step: "waitForArgoSync", app: args.appName, attempt, ...status });
132
+ // Adapt the Argo status fetcher into the object shape the spec's paths read.
133
+ const resourceFetcher: ResourceFetcher = async (_a, s) => {
134
+ const status = await fetcher(args, s);
135
+ return { status: { health: { status: status.health }, sync: { status: status.sync } } };
136
+ };
134
137
 
135
- if (TERMINAL_UNHEALTHY.has(status.health)) {
138
+ try {
139
+ const obj = await waitForReady(
140
+ {
141
+ kind: "application",
142
+ name: args.appName,
143
+ namespace: args.namespace ?? "argocd",
144
+ group: "argoproj.io",
145
+ spec,
146
+ intervalMs: args.intervalMs ?? 15_000,
147
+ },
148
+ signal,
149
+ resourceFetcher,
150
+ );
151
+ const status = (obj as { status: { health: { status: string }; sync: { status: string } } }).status;
152
+ return { health: status.health.status, sync: status.sync.status };
153
+ } catch (err) {
154
+ // Preserve the Argo-specific error type (the argoSync profile marks it
155
+ // non-retryable) while reusing the generic terminal detection.
156
+ if (err instanceof ReadinessFailedError) {
136
157
  throw new ArgoSyncFailedError(
137
- `Argo Application "${args.appName}" is ${status.health} (sync=${status.sync}) — it will not become Healthy without intervention.`,
158
+ `Argo Application "${args.appName}" reached a terminal unhealthy state (Degraded / Missing) — it will not become Healthy without intervention.`,
138
159
  );
139
160
  }
140
-
141
- if (status.health === "Healthy" && status.sync === "Synced") {
142
- return status;
143
- }
144
-
145
- await sleep(interval, signal);
161
+ throw err;
146
162
  }
147
163
  }
@@ -19,3 +19,22 @@ export type { K3dUpArgs, K3dDownArgs } from "./k3d";
19
19
 
20
20
  export { waitForArgoSync, defaultArgoStatusFetcher, ArgoSyncFailedError } from "./argo";
21
21
  export type { WaitForArgoSyncArgs, ArgoAppStatus, ArgoStatusFetcher } from "./argo";
22
+
23
+ export {
24
+ waitForReady,
25
+ defaultResourceFetcher,
26
+ ReadinessFailedError,
27
+ readinessFor,
28
+ isReady,
29
+ firstTerminal,
30
+ DEFAULT_READINESS,
31
+ READINESS_OVERRIDES,
32
+ } from "./wait-for-ready";
33
+ export type {
34
+ WaitForReadyArgs,
35
+ ResourceFetcher,
36
+ ReadinessSpec,
37
+ ReadinessMatch,
38
+ ConditionMatch,
39
+ PathMatch,
40
+ } from "./wait-for-ready";
@@ -0,0 +1,105 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ waitForReady,
4
+ ReadinessFailedError,
5
+ readinessFor,
6
+ isReady,
7
+ firstTerminal,
8
+ DEFAULT_READINESS,
9
+ type ResourceFetcher,
10
+ type ReadinessSpec,
11
+ } from "./wait-for-ready";
12
+ // The k8sWait profile marks ReadinessFailedError non-retryable for this activity.
13
+ import { TEMPORAL_ACTIVITY_PROFILES } from "@intentius/chant-lexicon-temporal/config";
14
+
15
+ /** A fetcher returning a scripted sequence of objects, repeating the last. */
16
+ function scriptedFetcher(sequence: Array<Record<string, unknown>>): ResourceFetcher {
17
+ let i = 0;
18
+ return async () => {
19
+ const obj = sequence[Math.min(i, sequence.length - 1)];
20
+ i++;
21
+ return obj;
22
+ };
23
+ }
24
+
25
+ const ready = (conds: Array<{ type: string; status: string }>, extra: Record<string, unknown> = {}) => ({
26
+ metadata: { generation: 1 },
27
+ status: { observedGeneration: 1, conditions: conds, ...extra },
28
+ });
29
+
30
+ const fast = { kind: "certificate", name: "tls", intervalMs: 0 };
31
+
32
+ describe("readiness model", () => {
33
+ test("default spec = Ready condition True + observedGeneration", () => {
34
+ expect(readinessFor(undefined, "Certificate")).toBe(DEFAULT_READINESS);
35
+ expect(isReady(ready([{ type: "Ready", status: "True" }]), DEFAULT_READINESS)).toBe(true);
36
+ expect(isReady(ready([{ type: "Ready", status: "False" }]), DEFAULT_READINESS)).toBe(false);
37
+ expect(isReady(ready([]), DEFAULT_READINESS)).toBe(false);
38
+ });
39
+
40
+ test("observedGeneration lagging metadata.generation blocks readiness", () => {
41
+ const obj = { metadata: { generation: 5 }, status: { observedGeneration: 4, conditions: [{ type: "Ready", status: "True" }] } };
42
+ expect(isReady(obj, DEFAULT_READINESS)).toBe(false);
43
+ });
44
+
45
+ test("absent observedGeneration does not block", () => {
46
+ const obj = { metadata: { generation: 5 }, status: { conditions: [{ type: "Ready", status: "True" }] } };
47
+ expect(isReady(obj, DEFAULT_READINESS)).toBe(true);
48
+ });
49
+
50
+ test("Argo override uses health/sync, not a Ready condition", () => {
51
+ const spec = readinessFor("argoproj.io", "Application");
52
+ expect(spec).not.toBe(DEFAULT_READINESS);
53
+ const healthy = { status: { health: { status: "Healthy" }, sync: { status: "Synced" } } };
54
+ const progressing = { status: { health: { status: "Progressing" }, sync: { status: "Synced" } } };
55
+ const degraded = { status: { health: { status: "Degraded" }, sync: { status: "OutOfSync" } } };
56
+ expect(isReady(healthy, spec)).toBe(true);
57
+ expect(isReady(progressing, spec)).toBe(false);
58
+ expect(firstTerminal(degraded, spec)).toBeDefined();
59
+ expect(firstTerminal(healthy, spec)).toBeUndefined();
60
+ });
61
+ });
62
+
63
+ describe("waitForReady", () => {
64
+ test("resolves once the Ready condition flips True", async () => {
65
+ const fetcher = scriptedFetcher([
66
+ ready([{ type: "Ready", status: "False" }]),
67
+ ready([{ type: "Ready", status: "False" }]),
68
+ ready([{ type: "Ready", status: "True" }]),
69
+ ]);
70
+ const obj = await waitForReady(fast, undefined, fetcher);
71
+ expect((obj as any).status.conditions[0].status).toBe("True");
72
+ });
73
+
74
+ test("polls past not-ready reads instead of returning early", async () => {
75
+ let calls = 0;
76
+ const fetcher: ResourceFetcher = async () => {
77
+ calls++;
78
+ return calls < 3 ? ready([{ type: "Ready", status: "False" }]) : ready([{ type: "Ready", status: "True" }]);
79
+ };
80
+ await waitForReady(fast, undefined, fetcher);
81
+ expect(calls).toBe(3);
82
+ });
83
+
84
+ test("throws ReadinessFailedError on a terminal state", async () => {
85
+ const spec: ReadinessSpec = {
86
+ ready: [{ conditionType: "Ready", status: "True" }],
87
+ terminal: [{ path: "status.phase", equals: "Failed" }],
88
+ };
89
+ const fetcher = scriptedFetcher([{ status: { phase: "Failed" } }]);
90
+ await expect(
91
+ waitForReady({ ...fast, spec }, undefined, fetcher),
92
+ ).rejects.toBeInstanceOf(ReadinessFailedError);
93
+ });
94
+
95
+ test("k8sWait marks ReadinessFailedError non-retryable", () => {
96
+ expect(TEMPORAL_ACTIVITY_PROFILES.k8sWait.retry?.nonRetryableErrorTypes).toContain("ReadinessFailedError");
97
+ });
98
+
99
+ test("explicit spec wins over the registry", async () => {
100
+ const spec: ReadinessSpec = { ready: [{ path: "status.state", equals: "running" }], observedGeneration: false };
101
+ const fetcher = scriptedFetcher([{ status: { state: "running" } }]);
102
+ const obj = await waitForReady({ kind: "widget", name: "w", intervalMs: 0, spec }, undefined, fetcher);
103
+ expect((obj as any).status.state).toBe("running");
104
+ });
105
+ });
@@ -0,0 +1,211 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { safeHeartbeat, sleep } from "@intentius/chant/op";
4
+
5
+ const execAsync = promisify(exec);
6
+
7
+ /**
8
+ * waitForReady — block until any operator-backed Kubernetes resource reports
9
+ * ready, driven by a data-only **readiness spec** rather than per-CRD code.
10
+ *
11
+ * Like `waitForArgoSync`, this activity is intentionally **dependency-light**:
12
+ * its signature is primitives + a plain readiness spec, so a Temporal worker
13
+ * loads it without importing the generated CRD declarable surface. It reads the
14
+ * resource via `kubectl get -o json` (injectable for tests) and evaluates the
15
+ * spec's predicates. It generalizes the bespoke `waitForArgoSync` /
16
+ * `waitForStack` waits — see #365.
17
+ */
18
+
19
+ // ── Readiness spec (plain data — no generated-type imports) ──────────
20
+
21
+ /** Match a Kubernetes-style `status.conditions[]` entry by `type`. */
22
+ export interface ConditionMatch {
23
+ conditionType: string;
24
+ /** Required `status` of that condition (default "True"). */
25
+ status?: string;
26
+ }
27
+
28
+ /** Match a value at a dot-path into the fetched object. */
29
+ export interface PathMatch {
30
+ /** e.g. "status.health.status". */
31
+ path: string;
32
+ /** Holds when the value strictly equals this. */
33
+ equals?: string | number | boolean;
34
+ /** Holds when the value is one of these. */
35
+ oneOf?: Array<string | number>;
36
+ // With neither `equals` nor `oneOf`, holds when the value is present (non-null).
37
+ }
38
+
39
+ export type ReadinessMatch = ConditionMatch | PathMatch;
40
+
41
+ export interface ReadinessSpec {
42
+ /** All must hold for the resource to be ready. */
43
+ ready: ReadinessMatch[];
44
+ /** If any holds, fail fast — the resource will not become ready. */
45
+ terminal?: ReadinessMatch[];
46
+ /**
47
+ * Also require `status.observedGeneration >= metadata.generation` when both
48
+ * are present. Default true — most operators set `observedGeneration`.
49
+ */
50
+ observedGeneration?: boolean;
51
+ }
52
+
53
+ /**
54
+ * The generic default (kstatus-style): a `Ready` condition of `True` and
55
+ * `observedGeneration` caught up. Covers cert-manager, Gateway API, KubeRay,
56
+ * and the CockroachDB operator.
57
+ */
58
+ export const DEFAULT_READINESS: ReadinessSpec = {
59
+ ready: [{ conditionType: "Ready", status: "True" }],
60
+ observedGeneration: true,
61
+ };
62
+
63
+ /**
64
+ * Per-resource overrides keyed by `"<group>/<kind>"`. Argo's `Application`
65
+ * reports `health`/`sync`, not a `Ready` condition — the case that proves the
66
+ * override is necessary (#365).
67
+ */
68
+ export const READINESS_OVERRIDES: Record<string, ReadinessSpec> = {
69
+ "argoproj.io/Application": {
70
+ ready: [
71
+ { path: "status.health.status", equals: "Healthy" },
72
+ { path: "status.sync.status", equals: "Synced" },
73
+ ],
74
+ terminal: [{ path: "status.health.status", oneOf: ["Degraded", "Missing"] }],
75
+ observedGeneration: false,
76
+ },
77
+ };
78
+
79
+ /** Resolve the readiness spec for a resource: registry override, else default. */
80
+ export function readinessFor(group: string | undefined, kind: string): ReadinessSpec {
81
+ return READINESS_OVERRIDES[`${group ?? ""}/${kind}`] ?? DEFAULT_READINESS;
82
+ }
83
+
84
+ // ── Predicate evaluation ────────────────────────────────────────────
85
+
86
+ function getPath(obj: unknown, path: string): unknown {
87
+ return path.split(".").reduce<unknown>(
88
+ (o, k) => (o != null && typeof o === "object" ? (o as Record<string, unknown>)[k] : undefined),
89
+ obj,
90
+ );
91
+ }
92
+
93
+ function isCondition(m: ReadinessMatch): m is ConditionMatch {
94
+ return (m as ConditionMatch).conditionType !== undefined;
95
+ }
96
+
97
+ function matchCondition(obj: unknown, m: ConditionMatch): boolean {
98
+ const conds = getPath(obj, "status.conditions");
99
+ if (!Array.isArray(conds)) return false;
100
+ const c = conds.find((x) => x && typeof x === "object" && (x as Record<string, unknown>).type === m.conditionType);
101
+ return !!c && String((c as Record<string, unknown>).status) === (m.status ?? "True");
102
+ }
103
+
104
+ function matchPath(obj: unknown, m: PathMatch): boolean {
105
+ const v = getPath(obj, m.path);
106
+ if (m.equals !== undefined) return v === m.equals;
107
+ if (m.oneOf !== undefined) return m.oneOf.includes(v as string | number);
108
+ return v !== undefined && v !== null;
109
+ }
110
+
111
+ function matches(obj: unknown, m: ReadinessMatch): boolean {
112
+ return isCondition(m) ? matchCondition(obj, m) : matchPath(obj, m);
113
+ }
114
+
115
+ function observedGenerationReady(obj: unknown): boolean {
116
+ const gen = getPath(obj, "metadata.generation");
117
+ const obs = getPath(obj, "status.observedGeneration");
118
+ if (typeof gen === "number" && typeof obs === "number") return obs >= gen;
119
+ return true; // absent on either side → don't block
120
+ }
121
+
122
+ /** True when every `ready` predicate holds (and observedGeneration is caught up). */
123
+ export function isReady(obj: unknown, spec: ReadinessSpec): boolean {
124
+ if (spec.observedGeneration !== false && !observedGenerationReady(obj)) return false;
125
+ return spec.ready.every((m) => matches(obj, m));
126
+ }
127
+
128
+ /** The first matching `terminal` predicate, if any — the resource is wedged. */
129
+ export function firstTerminal(obj: unknown, spec: ReadinessSpec): ReadinessMatch | undefined {
130
+ return spec.terminal?.find((m) => matches(obj, m));
131
+ }
132
+
133
+ // ── Activity ────────────────────────────────────────────────────────
134
+
135
+ /** Thrown when the resource reaches a terminal state. Mark non-retryable. */
136
+ export class ReadinessFailedError extends Error {
137
+ constructor(message: string) {
138
+ super(message);
139
+ this.name = "ReadinessFailedError";
140
+ }
141
+ }
142
+
143
+ export interface WaitForReadyArgs {
144
+ /** Resource kind/type as `kubectl get` accepts it (e.g. "certificate", "raycluster.ray.io"). */
145
+ kind: string;
146
+ /** Resource name. */
147
+ name: string;
148
+ /** Namespace (omit for cluster-scoped). */
149
+ namespace?: string;
150
+ /** kubectl context. */
151
+ context?: string;
152
+ /** API group, used to pick a readiness override when `spec` is not given. */
153
+ group?: string;
154
+ /** Explicit readiness spec — wins over the registry/default. */
155
+ spec?: ReadinessSpec;
156
+ /** Poll interval in ms (default 15000). Heartbeats every poll. */
157
+ intervalMs?: number;
158
+ }
159
+
160
+ /** Pluggable resource reader — overridden in tests with a fake. */
161
+ export type ResourceFetcher = (
162
+ args: WaitForReadyArgs,
163
+ signal?: AbortSignal,
164
+ ) => Promise<Record<string, unknown>>;
165
+
166
+ /** Read the resource via `kubectl get -o json`. */
167
+ async function fetchViaKubectl(args: WaitForReadyArgs, signal?: AbortSignal): Promise<Record<string, unknown>> {
168
+ const ns = args.namespace ? `-n ${args.namespace}` : "";
169
+ const ctx = args.context ? `--context ${args.context}` : "";
170
+ const { stdout } = await execAsync(`kubectl get ${args.kind} ${args.name} ${ns} ${ctx} -o json`, { signal });
171
+ return JSON.parse(stdout) as Record<string, unknown>;
172
+ }
173
+
174
+ export const defaultResourceFetcher: ResourceFetcher = (args, signal) => fetchViaKubectl(args, signal);
175
+
176
+ /**
177
+ * Poll until the resource satisfies its readiness spec. Throws
178
+ * `ReadinessFailedError` on a terminal state. Heartbeats every poll so the
179
+ * `k8sWait` profile's 60s heartbeat timeout never trips.
180
+ *
181
+ * @param fetcher injectable reader (defaults to kubectl). Tests pass a fake to
182
+ * drive not-ready → ready / terminal transitions.
183
+ */
184
+ export async function waitForReady(
185
+ args: WaitForReadyArgs,
186
+ signal?: AbortSignal,
187
+ fetcher: ResourceFetcher = defaultResourceFetcher,
188
+ ): Promise<Record<string, unknown>> {
189
+ const spec = args.spec ?? readinessFor(args.group, args.kind);
190
+ const interval = args.intervalMs ?? 15_000;
191
+ let attempt = 0;
192
+
193
+ while (true) {
194
+ if (signal?.aborted) throw new Error("waitForReady aborted");
195
+ attempt++;
196
+
197
+ const obj = await fetcher(args, signal);
198
+ safeHeartbeat({ step: "waitForReady", kind: args.kind, name: args.name, attempt });
199
+
200
+ const term = firstTerminal(obj, spec);
201
+ if (term) {
202
+ throw new ReadinessFailedError(
203
+ `${args.kind}/${args.name} reached a terminal state (${JSON.stringify(term)}) — it will not become ready without intervention.`,
204
+ );
205
+ }
206
+
207
+ if (isReady(obj, spec)) return obj;
208
+
209
+ await sleep(interval, signal);
210
+ }
211
+ }