@intentius/chant-lexicon-k8s 0.1.6 → 0.1.9
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/package.json +3 -3
- package/src/describe-resources.test.ts +162 -0
- package/src/describe-resources.ts +134 -0
- package/src/index.ts +3 -0
- package/src/micro-time.test.ts +48 -0
- package/src/micro-time.ts +51 -0
- package/src/plugin.test.ts +2 -2
- package/src/plugin.ts +7 -92
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "3b5e686ba2f5230ae91d763af4ce3eacde217b82c51db5063ae79415383ce2bc",
|
|
5
5
|
"meta.json": "970c70eb6eea1686f7cb8ce6ceccc46ce2e57d696d344f1dd52460e266f9a56c",
|
|
6
6
|
"types/index.d.ts": "07473e029254345488bc9952585e88bcf18da79d1026cc26744027683f472554",
|
|
7
7
|
"rules/hardcoded-namespace.ts": "ba3f43f2adbffdd87db20a2c45839354ceecda1b9f04f29ae31c4c077dddc7ec",
|
|
@@ -42,5 +42,5 @@
|
|
|
42
42
|
"skills/chant-k8s-gke.md": "8938840bf9ef5ed58d6333fdd773b3dd54ecaf25a9df35e58f7f5c3355d4928f",
|
|
43
43
|
"skills/chant-k8s-aks.md": "e18f0e2b055f72cd7a37deaf258d7027c2d4d3e286e8fd4975b27a1f981a3ad9"
|
|
44
44
|
},
|
|
45
|
-
"composite": "
|
|
45
|
+
"composite": "1cf502ba76b37beb544a97555310f41a48fc251e44330f3ac67faebeee530e2f"
|
|
46
46
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-k8s",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Kubernetes lexicon for chant — declarative IaC in TypeScript",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -43,11 +43,11 @@
|
|
|
43
43
|
"prepack": "npm run generate && npm run bundle && npm run validate"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"js-yaml": "^4.1.1"
|
|
46
|
+
"js-yaml": "^4.1.1",
|
|
47
|
+
"@types/js-yaml": "^4.0.9"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|
|
49
50
|
"@intentius/chant": "*",
|
|
50
|
-
"@types/js-yaml": "^4.0.9",
|
|
51
51
|
"typescript": "^5.9.3"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
const execMock = vi.fn();
|
|
4
|
+
vi.mock("node:child_process", async () => {
|
|
5
|
+
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
|
6
|
+
return { ...actual, exec: (cmd: string, cb: (err: Error | null, out: { stdout: string; stderr: string }) => void) => {
|
|
7
|
+
Promise.resolve(execMock(cmd)).then(
|
|
8
|
+
(out) => cb(null, out),
|
|
9
|
+
(err) => cb(err as Error, { stdout: "", stderr: "" }),
|
|
10
|
+
);
|
|
11
|
+
} };
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const { describeResources } = await import("./describe-resources");
|
|
15
|
+
|
|
16
|
+
function makeEntities(records: Array<{ name: string; entityType: string; props: Record<string, unknown> }>) {
|
|
17
|
+
return new Map(records.map((r) => [r.name, { entityType: r.entityType, props: r.props }]));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("k8s describeResources", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
execMock.mockReset();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("queries kubectl for each declared K8s entity and maps response to ResourceMetadata", async () => {
|
|
26
|
+
execMock.mockImplementation((cmd: string) => {
|
|
27
|
+
if (cmd.includes("deployment.apps web")) {
|
|
28
|
+
return {
|
|
29
|
+
stdout: JSON.stringify({
|
|
30
|
+
metadata: { name: "web", namespace: "prod", uid: "uid-1", creationTimestamp: "2026-05-01T00:00:00Z", labels: { app: "web" } },
|
|
31
|
+
status: { readyReplicas: 3, replicas: 3 },
|
|
32
|
+
}),
|
|
33
|
+
stderr: "",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (cmd.includes("service web-svc")) {
|
|
37
|
+
return {
|
|
38
|
+
stdout: JSON.stringify({
|
|
39
|
+
metadata: { name: "web-svc", namespace: "prod", uid: "uid-2", creationTimestamp: "2026-05-01T00:00:00Z" },
|
|
40
|
+
}),
|
|
41
|
+
stderr: "",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`unexpected cmd: ${cmd}`);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const entities = makeEntities([
|
|
48
|
+
{ name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
|
|
49
|
+
{ name: "webSvc", entityType: "K8s::Core::Service", props: { metadata: { name: "web-svc", namespace: "prod" } } },
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web", "webSvc"], entities });
|
|
53
|
+
|
|
54
|
+
expect(result["web"]).toMatchObject({
|
|
55
|
+
type: "K8s::Apps::Deployment",
|
|
56
|
+
physicalId: "uid-1",
|
|
57
|
+
status: "READY",
|
|
58
|
+
attributes: expect.objectContaining({ namespace: "prod", labels: { app: "web" } }),
|
|
59
|
+
});
|
|
60
|
+
expect(result["webSvc"]).toMatchObject({
|
|
61
|
+
type: "K8s::Core::Service",
|
|
62
|
+
physicalId: "uid-2",
|
|
63
|
+
status: "PRESENT",
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("kubectl-not-found leaves entity out of the result (so state diff reports as missing)", async () => {
|
|
68
|
+
execMock.mockImplementation(() => { throw new Error('Error from server (NotFound): deployments.apps "missing" not found'); });
|
|
69
|
+
|
|
70
|
+
const entities = makeEntities([
|
|
71
|
+
{ name: "missing", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "missing", namespace: "prod" } } },
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["missing"], entities });
|
|
75
|
+
|
|
76
|
+
expect(result).toEqual({});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("entity types without kubectl mapping are warn-skipped", async () => {
|
|
80
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
81
|
+
const entities = makeEntities([
|
|
82
|
+
{ name: "exotic", entityType: "K8s::Custom::CRD::SomeOperator", props: { metadata: { name: "x" } } },
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["exotic"], entities });
|
|
86
|
+
|
|
87
|
+
expect(result).toEqual({});
|
|
88
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("kubectl mapping"));
|
|
89
|
+
expect(warnSpy.mock.calls[0][0]).toContain("K8s::Custom::CRD::SomeOperator");
|
|
90
|
+
warnSpy.mockRestore();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("Deployment with replicas != readyReplicas reports PROGRESSING", async () => {
|
|
94
|
+
execMock.mockResolvedValue({
|
|
95
|
+
stdout: JSON.stringify({
|
|
96
|
+
metadata: { name: "web", namespace: "prod", uid: "uid", creationTimestamp: "t" },
|
|
97
|
+
status: { readyReplicas: 1, replicas: 3 },
|
|
98
|
+
}),
|
|
99
|
+
stderr: "",
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const entities = makeEntities([
|
|
103
|
+
{ name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web"], entities });
|
|
107
|
+
|
|
108
|
+
expect(result["web"].status).toBe("PROGRESSING(1/3)");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("Pod uses status.phase as the status", async () => {
|
|
112
|
+
execMock.mockResolvedValue({
|
|
113
|
+
stdout: JSON.stringify({
|
|
114
|
+
metadata: { name: "p", namespace: "prod", uid: "uid", creationTimestamp: "t" },
|
|
115
|
+
status: { phase: "Running" },
|
|
116
|
+
}),
|
|
117
|
+
stderr: "",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const entities = makeEntities([
|
|
121
|
+
{ name: "p", entityType: "K8s::Core::Pod", props: { metadata: { name: "p", namespace: "prod" } } },
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["p"], entities });
|
|
125
|
+
|
|
126
|
+
expect(result["p"].status).toBe("Running");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("namespace-less resource omits the -n flag", async () => {
|
|
130
|
+
let receivedCmd = "";
|
|
131
|
+
execMock.mockImplementation((cmd: string) => {
|
|
132
|
+
receivedCmd = cmd;
|
|
133
|
+
return {
|
|
134
|
+
stdout: JSON.stringify({
|
|
135
|
+
metadata: { name: "mynamespace", uid: "uid", creationTimestamp: "t" },
|
|
136
|
+
status: { phase: "Active" },
|
|
137
|
+
}),
|
|
138
|
+
stderr: "",
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const entities = makeEntities([
|
|
143
|
+
{ name: "ns", entityType: "K8s::Core::Namespace", props: { metadata: { name: "mynamespace" } } },
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
await describeResources({ environment: "prod", buildOutput: "", entityNames: ["ns"], entities });
|
|
147
|
+
|
|
148
|
+
expect(receivedCmd).not.toContain("-n");
|
|
149
|
+
expect(receivedCmd).toContain("namespace mynamespace");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("entity without metadata.name is silently skipped", async () => {
|
|
153
|
+
const entities = makeEntities([
|
|
154
|
+
{ name: "broken", entityType: "K8s::Apps::Deployment", props: {} },
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["broken"], entities });
|
|
158
|
+
|
|
159
|
+
expect(result).toEqual({});
|
|
160
|
+
expect(execMock).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live introspection of a Kubernetes cluster — implements the
|
|
3
|
+
* LexiconPlugin.describeResources() contract for the k8s lexicon.
|
|
4
|
+
*
|
|
5
|
+
* For each declared K8s entity, runs `kubectl get <kind> <name> [-n <ns>] -o json`
|
|
6
|
+
* and maps the response to a ResourceMetadata entry keyed by the chant entity
|
|
7
|
+
* name (using the props.metadata.name + props.metadata.namespace from #39's
|
|
8
|
+
* entity-prop pass-through).
|
|
9
|
+
*
|
|
10
|
+
* Resource-not-found is silent — `state diff --live` then reports it as
|
|
11
|
+
* missing (declared, not in cloud). Unknown entity types are warn-skipped;
|
|
12
|
+
* extending the KUBECTL_RESOURCE map covers more.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { exec } from "node:child_process";
|
|
16
|
+
import { promisify } from "node:util";
|
|
17
|
+
import type { ResourceMetadata } from "@intentius/chant/lexicon";
|
|
18
|
+
|
|
19
|
+
const execAsync = promisify(exec);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Map chant entity types to `kubectl get` resource names. Add entries here
|
|
23
|
+
* as new types are needed.
|
|
24
|
+
*/
|
|
25
|
+
const KUBECTL_RESOURCE: Record<string, string> = {
|
|
26
|
+
"K8s::Apps::Deployment": "deployment.apps",
|
|
27
|
+
"K8s::Apps::StatefulSet": "statefulset.apps",
|
|
28
|
+
"K8s::Apps::DaemonSet": "daemonset.apps",
|
|
29
|
+
"K8s::Apps::ReplicaSet": "replicaset.apps",
|
|
30
|
+
"K8s::Core::Service": "service",
|
|
31
|
+
"K8s::Core::ConfigMap": "configmap",
|
|
32
|
+
"K8s::Core::Secret": "secret",
|
|
33
|
+
"K8s::Core::Namespace": "namespace",
|
|
34
|
+
"K8s::Core::Pod": "pod",
|
|
35
|
+
"K8s::Core::PersistentVolumeClaim": "persistentvolumeclaim",
|
|
36
|
+
"K8s::Core::ServiceAccount": "serviceaccount",
|
|
37
|
+
"K8s::Batch::Job": "job.batch",
|
|
38
|
+
"K8s::Batch::CronJob": "cronjob.batch",
|
|
39
|
+
"K8s::Networking::Ingress": "ingress.networking.k8s.io",
|
|
40
|
+
"K8s::Networking::NetworkPolicy": "networkpolicy.networking.k8s.io",
|
|
41
|
+
"K8s::Rbac::Role": "role.rbac.authorization.k8s.io",
|
|
42
|
+
"K8s::Rbac::RoleBinding": "rolebinding.rbac.authorization.k8s.io",
|
|
43
|
+
"K8s::Rbac::ClusterRole": "clusterrole.rbac.authorization.k8s.io",
|
|
44
|
+
"K8s::Rbac::ClusterRoleBinding": "clusterrolebinding.rbac.authorization.k8s.io",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
interface KubectlResponse {
|
|
48
|
+
metadata?: {
|
|
49
|
+
name?: string;
|
|
50
|
+
namespace?: string;
|
|
51
|
+
uid?: string;
|
|
52
|
+
creationTimestamp?: string;
|
|
53
|
+
labels?: Record<string, string>;
|
|
54
|
+
resourceVersion?: string;
|
|
55
|
+
};
|
|
56
|
+
status?: {
|
|
57
|
+
phase?: string;
|
|
58
|
+
[k: string]: unknown;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function pruneUndefined<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
|
|
63
|
+
const out: Record<string, unknown> = {};
|
|
64
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
65
|
+
if (v !== undefined) out[k] = v;
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function statusFromKubectl(obj: KubectlResponse): string {
|
|
71
|
+
// Different K8s resource types report status differently. Fall back to
|
|
72
|
+
// "PRESENT" if we can't extract a meaningful field.
|
|
73
|
+
const phase = obj.status?.phase;
|
|
74
|
+
if (typeof phase === "string") return phase;
|
|
75
|
+
// Deployment/StatefulSet — readyReplicas == replicas → READY
|
|
76
|
+
const status = obj.status as Record<string, unknown> | undefined;
|
|
77
|
+
if (status && typeof status.readyReplicas === "number" && typeof status.replicas === "number") {
|
|
78
|
+
return status.readyReplicas === status.replicas ? "READY" : `PROGRESSING(${status.readyReplicas}/${status.replicas})`;
|
|
79
|
+
}
|
|
80
|
+
return "PRESENT";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function describeResources(options: {
|
|
84
|
+
environment: string;
|
|
85
|
+
buildOutput: string;
|
|
86
|
+
entityNames: string[];
|
|
87
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
88
|
+
}): Promise<Record<string, ResourceMetadata>> {
|
|
89
|
+
const result: Record<string, ResourceMetadata> = {};
|
|
90
|
+
const skippedTypes = new Set<string>();
|
|
91
|
+
|
|
92
|
+
for (const [entityName, { entityType, props }] of options.entities) {
|
|
93
|
+
const kubectlResource = KUBECTL_RESOURCE[entityType];
|
|
94
|
+
if (!kubectlResource) {
|
|
95
|
+
skippedTypes.add(entityType);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const metadata = props.metadata as { name?: string; namespace?: string } | undefined;
|
|
100
|
+
const name = metadata?.name;
|
|
101
|
+
if (!name) continue;
|
|
102
|
+
|
|
103
|
+
const nsArg = metadata.namespace ? ["-n", metadata.namespace] : [];
|
|
104
|
+
const cmd = ["kubectl", "get", kubectlResource, name, ...nsArg, "-o", "json"].join(" ");
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const { stdout } = await execAsync(cmd);
|
|
108
|
+
const obj: KubectlResponse = JSON.parse(stdout);
|
|
109
|
+
result[entityName] = {
|
|
110
|
+
type: entityType,
|
|
111
|
+
physicalId: obj.metadata?.uid,
|
|
112
|
+
status: statusFromKubectl(obj),
|
|
113
|
+
lastUpdated: obj.metadata?.creationTimestamp,
|
|
114
|
+
attributes: pruneUndefined({
|
|
115
|
+
namespace: obj.metadata?.namespace,
|
|
116
|
+
labels: obj.metadata?.labels,
|
|
117
|
+
resourceVersion: obj.metadata?.resourceVersion,
|
|
118
|
+
}),
|
|
119
|
+
};
|
|
120
|
+
} catch {
|
|
121
|
+
// Resource not found / kubectl error — leave it out so state diff
|
|
122
|
+
// can report it as missing. Don't fail the whole snapshot.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (skippedTypes.size > 0) {
|
|
127
|
+
// eslint-disable-next-line no-console
|
|
128
|
+
console.warn(
|
|
129
|
+
`[k8s] skipped ${skippedTypes.size} entity type(s) without kubectl mapping: ${[...skippedTypes].join(", ")}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return result;
|
|
134
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,9 @@ export { DEFAULT_LABELS_MARKER, DEFAULT_ANNOTATIONS_MARKER } from "./default-lab
|
|
|
11
11
|
// Variables / label constants
|
|
12
12
|
export { K8sLabels, K8sAnnotations } from "./variables";
|
|
13
13
|
|
|
14
|
+
// MicroTime helpers — see micro-time.ts for why this exists.
|
|
15
|
+
export { microTime, isMicroTimeFormatted } from "./micro-time";
|
|
16
|
+
|
|
14
17
|
// Generated entities — export everything from generated index
|
|
15
18
|
// After running `chant generate`, this re-exports all K8s resource classes
|
|
16
19
|
export * from "./generated/index";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { microTime, isMicroTimeFormatted } from "./micro-time";
|
|
3
|
+
|
|
4
|
+
describe("microTime", () => {
|
|
5
|
+
test("formats a Date with 6 fractional digits + Z", () => {
|
|
6
|
+
const d = new Date("2026-05-11T21:25:36.495Z");
|
|
7
|
+
expect(microTime(d)).toBe("2026-05-11T21:25:36.495000Z");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("handles dates whose ms is zero", () => {
|
|
11
|
+
const d = new Date("2026-01-01T00:00:00.000Z");
|
|
12
|
+
expect(microTime(d)).toBe("2026-01-01T00:00:00.000000Z");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("defaults to current time when called with no args", () => {
|
|
16
|
+
const out = microTime();
|
|
17
|
+
expect(out).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("output is accepted by isMicroTimeFormatted()", () => {
|
|
21
|
+
expect(isMicroTimeFormatted(microTime(new Date()))).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("isMicroTimeFormatted", () => {
|
|
26
|
+
test("accepts canonical MicroTime strings", () => {
|
|
27
|
+
expect(isMicroTimeFormatted("2026-05-11T21:25:36.495000Z")).toBe(true);
|
|
28
|
+
expect(isMicroTimeFormatted("2026-05-11T21:25:36.000000Z")).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("rejects nanosecond precision (the bug this helper avoids)", () => {
|
|
32
|
+
// time.RFC3339Nano in Go: 9 fractional digits
|
|
33
|
+
expect(isMicroTimeFormatted("2026-05-11T21:25:36.495095754Z")).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("rejects millisecond precision (Date.toISOString() shape)", () => {
|
|
37
|
+
expect(isMicroTimeFormatted("2026-05-11T21:25:36.495Z")).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("rejects non-UTC offset", () => {
|
|
41
|
+
expect(isMicroTimeFormatted("2026-05-11T21:25:36.495000-05:00")).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("rejects garbage", () => {
|
|
45
|
+
expect(isMicroTimeFormatted("yesterday")).toBe(false);
|
|
46
|
+
expect(isMicroTimeFormatted("")).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for K8s `MicroTime` fields.
|
|
3
|
+
*
|
|
4
|
+
* The K8s API server validates fields like `coordination.k8s.io/v1.Lease.spec.renewTime`
|
|
5
|
+
* (kind: MicroTime) with the exact format `2006-01-02T15:04:05.000000Z07:00`
|
|
6
|
+
* — microsecond precision, always 6 fractional digits, UTC offset.
|
|
7
|
+
*
|
|
8
|
+
* The naive JS `Date.toISOString()` (millisecond precision, 3 fractional
|
|
9
|
+
* digits) is accepted by the API server too — the schema is more permissive
|
|
10
|
+
* than the documented format string — but `time.RFC3339Nano`-style strings
|
|
11
|
+
* with nanosecond precision (9 fractional digits) are rejected as
|
|
12
|
+
* `422 Unprocessable Entity` with:
|
|
13
|
+
*
|
|
14
|
+
* parsing time "...754Z" as "2006-01-02T15:04:05.000000Z07:00":
|
|
15
|
+
* cannot parse "754Z" as "Z07:00"
|
|
16
|
+
*
|
|
17
|
+
* Pass a JS Date through `microTime()` to get a string the API server
|
|
18
|
+
* always accepts. This is the canonical way to populate MicroTime fields
|
|
19
|
+
* when authoring chant resources by hand.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Format a Date as a K8s MicroTime string (UTC, exactly 6 fractional digits).
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* import { Lease } from "@intentius/chant-lexicon-k8s";
|
|
27
|
+
* import { microTime } from "@intentius/chant-lexicon-k8s/micro-time";
|
|
28
|
+
*
|
|
29
|
+
* new Lease({
|
|
30
|
+
* metadata: { name: "my-lease", namespace: "default" },
|
|
31
|
+
* spec: {
|
|
32
|
+
* holderIdentity: "node-1",
|
|
33
|
+
* leaseDurationSeconds: 15,
|
|
34
|
+
* renewTime: microTime(new Date()),
|
|
35
|
+
* },
|
|
36
|
+
* });
|
|
37
|
+
*/
|
|
38
|
+
export function microTime(date: Date = new Date()): string {
|
|
39
|
+
const iso = date.toISOString(); // "2026-05-11T21:25:36.495Z" (3 digits)
|
|
40
|
+
// Replace the millisecond fragment with a 6-digit microsecond fragment.
|
|
41
|
+
// toISOString() always produces ms precision; pad to 6 by appending 3 zeros.
|
|
42
|
+
return iso.replace(/\.(\d{3})Z$/, ".$1000Z");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Returns true if `value` is a string in the K8s MicroTime canonical format
|
|
47
|
+
* (UTC, microsecond precision). Useful in lint/validation contexts.
|
|
48
|
+
*/
|
|
49
|
+
export function isMicroTimeFormatted(value: string): boolean {
|
|
50
|
+
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/.test(value);
|
|
51
|
+
}
|
package/src/plugin.test.ts
CHANGED
|
@@ -177,13 +177,13 @@ describe("k8sPlugin", () => {
|
|
|
177
177
|
test("mcpTools() returns diff tool", () => {
|
|
178
178
|
const tools = k8sPlugin.mcpTools!();
|
|
179
179
|
expect(Array.isArray(tools)).toBe(true);
|
|
180
|
-
expect(tools.some((t) => t.name === "diff")).toBe(true);
|
|
180
|
+
expect(tools.some((t) => t.name === "k8s:diff")).toBe(true);
|
|
181
181
|
});
|
|
182
182
|
|
|
183
183
|
test("mcpResources() returns resource-catalog and examples", () => {
|
|
184
184
|
const resources = k8sPlugin.mcpResources!();
|
|
185
185
|
expect(Array.isArray(resources)).toBe(true);
|
|
186
|
-
expect(resources.some((r) => r.uri === "resource-catalog")).toBe(true);
|
|
186
|
+
expect(resources.some((r) => r.uri === "k8s:resource-catalog")).toBe(true);
|
|
187
187
|
expect(resources.some((r) => r.uri === "examples/basic-deployment")).toBe(true);
|
|
188
188
|
});
|
|
189
189
|
|
package/src/plugin.ts
CHANGED
|
@@ -273,103 +273,13 @@ export const service = new Service({
|
|
|
273
273
|
console.error(`Packaged ${stats.resources} resources, ${stats.ruleCount} rules, ${stats.skillCount} skills`);
|
|
274
274
|
},
|
|
275
275
|
|
|
276
|
-
async describeResources(options: {
|
|
277
|
-
environment: string;
|
|
278
|
-
buildOutput: string;
|
|
279
|
-
entityNames: string[];
|
|
280
|
-
}): Promise<Record<string, ResourceMetadata>> {
|
|
281
|
-
const { getRuntime } = await import("@intentius/chant/runtime-adapter");
|
|
282
|
-
const rt = getRuntime();
|
|
283
|
-
const resources: Record<string, ResourceMetadata> = {};
|
|
284
|
-
|
|
285
|
-
// Resolve namespace from environment (convention: env name = namespace)
|
|
286
|
-
const namespace = options.environment;
|
|
287
|
-
|
|
288
|
-
// Parse build output to extract kind/name pairs for each entity
|
|
289
|
-
let manifests: Array<{ kind: string; metadata: { name: string; namespace?: string }; apiVersion: string }> = [];
|
|
290
|
-
try {
|
|
291
|
-
// K8s build output is YAML with --- separators
|
|
292
|
-
const docs = options.buildOutput.split(/^---$/m).filter((d) => d.trim());
|
|
293
|
-
for (const doc of docs) {
|
|
294
|
-
// Simple YAML parsing — look for kind: and metadata.name:
|
|
295
|
-
const kindMatch = doc.match(/^kind:\s*(.+)$/m);
|
|
296
|
-
const nameMatch = doc.match(/^\s+name:\s*(.+)$/m);
|
|
297
|
-
const apiVersionMatch = doc.match(/^apiVersion:\s*(.+)$/m);
|
|
298
|
-
if (kindMatch && nameMatch && apiVersionMatch) {
|
|
299
|
-
manifests.push({
|
|
300
|
-
kind: kindMatch[1].trim(),
|
|
301
|
-
metadata: { name: nameMatch[1].trim() },
|
|
302
|
-
apiVersion: apiVersionMatch[1].trim(),
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
} catch {
|
|
307
|
-
// If build output parsing fails, skip
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// Query each resource
|
|
311
|
-
for (const entityName of options.entityNames) {
|
|
312
|
-
// Find the corresponding manifest
|
|
313
|
-
const manifest = manifests.find((m) => {
|
|
314
|
-
// Try matching by entity name convention
|
|
315
|
-
return m.metadata.name === entityName ||
|
|
316
|
-
entityName.toLowerCase().includes(m.metadata.name.toLowerCase());
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
if (!manifest) continue;
|
|
320
|
-
|
|
321
|
-
// Build kubectl resource type from apiVersion + kind
|
|
322
|
-
const resourceType = manifest.kind.toLowerCase();
|
|
323
|
-
const getResult = await rt.spawn([
|
|
324
|
-
"kubectl", "get", resourceType, manifest.metadata.name,
|
|
325
|
-
"-n", namespace,
|
|
326
|
-
"-o", "json",
|
|
327
|
-
]);
|
|
328
|
-
|
|
329
|
-
if (getResult.exitCode !== 0) continue;
|
|
330
|
-
|
|
331
|
-
try {
|
|
332
|
-
const obj = JSON.parse(getResult.stdout) as {
|
|
333
|
-
metadata: { name: string; uid: string; creationTimestamp: string };
|
|
334
|
-
status?: { phase?: string; conditions?: Array<{ type: string; status: string }> };
|
|
335
|
-
};
|
|
336
|
-
|
|
337
|
-
// Derive status from conditions or phase
|
|
338
|
-
let status = "Unknown";
|
|
339
|
-
if (obj.status?.phase) {
|
|
340
|
-
status = obj.status.phase;
|
|
341
|
-
} else if (obj.status?.conditions) {
|
|
342
|
-
const ready = obj.status.conditions.find((c) => c.type === "Ready" || c.type === "Available");
|
|
343
|
-
status = ready?.status === "True" ? "Ready" : "NotReady";
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// Build attributes, scrubbing sensitive data
|
|
347
|
-
const attributes: Record<string, unknown> = {
|
|
348
|
-
uid: obj.metadata.uid,
|
|
349
|
-
};
|
|
350
|
-
|
|
351
|
-
resources[entityName] = {
|
|
352
|
-
type: `${manifest.apiVersion}/${manifest.kind}`,
|
|
353
|
-
physicalId: obj.metadata.name,
|
|
354
|
-
status,
|
|
355
|
-
lastUpdated: obj.metadata.creationTimestamp,
|
|
356
|
-
attributes,
|
|
357
|
-
};
|
|
358
|
-
} catch {
|
|
359
|
-
// Skip parse failures
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
return resources;
|
|
364
|
-
},
|
|
365
|
-
|
|
366
276
|
mcpTools() {
|
|
367
|
-
return [createDiffTool(k8sSerializer, "Compare current build output against previous output for Kubernetes manifests")];
|
|
277
|
+
return [createDiffTool(k8sSerializer, "Compare current build output against previous output for Kubernetes manifests", "k8s")];
|
|
368
278
|
},
|
|
369
279
|
|
|
370
280
|
mcpResources() {
|
|
371
281
|
return [
|
|
372
|
-
createCatalogResource(import.meta.url, "Kubernetes Resource Catalog", "JSON list of all supported Kubernetes resource types", "lexicon-k8s.json"),
|
|
282
|
+
createCatalogResource(import.meta.url, "Kubernetes Resource Catalog", "JSON list of all supported Kubernetes resource types", "lexicon-k8s.json", "k8s"),
|
|
373
283
|
{
|
|
374
284
|
uri: "examples/basic-deployment",
|
|
375
285
|
name: "Basic Deployment Example",
|
|
@@ -657,4 +567,9 @@ const { deployment, service, serviceMonitor, prometheusRule } = MonitoredService
|
|
|
657
567
|
],
|
|
658
568
|
},
|
|
659
569
|
]),
|
|
570
|
+
|
|
571
|
+
async describeResources(options) {
|
|
572
|
+
const { describeResources } = await import("./describe-resources");
|
|
573
|
+
return describeResources(options);
|
|
574
|
+
},
|
|
660
575
|
};
|