@intentius/chant-lexicon-k8s 0.17.0 → 0.18.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/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/argo.d.ts +57 -0
- package/dist/op/activities/argo.d.ts.map +1 -0
- package/dist/op/activities/index.d.ts +20 -0
- package/dist/op/activities/index.d.ts.map +1 -0
- package/dist/op/activities/k3d.d.ts +44 -0
- package/dist/op/activities/k3d.d.ts.map +1 -0
- package/dist/op/activities/kubectl.d.ts +11 -0
- package/dist/op/activities/kubectl.d.ts.map +1 -0
- package/package.json +7 -2
- package/src/op/activities/argo.test.ts +80 -0
- package/src/op/activities/argo.ts +147 -0
- package/src/op/activities/index.ts +21 -0
- package/src/op/activities/k3d.test.ts +68 -0
- package/src/op/activities/k3d.ts +96 -0
- package/src/op/activities/kubectl.ts +33 -0
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "e5e92d6151af7a608284b708614aa867dad5d1cc2ef7b53d02234188953fb55f",
|
|
5
5
|
"meta.json": "560e496dee251a4fcc249373e50b7f16b5ee563ac8f9c832343a56f62bcfb656",
|
|
6
6
|
"types/index.d.ts": "3cba22f6b907ebcbad3efded303cb21ba82fb5b8ac0ec41883fee6c84f0895ee",
|
|
7
7
|
"rules/argo-appset-single-project.ts": "afa9f310753aa2d475f35012b13135b2dfaebed2a35d44edbe185ebc07673674",
|
|
@@ -50,5 +50,5 @@
|
|
|
50
50
|
"skills/chant-k8s-aks.md": "e18f0e2b055f72cd7a37deaf258d7027c2d4d3e286e8fd4975b27a1f981a3ad9",
|
|
51
51
|
"skills/chant-k8s-argo.md": "b1a0b826559d8c5033a479c5781efaf650320f0aee4419d8841170bd3393cea5"
|
|
52
52
|
},
|
|
53
|
-
"composite": "
|
|
53
|
+
"composite": "775b5fbf8890c053e071209666333a08ba40a5f613d11c35753914cf49d7e7d4"
|
|
54
54
|
}
|
package/dist/manifest.json
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* waitForArgoSync — block until an Argo CD Application reports
|
|
3
|
+
* `health=Healthy && sync=Synced`.
|
|
4
|
+
*
|
|
5
|
+
* This activity is intentionally **dependency-light**: though it now lives in the
|
|
6
|
+
* k8s lexicon (#809), it must not import the lexicon's generated Argo CRD types —
|
|
7
|
+
* its signature is primitives-only (app name / namespace / server), so a Temporal
|
|
8
|
+
* worker can load it without pulling in the declarable surface. It reads the
|
|
9
|
+
* Application's status either via `kubectl get application` (default) or the Argo
|
|
10
|
+
* CD REST API (when `server` is given), so an Op can gate procedural steps on a
|
|
11
|
+
* declarative apply that Argo owns.
|
|
12
|
+
*/
|
|
13
|
+
export interface WaitForArgoSyncArgs {
|
|
14
|
+
/** Argo Application name. */
|
|
15
|
+
appName: string;
|
|
16
|
+
/** Namespace the Application object lives in (default "argocd"). */
|
|
17
|
+
namespace?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Argo CD API base URL (e.g. https://argocd.example.com). When set, status is
|
|
20
|
+
* read from the REST API instead of kubectl. Pass `authToken` with it.
|
|
21
|
+
*/
|
|
22
|
+
server?: string;
|
|
23
|
+
/** Bearer token for the Argo CD REST API (used with `server`). */
|
|
24
|
+
authToken?: string;
|
|
25
|
+
/** Skip TLS verification for the REST API (default false). */
|
|
26
|
+
insecure?: boolean;
|
|
27
|
+
/** kubectl context (used when `server` is not set). */
|
|
28
|
+
context?: string;
|
|
29
|
+
/** Poll interval in ms (default 15000). Heartbeats every poll. */
|
|
30
|
+
intervalMs?: number;
|
|
31
|
+
}
|
|
32
|
+
/** The two status fields the activity gates on. */
|
|
33
|
+
export interface ArgoAppStatus {
|
|
34
|
+
/** Application health: Healthy | Progressing | Degraded | Missing | Suspended | Unknown. */
|
|
35
|
+
health: string;
|
|
36
|
+
/** Sync status: Synced | OutOfSync | Unknown. */
|
|
37
|
+
sync: string;
|
|
38
|
+
}
|
|
39
|
+
/** Pluggable status reader — overridden in tests with a faked Argo API. */
|
|
40
|
+
export type ArgoStatusFetcher = (args: WaitForArgoSyncArgs, signal?: AbortSignal) => Promise<ArgoAppStatus>;
|
|
41
|
+
/** Error thrown when the Application reaches a terminal unhealthy state. */
|
|
42
|
+
export declare class ArgoSyncFailedError extends Error {
|
|
43
|
+
constructor(message: string);
|
|
44
|
+
}
|
|
45
|
+
/** Default fetcher: REST API when `server` is set, else kubectl. */
|
|
46
|
+
export declare const defaultArgoStatusFetcher: ArgoStatusFetcher;
|
|
47
|
+
/**
|
|
48
|
+
* Poll until the Application is Healthy and Synced. Throws
|
|
49
|
+
* `ArgoSyncFailedError` if it reaches a terminal unhealthy state (Degraded /
|
|
50
|
+
* Missing). Heartbeats every poll so the `argoSync` profile's 60s heartbeat
|
|
51
|
+
* timeout never trips.
|
|
52
|
+
*
|
|
53
|
+
* @param fetcher injectable status reader (defaults to kubectl/REST). Tests pass
|
|
54
|
+
* a fake to drive Healthy/Progressing/Degraded transitions.
|
|
55
|
+
*/
|
|
56
|
+
export declare function waitForArgoSync(args: WaitForArgoSyncArgs, signal?: AbortSignal, fetcher?: ArgoStatusFetcher): Promise<ArgoAppStatus>;
|
|
57
|
+
//# sourceMappingURL=argo.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"argo.d.ts","sourceRoot":"","sources":["../../../src/op/activities/argo.ts"],"names":[],"mappings":"AAMA;;;;;;;;;;;GAWG;AAEH,MAAM,WAAW,mBAAmB;IAClC,6BAA6B;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,uDAAuD;IACvD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,mDAAmD;AACnD,MAAM,WAAW,aAAa;IAC5B,4FAA4F;IAC5F,MAAM,EAAE,MAAM,CAAC;IACf,iDAAiD;IACjD,IAAI,EAAE,MAAM,CAAC;CACd;AAED,2EAA2E;AAC3E,MAAM,MAAM,iBAAiB,GAAG,CAC9B,IAAI,EAAE,mBAAmB,EACzB,MAAM,CAAC,EAAE,WAAW,KACjB,OAAO,CAAC,aAAa,CAAC,CAAC;AAK5B,4EAA4E;AAC5E,qBAAa,mBAAoB,SAAQ,KAAK;gBAChC,OAAO,EAAE,MAAM;CAI5B;AA4CD,oEAAoE;AACpE,eAAO,MAAM,wBAAwB,EAAE,iBACkC,CAAC;AAE1E;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CACnC,IAAI,EAAE,mBAAmB,EACzB,MAAM,CAAC,EAAE,WAAW,EACpB,OAAO,GAAE,iBAA4C,GACpD,OAAO,CAAC,aAAa,CAAC,CAuBxB"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* k8s Op activities — resolved by the core activity registry when a project's
|
|
3
|
+
* `chant.config.ts` lists the `k8s` lexicon. Relocated from the temporal lexicon
|
|
4
|
+
* (#809) so Kubernetes-facing imperative activities live with their product:
|
|
5
|
+
* - kubectlApply — `kubectl apply` a rendered manifest
|
|
6
|
+
* - k3dUp / k3dDown — boot/tear down a local k3d cluster
|
|
7
|
+
* - waitForArgoSync — block until an Argo CD Application is Healthy && Synced
|
|
8
|
+
*
|
|
9
|
+
* The step builders (kubectlApply, k3dUp, k3dDown) stay in core, re-exported from
|
|
10
|
+
* the temporal Op-authoring barrel like the other core builders. Each activity is
|
|
11
|
+
* dependency-light — it shells out to a CLI and does not import the k8s declarable
|
|
12
|
+
* surface — so a Temporal worker loads it cheaply.
|
|
13
|
+
*/
|
|
14
|
+
export { kubectlApply } from "./kubectl.js";
|
|
15
|
+
export type { KubectlApplyArgs } from "./kubectl.js";
|
|
16
|
+
export { k3dUp, k3dDown, k3dUpCommand, k3dDownCommand, k3dExistsCommand } from "./k3d.js";
|
|
17
|
+
export type { K3dUpArgs, K3dDownArgs } from "./k3d.js";
|
|
18
|
+
export { waitForArgoSync, defaultArgoStatusFetcher, ArgoSyncFailedError } from "./argo.js";
|
|
19
|
+
export type { WaitForArgoSyncArgs, ArgoAppStatus, ArgoStatusFetcher } from "./argo.js";
|
|
20
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/op/activities/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AACzC,YAAY,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAElD,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,OAAO,CAAC;AACvF,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,OAAO,CAAC;AAEpD,OAAO,EAAE,eAAe,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,MAAM,QAAQ,CAAC;AACxF,YAAY,EAAE,mBAAmB,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,QAAQ,CAAC"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface K3dUpArgs {
|
|
2
|
+
/** Cluster name (`k3d cluster create <name>`). */
|
|
3
|
+
name: string;
|
|
4
|
+
/** Number of server (control-plane) nodes. */
|
|
5
|
+
servers?: number;
|
|
6
|
+
/** Number of agent (worker) nodes. */
|
|
7
|
+
agents?: number;
|
|
8
|
+
/** k3s image, e.g. `rancher/k3s:v1.31.4-k3s1`. */
|
|
9
|
+
image?: string;
|
|
10
|
+
/** Port mappings, e.g. `["8080:80@loadbalancer"]`. */
|
|
11
|
+
ports?: string[];
|
|
12
|
+
/** Create a managed local registry with this name (`--registry-create`). */
|
|
13
|
+
registryCreate?: string;
|
|
14
|
+
/** Path to a k3d config file (`--config`). */
|
|
15
|
+
configFile?: string;
|
|
16
|
+
/** Readiness timeout for `--wait`. Default: `120s`. */
|
|
17
|
+
timeout?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface K3dDownArgs {
|
|
20
|
+
/** Cluster name to delete. */
|
|
21
|
+
name: string;
|
|
22
|
+
}
|
|
23
|
+
/** `k3d cluster list <name> --no-headers` — non-empty stdout means the cluster exists. */
|
|
24
|
+
export declare function k3dExistsCommand(name: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* Build the `k3d cluster create` command. k3d merges the new cluster into the
|
|
27
|
+
* default kubeconfig and switches context by default, so no extra flags are
|
|
28
|
+
* needed for the manifests to be `kubectl apply`-able afterward.
|
|
29
|
+
*/
|
|
30
|
+
export declare function k3dUpCommand(args: K3dUpArgs): string;
|
|
31
|
+
/** Build the `k3d cluster delete` command. */
|
|
32
|
+
export declare function k3dDownCommand(args: K3dDownArgs): string;
|
|
33
|
+
/**
|
|
34
|
+
* Create a local k3d cluster (vanilla Kubernetes in Docker). Idempotent: if a
|
|
35
|
+
* cluster of the same name already exists it is left as-is. Uses longInfra
|
|
36
|
+
* profile — 20m timeout, heartbeat every 15s (creation may pull the k3s image).
|
|
37
|
+
*/
|
|
38
|
+
export declare function k3dUp(args: K3dUpArgs, signal?: AbortSignal): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Delete a local k3d cluster. Uses fastIdempotent profile — 5m timeout.
|
|
41
|
+
* `k3d cluster delete` is a no-op success when the cluster is already gone.
|
|
42
|
+
*/
|
|
43
|
+
export declare function k3dDown(args: K3dDownArgs, signal?: AbortSignal): Promise<void>;
|
|
44
|
+
//# sourceMappingURL=k3d.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"k3d.d.ts","sourceRoot":"","sources":["../../../src/op/activities/k3d.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,SAAS;IACxB,kDAAkD;IAClD,IAAI,EAAE,MAAM,CAAC;IACb,8CAA8C;IAC9C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,sCAAsC;IACtC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,kDAAkD;IAClD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sDAAsD;IACtD,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,4EAA4E;IAC5E,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,8CAA8C;IAC9C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uDAAuD;IACvD,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,8BAA8B;IAC9B,IAAI,EAAE,MAAM,CAAC;CACd;AAED,0FAA0F;AAC1F,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAErD;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,SAAS,GAAG,MAAM,CAWpD;AAED,8CAA8C;AAC9C,wBAAgB,cAAc,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,CAExD;AAED;;;;GAIG;AACH,wBAAsB,KAAK,CAAC,IAAI,EAAE,SAAS,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAsBhF;AAED;;;GAGG;AACH,wBAAsB,OAAO,CAAC,IAAI,EAAE,WAAW,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAIpF"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface KubectlApplyArgs {
|
|
2
|
+
manifest: string;
|
|
3
|
+
/** kubectl context name. Uses current context if omitted. */
|
|
4
|
+
context?: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Run `kubectl apply -f <manifest>`.
|
|
8
|
+
* Uses longInfra profile — 20m timeout, heartbeat every 15s.
|
|
9
|
+
*/
|
|
10
|
+
export declare function kubectlApply(args: KubectlApplyArgs, signal?: AbortSignal): Promise<void>;
|
|
11
|
+
//# sourceMappingURL=kubectl.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"kubectl.d.ts","sourceRoot":"","sources":["../../../src/op/activities/kubectl.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,6DAA6D;IAC7D,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAgB9F"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-k8s",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Kubernetes lexicon for chant — declarative IaC in TypeScript",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -49,6 +49,11 @@
|
|
|
49
49
|
"types": "./dist/lint/post-synth/index.d.ts",
|
|
50
50
|
"default": "./src/lint/post-synth/index.ts"
|
|
51
51
|
},
|
|
52
|
+
"./op/activities": {
|
|
53
|
+
"development": "./src/op/activities/index.ts",
|
|
54
|
+
"types": "./dist/op/activities/index.d.ts",
|
|
55
|
+
"default": "./src/op/activities/index.ts"
|
|
56
|
+
},
|
|
52
57
|
"./manifest": "./dist/manifest.json",
|
|
53
58
|
"./meta": "./dist/meta.json",
|
|
54
59
|
"./types": "./dist/types/index.d.ts"
|
|
@@ -70,6 +75,6 @@
|
|
|
70
75
|
"typescript": "^5.9.3"
|
|
71
76
|
},
|
|
72
77
|
"peerDependencies": {
|
|
73
|
-
"@intentius/chant": "^0.
|
|
78
|
+
"@intentius/chant": "^0.18.0"
|
|
74
79
|
}
|
|
75
80
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
waitForArgoSync,
|
|
4
|
+
ArgoSyncFailedError,
|
|
5
|
+
type ArgoAppStatus,
|
|
6
|
+
type ArgoStatusFetcher,
|
|
7
|
+
} from "./argo";
|
|
8
|
+
// Activity profiles live centrally in the temporal lexicon (loadProfiles reads
|
|
9
|
+
// them there); argoSync marks ArgoSyncFailedError non-retryable for this activity.
|
|
10
|
+
import { TEMPORAL_ACTIVITY_PROFILES } from "@intentius/chant-lexicon-temporal/config";
|
|
11
|
+
|
|
12
|
+
/** A fetcher that returns a scripted sequence of statuses, repeating the last. */
|
|
13
|
+
function scriptedFetcher(sequence: ArgoAppStatus[]): ArgoStatusFetcher {
|
|
14
|
+
let i = 0;
|
|
15
|
+
return async () => {
|
|
16
|
+
const status = sequence[Math.min(i, sequence.length - 1)];
|
|
17
|
+
i++;
|
|
18
|
+
return status;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const fast = { appName: "guestbook", intervalMs: 0 };
|
|
23
|
+
|
|
24
|
+
describe("waitForArgoSync", () => {
|
|
25
|
+
test("resolves once the Application is Healthy and Synced", async () => {
|
|
26
|
+
const fetcher = scriptedFetcher([
|
|
27
|
+
{ health: "Progressing", sync: "OutOfSync" },
|
|
28
|
+
{ health: "Progressing", sync: "Synced" },
|
|
29
|
+
{ health: "Healthy", sync: "Synced" },
|
|
30
|
+
]);
|
|
31
|
+
const result = await waitForArgoSync(fast, undefined, fetcher);
|
|
32
|
+
expect(result).toEqual({ health: "Healthy", sync: "Synced" });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("does not resolve while Synced but still Progressing", async () => {
|
|
36
|
+
// First Healthy+Synced read is the third; ensure it polls past the
|
|
37
|
+
// Progressing reads rather than returning early.
|
|
38
|
+
let calls = 0;
|
|
39
|
+
const fetcher: ArgoStatusFetcher = async () => {
|
|
40
|
+
calls++;
|
|
41
|
+
if (calls < 3) return { health: "Progressing", sync: "Synced" };
|
|
42
|
+
return { health: "Healthy", sync: "Synced" };
|
|
43
|
+
};
|
|
44
|
+
await waitForArgoSync(fast, undefined, fetcher);
|
|
45
|
+
expect(calls).toBe(3);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("throws ArgoSyncFailedError when the Application is Degraded", async () => {
|
|
49
|
+
const fetcher = scriptedFetcher([{ health: "Degraded", sync: "Synced" }]);
|
|
50
|
+
await expect(waitForArgoSync(fast, undefined, fetcher)).rejects.toBeInstanceOf(
|
|
51
|
+
ArgoSyncFailedError,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("throws ArgoSyncFailedError when the Application is Missing", async () => {
|
|
56
|
+
const fetcher = scriptedFetcher([{ health: "Missing", sync: "OutOfSync" }]);
|
|
57
|
+
await expect(waitForArgoSync(fast, undefined, fetcher)).rejects.toThrow(/Missing/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("honors an aborted signal", async () => {
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
controller.abort();
|
|
63
|
+
const fetcher = scriptedFetcher([{ health: "Progressing", sync: "OutOfSync" }]);
|
|
64
|
+
await expect(waitForArgoSync(fast, controller.signal, fetcher)).rejects.toThrow(/aborted/);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("argoSync profile", () => {
|
|
69
|
+
test("is exported with a long timeout and 60s heartbeat", () => {
|
|
70
|
+
const p = TEMPORAL_ACTIVITY_PROFILES.argoSync;
|
|
71
|
+
expect(p.startToCloseTimeout).toBe("30m");
|
|
72
|
+
expect(p.heartbeatTimeout).toBe("60s");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("treats ArgoSyncFailedError as non-retryable", () => {
|
|
76
|
+
expect(TEMPORAL_ACTIVITY_PROFILES.argoSync.retry?.nonRetryableErrorTypes).toContain(
|
|
77
|
+
"ArgoSyncFailedError",
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
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
|
+
* waitForArgoSync — block until an Argo CD Application reports
|
|
9
|
+
* `health=Healthy && sync=Synced`.
|
|
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.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface WaitForArgoSyncArgs {
|
|
21
|
+
/** Argo Application name. */
|
|
22
|
+
appName: string;
|
|
23
|
+
/** Namespace the Application object lives in (default "argocd"). */
|
|
24
|
+
namespace?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Argo CD API base URL (e.g. https://argocd.example.com). When set, status is
|
|
27
|
+
* read from the REST API instead of kubectl. Pass `authToken` with it.
|
|
28
|
+
*/
|
|
29
|
+
server?: string;
|
|
30
|
+
/** Bearer token for the Argo CD REST API (used with `server`). */
|
|
31
|
+
authToken?: string;
|
|
32
|
+
/** Skip TLS verification for the REST API (default false). */
|
|
33
|
+
insecure?: boolean;
|
|
34
|
+
/** kubectl context (used when `server` is not set). */
|
|
35
|
+
context?: string;
|
|
36
|
+
/** Poll interval in ms (default 15000). Heartbeats every poll. */
|
|
37
|
+
intervalMs?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The two status fields the activity gates on. */
|
|
41
|
+
export interface ArgoAppStatus {
|
|
42
|
+
/** Application health: Healthy | Progressing | Degraded | Missing | Suspended | Unknown. */
|
|
43
|
+
health: string;
|
|
44
|
+
/** Sync status: Synced | OutOfSync | Unknown. */
|
|
45
|
+
sync: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Pluggable status reader — overridden in tests with a faked Argo API. */
|
|
49
|
+
export type ArgoStatusFetcher = (
|
|
50
|
+
args: WaitForArgoSyncArgs,
|
|
51
|
+
signal?: AbortSignal,
|
|
52
|
+
) => Promise<ArgoAppStatus>;
|
|
53
|
+
|
|
54
|
+
/** Health states that will never become Healthy without intervention. */
|
|
55
|
+
const TERMINAL_UNHEALTHY = new Set(["Degraded", "Missing"]);
|
|
56
|
+
|
|
57
|
+
/** Error thrown when the Application reaches a terminal unhealthy state. */
|
|
58
|
+
export class ArgoSyncFailedError extends Error {
|
|
59
|
+
constructor(message: string) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = "ArgoSyncFailedError";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Read status via the Argo CD REST API. */
|
|
66
|
+
async function fetchViaApi(args: WaitForArgoSyncArgs, signal?: AbortSignal): Promise<ArgoAppStatus> {
|
|
67
|
+
const base = args.server!.replace(/\/$/, "");
|
|
68
|
+
const ns = args.namespace ?? "argocd";
|
|
69
|
+
const url = `${base}/api/v1/applications/${encodeURIComponent(args.appName)}?appNamespace=${encodeURIComponent(ns)}`;
|
|
70
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
71
|
+
if (args.authToken) headers.Authorization = `Bearer ${args.authToken}`;
|
|
72
|
+
|
|
73
|
+
// Honor `insecure` without importing https Agent types — Node respects this
|
|
74
|
+
// env toggle for the duration of the call.
|
|
75
|
+
const prevTlsReject = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
|
76
|
+
if (args.insecure) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(url, { headers, signal });
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
throw new Error(`Argo CD API returned ${res.status} for application "${args.appName}"`);
|
|
81
|
+
}
|
|
82
|
+
const body = (await res.json()) as { status?: { health?: { status?: string }; sync?: { status?: string } } };
|
|
83
|
+
return {
|
|
84
|
+
health: body.status?.health?.status ?? "Unknown",
|
|
85
|
+
sync: body.status?.sync?.status ?? "Unknown",
|
|
86
|
+
};
|
|
87
|
+
} finally {
|
|
88
|
+
if (args.insecure) {
|
|
89
|
+
if (prevTlsReject === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
|
90
|
+
else process.env.NODE_TLS_REJECT_UNAUTHORIZED = prevTlsReject;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Read status via `kubectl get application -o json`. */
|
|
96
|
+
async function fetchViaKubectl(args: WaitForArgoSyncArgs, signal?: AbortSignal): Promise<ArgoAppStatus> {
|
|
97
|
+
const ns = args.namespace ?? "argocd";
|
|
98
|
+
const ctx = args.context ? `--context ${args.context}` : "";
|
|
99
|
+
const cmd =
|
|
100
|
+
`kubectl get application ${args.appName} -n ${ns} ${ctx} ` +
|
|
101
|
+
`-o jsonpath='{.status.health.status}|{.status.sync.status}'`;
|
|
102
|
+
const { stdout } = await execAsync(cmd, { signal });
|
|
103
|
+
const [health = "Unknown", sync = "Unknown"] = stdout.trim().replace(/^'|'$/g, "").split("|");
|
|
104
|
+
return { health: health || "Unknown", sync: sync || "Unknown" };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Default fetcher: REST API when `server` is set, else kubectl. */
|
|
108
|
+
export const defaultArgoStatusFetcher: ArgoStatusFetcher = (args, signal) =>
|
|
109
|
+
args.server ? fetchViaApi(args, signal) : fetchViaKubectl(args, signal);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Poll until the Application is Healthy and Synced. Throws
|
|
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.
|
|
116
|
+
*
|
|
117
|
+
* @param fetcher injectable status reader (defaults to kubectl/REST). Tests pass
|
|
118
|
+
* a fake to drive Healthy/Progressing/Degraded transitions.
|
|
119
|
+
*/
|
|
120
|
+
export async function waitForArgoSync(
|
|
121
|
+
args: WaitForArgoSyncArgs,
|
|
122
|
+
signal?: AbortSignal,
|
|
123
|
+
fetcher: ArgoStatusFetcher = defaultArgoStatusFetcher,
|
|
124
|
+
): 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++;
|
|
131
|
+
|
|
132
|
+
const status = await fetcher(args, signal);
|
|
133
|
+
safeHeartbeat({ step: "waitForArgoSync", app: args.appName, attempt, ...status });
|
|
134
|
+
|
|
135
|
+
if (TERMINAL_UNHEALTHY.has(status.health)) {
|
|
136
|
+
throw new ArgoSyncFailedError(
|
|
137
|
+
`Argo Application "${args.appName}" is ${status.health} (sync=${status.sync}) — it will not become Healthy without intervention.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (status.health === "Healthy" && status.sync === "Synced") {
|
|
142
|
+
return status;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await sleep(interval, signal);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* k8s Op activities — resolved by the core activity registry when a project's
|
|
3
|
+
* `chant.config.ts` lists the `k8s` lexicon. Relocated from the temporal lexicon
|
|
4
|
+
* (#809) so Kubernetes-facing imperative activities live with their product:
|
|
5
|
+
* - kubectlApply — `kubectl apply` a rendered manifest
|
|
6
|
+
* - k3dUp / k3dDown — boot/tear down a local k3d cluster
|
|
7
|
+
* - waitForArgoSync — block until an Argo CD Application is Healthy && Synced
|
|
8
|
+
*
|
|
9
|
+
* The step builders (kubectlApply, k3dUp, k3dDown) stay in core, re-exported from
|
|
10
|
+
* the temporal Op-authoring barrel like the other core builders. Each activity is
|
|
11
|
+
* dependency-light — it shells out to a CLI and does not import the k8s declarable
|
|
12
|
+
* surface — so a Temporal worker loads it cheaply.
|
|
13
|
+
*/
|
|
14
|
+
export { kubectlApply } from "./kubectl";
|
|
15
|
+
export type { KubectlApplyArgs } from "./kubectl";
|
|
16
|
+
|
|
17
|
+
export { k3dUp, k3dDown, k3dUpCommand, k3dDownCommand, k3dExistsCommand } from "./k3d";
|
|
18
|
+
export type { K3dUpArgs, K3dDownArgs } from "./k3d";
|
|
19
|
+
|
|
20
|
+
export { waitForArgoSync, defaultArgoStatusFetcher, ArgoSyncFailedError } from "./argo";
|
|
21
|
+
export type { WaitForArgoSyncArgs, ArgoAppStatus, ArgoStatusFetcher } from "./argo";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { k3dUpCommand, k3dDownCommand, k3dExistsCommand } from "./k3d";
|
|
3
|
+
|
|
4
|
+
describe("k3dUpCommand (#704)", () => {
|
|
5
|
+
test("minimal — name only, defaults to --wait with a 120s timeout", () => {
|
|
6
|
+
const cmd = k3dUpCommand({ name: "chant-local" });
|
|
7
|
+
expect(cmd).toBe("k3d cluster create chant-local --wait --timeout 120s");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("servers/agents/image flags", () => {
|
|
11
|
+
const cmd = k3dUpCommand({
|
|
12
|
+
name: "dev",
|
|
13
|
+
servers: 1,
|
|
14
|
+
agents: 2,
|
|
15
|
+
image: "rancher/k3s:v1.31.4-k3s1",
|
|
16
|
+
});
|
|
17
|
+
expect(cmd).toContain("--servers 1");
|
|
18
|
+
expect(cmd).toContain("--agents 2");
|
|
19
|
+
expect(cmd).toContain("--image rancher/k3s:v1.31.4-k3s1");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("each port becomes a quoted -p flag", () => {
|
|
23
|
+
const cmd = k3dUpCommand({
|
|
24
|
+
name: "dev",
|
|
25
|
+
ports: ["8080:80@loadbalancer", "8443:443@loadbalancer"],
|
|
26
|
+
});
|
|
27
|
+
expect(cmd).toContain('-p "8080:80@loadbalancer"');
|
|
28
|
+
expect(cmd).toContain('-p "8443:443@loadbalancer"');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("registry and config file", () => {
|
|
32
|
+
const cmd = k3dUpCommand({
|
|
33
|
+
name: "dev",
|
|
34
|
+
registryCreate: "chant-registry",
|
|
35
|
+
configFile: "k3d.yaml",
|
|
36
|
+
});
|
|
37
|
+
expect(cmd).toContain("--registry-create chant-registry");
|
|
38
|
+
expect(cmd).toContain("--config k3d.yaml");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("custom timeout overrides the default", () => {
|
|
42
|
+
const cmd = k3dUpCommand({ name: "dev", timeout: "300s" });
|
|
43
|
+
expect(cmd).toContain("--timeout 300s");
|
|
44
|
+
expect(cmd).not.toContain("120s");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("zero servers is emitted (not dropped as falsy)", () => {
|
|
48
|
+
// servers: 0 is unusual but must round-trip — guarded by `!== undefined`.
|
|
49
|
+
const cmd = k3dUpCommand({ name: "dev", servers: 0 });
|
|
50
|
+
expect(cmd).toContain("--servers 0");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("k3dDownCommand (#704)", () => {
|
|
55
|
+
test("deletes the named cluster", () => {
|
|
56
|
+
expect(k3dDownCommand({ name: "chant-local" })).toBe(
|
|
57
|
+
"k3d cluster delete chant-local",
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("k3dExistsCommand (#704)", () => {
|
|
63
|
+
test("lists a single cluster with no headers for an emptiness check", () => {
|
|
64
|
+
expect(k3dExistsCommand("chant-local")).toBe(
|
|
65
|
+
"k3d cluster list chant-local --no-headers",
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { safeHeartbeat } from "@intentius/chant/op";
|
|
4
|
+
|
|
5
|
+
const execAsync = promisify(exec);
|
|
6
|
+
|
|
7
|
+
export interface K3dUpArgs {
|
|
8
|
+
/** Cluster name (`k3d cluster create <name>`). */
|
|
9
|
+
name: string;
|
|
10
|
+
/** Number of server (control-plane) nodes. */
|
|
11
|
+
servers?: number;
|
|
12
|
+
/** Number of agent (worker) nodes. */
|
|
13
|
+
agents?: number;
|
|
14
|
+
/** k3s image, e.g. `rancher/k3s:v1.31.4-k3s1`. */
|
|
15
|
+
image?: string;
|
|
16
|
+
/** Port mappings, e.g. `["8080:80@loadbalancer"]`. */
|
|
17
|
+
ports?: string[];
|
|
18
|
+
/** Create a managed local registry with this name (`--registry-create`). */
|
|
19
|
+
registryCreate?: string;
|
|
20
|
+
/** Path to a k3d config file (`--config`). */
|
|
21
|
+
configFile?: string;
|
|
22
|
+
/** Readiness timeout for `--wait`. Default: `120s`. */
|
|
23
|
+
timeout?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface K3dDownArgs {
|
|
27
|
+
/** Cluster name to delete. */
|
|
28
|
+
name: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `k3d cluster list <name> --no-headers` — non-empty stdout means the cluster exists. */
|
|
32
|
+
export function k3dExistsCommand(name: string): string {
|
|
33
|
+
return `k3d cluster list ${name} --no-headers`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build the `k3d cluster create` command. k3d merges the new cluster into the
|
|
38
|
+
* default kubeconfig and switches context by default, so no extra flags are
|
|
39
|
+
* needed for the manifests to be `kubectl apply`-able afterward.
|
|
40
|
+
*/
|
|
41
|
+
export function k3dUpCommand(args: K3dUpArgs): string {
|
|
42
|
+
const parts = ["k3d", "cluster", "create", args.name];
|
|
43
|
+
if (args.servers !== undefined) parts.push(`--servers ${args.servers}`);
|
|
44
|
+
if (args.agents !== undefined) parts.push(`--agents ${args.agents}`);
|
|
45
|
+
if (args.image) parts.push(`--image ${args.image}`);
|
|
46
|
+
for (const p of args.ports ?? []) parts.push(`-p "${p}"`);
|
|
47
|
+
if (args.registryCreate) parts.push(`--registry-create ${args.registryCreate}`);
|
|
48
|
+
if (args.configFile) parts.push(`--config ${args.configFile}`);
|
|
49
|
+
parts.push("--wait");
|
|
50
|
+
parts.push(`--timeout ${args.timeout ?? "120s"}`);
|
|
51
|
+
return parts.join(" ");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build the `k3d cluster delete` command. */
|
|
55
|
+
export function k3dDownCommand(args: K3dDownArgs): string {
|
|
56
|
+
return `k3d cluster delete ${args.name}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Create a local k3d cluster (vanilla Kubernetes in Docker). Idempotent: if a
|
|
61
|
+
* cluster of the same name already exists it is left as-is. Uses longInfra
|
|
62
|
+
* profile — 20m timeout, heartbeat every 15s (creation may pull the k3s image).
|
|
63
|
+
*/
|
|
64
|
+
export async function k3dUp(args: K3dUpArgs, signal?: AbortSignal): Promise<void> {
|
|
65
|
+
try {
|
|
66
|
+
const { stdout } = await execAsync(k3dExistsCommand(args.name), { signal });
|
|
67
|
+
if (stdout.trim()) {
|
|
68
|
+
console.log(`k3d cluster "${args.name}" already exists — skipping create`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// `cluster list` errors when the cluster is absent — fall through to create.
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const heartbeatInterval = setInterval(() => {
|
|
76
|
+
safeHeartbeat({ step: "k3d cluster create", cluster: args.name });
|
|
77
|
+
}, 15_000);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const { stdout, stderr } = await execAsync(k3dUpCommand(args), { signal });
|
|
81
|
+
if (stdout) console.log(stdout);
|
|
82
|
+
if (stderr) console.error(stderr);
|
|
83
|
+
} finally {
|
|
84
|
+
clearInterval(heartbeatInterval);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Delete a local k3d cluster. Uses fastIdempotent profile — 5m timeout.
|
|
90
|
+
* `k3d cluster delete` is a no-op success when the cluster is already gone.
|
|
91
|
+
*/
|
|
92
|
+
export async function k3dDown(args: K3dDownArgs, signal?: AbortSignal): Promise<void> {
|
|
93
|
+
const { stdout, stderr } = await execAsync(k3dDownCommand(args), { signal });
|
|
94
|
+
if (stdout) console.log(stdout);
|
|
95
|
+
if (stderr) console.error(stderr);
|
|
96
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { safeHeartbeat } from "@intentius/chant/op";
|
|
4
|
+
|
|
5
|
+
const execAsync = promisify(exec);
|
|
6
|
+
|
|
7
|
+
export interface KubectlApplyArgs {
|
|
8
|
+
manifest: string;
|
|
9
|
+
/** kubectl context name. Uses current context if omitted. */
|
|
10
|
+
context?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Run `kubectl apply -f <manifest>`.
|
|
15
|
+
* Uses longInfra profile — 20m timeout, heartbeat every 15s.
|
|
16
|
+
*/
|
|
17
|
+
export async function kubectlApply(args: KubectlApplyArgs, signal?: AbortSignal): Promise<void> {
|
|
18
|
+
const ctx = args.context ? `--context ${args.context}` : "";
|
|
19
|
+
const heartbeatInterval = setInterval(() => {
|
|
20
|
+
safeHeartbeat({ step: "kubectl apply", manifest: args.manifest });
|
|
21
|
+
}, 15_000);
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const { stdout, stderr } = await execAsync(
|
|
25
|
+
`kubectl apply -f ${args.manifest} ${ctx} --wait=true`,
|
|
26
|
+
{ signal },
|
|
27
|
+
);
|
|
28
|
+
if (stdout) console.log(stdout);
|
|
29
|
+
if (stderr) console.error(stderr);
|
|
30
|
+
} finally {
|
|
31
|
+
clearInterval(heartbeatInterval);
|
|
32
|
+
}
|
|
33
|
+
}
|