@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.
- package/dist/crd/parser.d.ts.map +1 -1
- package/dist/generated/index.d.ts +17 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- package/dist/manifest.json +1 -1
- package/dist/meta.json +85 -0
- package/dist/op/activities/argo.d.ts +17 -9
- package/dist/op/activities/argo.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +2 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/wait-for-ready.d.ts +89 -0
- package/dist/op/activities/wait-for-ready.d.ts.map +1 -0
- package/dist/types/index.d.ts +17 -0
- package/package.json +2 -2
- package/src/crd/parser.test.ts +100 -0
- package/src/crd/parser.ts +55 -6
- package/src/generated/index.d.ts +17 -0
- package/src/generated/index.ts +34 -17
- package/src/generated/lexicon-k8s.json +85 -0
- package/src/op/activities/argo.ts +45 -29
- package/src/op/activities/index.ts +19 -0
- package/src/op/activities/wait-for-ready.test.ts +105 -0
- package/src/op/activities/wait-for-ready.ts +211 -0
|
@@ -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 {
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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).
|
|
115
|
-
*
|
|
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
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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}"
|
|
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
|
+
}
|