@intentius/chant-lexicon-k8s 0.31.0 → 0.32.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/api/classify.d.ts +10 -0
- package/dist/api/classify.d.ts.map +1 -1
- package/dist/api/owner-chain.d.ts +46 -0
- package/dist/api/owner-chain.d.ts.map +1 -0
- package/dist/api/sweep-types.d.ts +17 -0
- package/dist/api/sweep-types.d.ts.map +1 -0
- package/dist/deep-observe-hooks.d.ts +86 -0
- package/dist/deep-observe-hooks.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +146 -0
- package/dist/deep-observe.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +15 -0
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/export-resources.d.ts +6 -2
- package/dist/export-resources.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/kube/apply.d.ts +26 -0
- package/dist/kube/apply.d.ts.map +1 -0
- package/dist/kube/connect.d.ts +30 -0
- package/dist/kube/connect.d.ts.map +1 -0
- package/dist/kube/delete.d.ts +27 -0
- package/dist/kube/delete.d.ts.map +1 -0
- package/dist/kube/describe.d.ts +19 -0
- package/dist/kube/describe.d.ts.map +1 -0
- package/dist/kube/events.d.ts +34 -0
- package/dist/kube/events.d.ts.map +1 -0
- package/dist/kube/flags.d.ts +40 -0
- package/dist/kube/flags.d.ts.map +1 -0
- package/dist/kube/get.d.ts +21 -0
- package/dist/kube/get.d.ts.map +1 -0
- package/dist/kube/group.d.ts +19 -0
- package/dist/kube/group.d.ts.map +1 -0
- package/dist/kube/logs.d.ts +16 -0
- package/dist/kube/logs.d.ts.map +1 -0
- package/dist/kube/project.d.ts +59 -0
- package/dist/kube/project.d.ts.map +1 -0
- package/dist/kube/render.d.ts +75 -0
- package/dist/kube/render.d.ts.map +1 -0
- package/dist/kube/source.d.ts +19 -0
- package/dist/kube/source.d.ts.map +1 -0
- package/dist/kube/target.d.ts +52 -0
- package/dist/kube/target.d.ts.map +1 -0
- package/dist/kube/testing.d.ts +21 -0
- package/dist/kube/testing.d.ts.map +1 -0
- package/dist/kube/top.d.ts +23 -0
- package/dist/kube/top.d.ts.map +1 -0
- package/dist/kube/verdict.d.ts +45 -0
- package/dist/kube/verdict.d.ts.map +1 -0
- package/dist/kube/wait.d.ts +23 -0
- package/dist/kube/wait.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.d.ts +6 -3
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/kubectl.d.ts +91 -9
- package/dist/op/activities/kubectl.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/api/classify.ts +13 -0
- package/src/api/owner-chain.test.ts +171 -0
- package/src/api/owner-chain.ts +144 -0
- package/src/api/sweep-types.ts +36 -0
- package/src/deep-observe-hooks.ts +125 -0
- package/src/deep-observe.test.ts +668 -0
- package/src/deep-observe.ts +299 -0
- package/src/describe-resources.test.ts +224 -1
- package/src/describe-resources.ts +86 -1
- package/src/export-resources.ts +6 -22
- package/src/kube/apply.test.ts +105 -0
- package/src/kube/apply.ts +101 -0
- package/src/kube/connect.ts +54 -0
- package/src/kube/delete.test.ts +123 -0
- package/src/kube/delete.ts +174 -0
- package/src/kube/describe.test.ts +106 -0
- package/src/kube/describe.ts +195 -0
- package/src/kube/events.test.ts +115 -0
- package/src/kube/events.ts +160 -0
- package/src/kube/flags.test.ts +65 -0
- package/src/kube/flags.ts +114 -0
- package/src/kube/get.test.ts +195 -0
- package/src/kube/get.ts +190 -0
- package/src/kube/group.test.ts +33 -0
- package/src/kube/group.ts +125 -0
- package/src/kube/logs.test.ts +126 -0
- package/src/kube/logs.ts +96 -0
- package/src/kube/project.test.ts +57 -0
- package/src/kube/project.ts +106 -0
- package/src/kube/render.test.ts +142 -0
- package/src/kube/render.ts +252 -0
- package/src/kube/source.test.ts +138 -0
- package/src/kube/source.ts +139 -0
- package/src/kube/target.test.ts +67 -0
- package/src/kube/target.ts +89 -0
- package/src/kube/testing.ts +42 -0
- package/src/kube/top.test.ts +149 -0
- package/src/kube/top.ts +120 -0
- package/src/kube/verdict.test.ts +48 -0
- package/src/kube/verdict.ts +77 -0
- package/src/kube/wait.test.ts +125 -0
- package/src/kube/wait.ts +160 -0
- package/src/op/activities/index.ts +6 -3
- package/src/op/activities/kubectl.test.ts +298 -26
- package/src/op/activities/kubectl.ts +313 -20
- package/src/plugin.test.ts +58 -1
- package/src/plugin.ts +29 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-k8s",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.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",
|
|
@@ -71,14 +71,14 @@
|
|
|
71
71
|
"@types/js-yaml": "^4.0.9"
|
|
72
72
|
},
|
|
73
73
|
"optionalDependencies": {
|
|
74
|
-
"@intentius/chant-k8s-client": "^0.
|
|
74
|
+
"@intentius/chant-k8s-client": "^0.32.0"
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
|
77
77
|
"@intentius/chant": "*",
|
|
78
78
|
"typescript": "^5.9.3"
|
|
79
79
|
},
|
|
80
80
|
"peerDependencies": {
|
|
81
|
-
"@intentius/chant": "^0.
|
|
81
|
+
"@intentius/chant": "^0.32.0",
|
|
82
82
|
"typescript": "^5.9.3"
|
|
83
83
|
}
|
|
84
84
|
}
|
package/src/api/classify.ts
CHANGED
|
@@ -96,6 +96,19 @@ export function classifyApiFailure(err: unknown): K8sReadOutcome {
|
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* The cluster's own discovery reports no such kind at all — distinct from a
|
|
101
|
+
* kind that exists but currently has zero live instances. `classifyApiFailure`
|
|
102
|
+
* folds both into `absent` for the declared-entity read path, where "no
|
|
103
|
+
* instance can exist" is exactly the signal a `create` needs. `chant kube get`
|
|
104
|
+
* (chant #1079) needs the finer distinction: an ad hoc `chant kube get
|
|
105
|
+
* widgets` against a kind the cluster has never heard of should say so, not
|
|
106
|
+
* print "No resources found." as if `widgets` were a real, empty kind.
|
|
107
|
+
*/
|
|
108
|
+
export function isUnknownResource(err: unknown): boolean {
|
|
109
|
+
return shapeOf(err).name === NAMES.unknownResource;
|
|
110
|
+
}
|
|
111
|
+
|
|
99
112
|
/**
|
|
100
113
|
* Whether a failure kills the whole observation rather than one entity. A
|
|
101
114
|
* refused binding, a missing client package and a rejected credential plugin
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, test, expect, vi } from "vitest";
|
|
2
|
+
import { resolveK8sOwnerChain } from "./owner-chain";
|
|
3
|
+
import type { K8sObject } from "@intentius/chant-k8s-client";
|
|
4
|
+
|
|
5
|
+
function pod(uid: string, ownerRefs?: Array<Record<string, unknown>>): K8sObject {
|
|
6
|
+
return {
|
|
7
|
+
apiVersion: "v1",
|
|
8
|
+
kind: "Pod",
|
|
9
|
+
metadata: { name: "p", namespace: "prod", uid, ...(ownerRefs ? { ownerReferences: ownerRefs } : {}) },
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function ownerRef(kind: string, name: string, uid: string, controller = true): Record<string, unknown> {
|
|
14
|
+
return { apiVersion: "apps/v1", kind, name, uid, controller };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("resolveK8sOwnerChain (#1077)", () => {
|
|
18
|
+
test("no ownerReferences at all → unowned", async () => {
|
|
19
|
+
const reader = { readIfPresent: vi.fn() };
|
|
20
|
+
const result = await resolveK8sOwnerChain(pod("pod-uid"), {
|
|
21
|
+
declaredByUid: new Map(),
|
|
22
|
+
reader,
|
|
23
|
+
namespace: "prod",
|
|
24
|
+
});
|
|
25
|
+
expect(result).toEqual({ root: "unowned" });
|
|
26
|
+
expect(reader.readIfPresent).not.toHaveBeenCalled();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("owner uid matches a declared entity directly → declared, no extra reads", async () => {
|
|
30
|
+
const reader = { readIfPresent: vi.fn() };
|
|
31
|
+
const result = await resolveK8sOwnerChain(pod("pod-uid", [ownerRef("Deployment", "web", "deploy-uid")]), {
|
|
32
|
+
declaredByUid: new Map([["deploy-uid", "web"]]),
|
|
33
|
+
reader,
|
|
34
|
+
namespace: "prod",
|
|
35
|
+
});
|
|
36
|
+
expect(result).toEqual({ root: "declared", entity: "web" });
|
|
37
|
+
expect(reader.readIfPresent).not.toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("Pod → ReplicaSet (undeclared, read live) → Deployment (declared) resolves through the intermediate hop", async () => {
|
|
41
|
+
const replicaSet: K8sObject = {
|
|
42
|
+
apiVersion: "apps/v1",
|
|
43
|
+
kind: "ReplicaSet",
|
|
44
|
+
metadata: {
|
|
45
|
+
name: "web-7d9f8c9c8",
|
|
46
|
+
namespace: "prod",
|
|
47
|
+
uid: "rs-uid",
|
|
48
|
+
ownerReferences: [ownerRef("Deployment", "web", "deploy-uid")],
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
const reader = { readIfPresent: vi.fn().mockResolvedValue(replicaSet) };
|
|
52
|
+
const result = await resolveK8sOwnerChain(pod("pod-uid", [ownerRef("ReplicaSet", "web-7d9f8c9c8", "rs-uid")]), {
|
|
53
|
+
declaredByUid: new Map([["deploy-uid", "web"]]),
|
|
54
|
+
reader,
|
|
55
|
+
namespace: "prod",
|
|
56
|
+
});
|
|
57
|
+
expect(result).toEqual({ root: "declared", entity: "web" });
|
|
58
|
+
expect(reader.readIfPresent).toHaveBeenCalledTimes(1);
|
|
59
|
+
expect(reader.readIfPresent).toHaveBeenCalledWith({
|
|
60
|
+
apiVersion: "apps/v1",
|
|
61
|
+
kind: "ReplicaSet",
|
|
62
|
+
name: "web-7d9f8c9c8",
|
|
63
|
+
namespace: "prod",
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("chain resolves fully to a live, undeclared root → foreign", async () => {
|
|
68
|
+
const replicaSet: K8sObject = {
|
|
69
|
+
apiVersion: "apps/v1",
|
|
70
|
+
kind: "ReplicaSet",
|
|
71
|
+
metadata: { name: "other-rs", namespace: "prod", uid: "rs-uid" }, // no further owner
|
|
72
|
+
};
|
|
73
|
+
const reader = { readIfPresent: vi.fn().mockResolvedValue(replicaSet) };
|
|
74
|
+
const result = await resolveK8sOwnerChain(pod("pod-uid", [ownerRef("ReplicaSet", "other-rs", "rs-uid")]), {
|
|
75
|
+
declaredByUid: new Map(), // nothing declared at all
|
|
76
|
+
reader,
|
|
77
|
+
namespace: "prod",
|
|
78
|
+
});
|
|
79
|
+
expect(result).toEqual({ root: "foreign" });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("an owner read that 404s (readIfPresent → undefined) is conservative unknown, not foreign", async () => {
|
|
83
|
+
const reader = { readIfPresent: vi.fn().mockResolvedValue(undefined) };
|
|
84
|
+
const result = await resolveK8sOwnerChain(pod("pod-uid", [ownerRef("ReplicaSet", "gone-rs", "rs-uid")]), {
|
|
85
|
+
declaredByUid: new Map(),
|
|
86
|
+
reader,
|
|
87
|
+
namespace: "prod",
|
|
88
|
+
});
|
|
89
|
+
expect(result).toEqual({ root: "unknown" });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("an owner read that throws (RBAC denial, transport error) is conservative unknown", async () => {
|
|
93
|
+
const reader = { readIfPresent: vi.fn().mockRejectedValue(new Error("Forbidden")) };
|
|
94
|
+
const result = await resolveK8sOwnerChain(pod("pod-uid", [ownerRef("ReplicaSet", "denied-rs", "rs-uid")]), {
|
|
95
|
+
declaredByUid: new Map(),
|
|
96
|
+
reader,
|
|
97
|
+
namespace: "prod",
|
|
98
|
+
});
|
|
99
|
+
expect(result).toEqual({ root: "unknown" });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("a cycle (A owns B, B owns A) terminates and classifies unknown rather than looping forever", async () => {
|
|
103
|
+
const objA: K8sObject = {
|
|
104
|
+
apiVersion: "v1",
|
|
105
|
+
kind: "Widget",
|
|
106
|
+
metadata: { name: "a", namespace: "prod", uid: "a-uid", ownerReferences: [ownerRef("Widget", "b", "b-uid")] },
|
|
107
|
+
};
|
|
108
|
+
const objB: K8sObject = {
|
|
109
|
+
apiVersion: "v1",
|
|
110
|
+
kind: "Widget",
|
|
111
|
+
metadata: { name: "b", namespace: "prod", uid: "b-uid", ownerReferences: [ownerRef("Widget", "a", "a-uid")] },
|
|
112
|
+
};
|
|
113
|
+
const reader = {
|
|
114
|
+
readIfPresent: vi.fn(async ({ name }: { name: string }) => (name === "a" ? objA : objB)),
|
|
115
|
+
};
|
|
116
|
+
const start: K8sObject = {
|
|
117
|
+
apiVersion: "v1",
|
|
118
|
+
kind: "Widget",
|
|
119
|
+
metadata: { name: "start", namespace: "prod", uid: "start-uid", ownerReferences: [ownerRef("Widget", "a", "a-uid")] },
|
|
120
|
+
};
|
|
121
|
+
const result = await resolveK8sOwnerChain(start, { declaredByUid: new Map(), reader, namespace: "prod" });
|
|
122
|
+
expect(result).toEqual({ root: "unknown" });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("respects a custom maxDepth, fetching no more than maxDepth + 1 objects", async () => {
|
|
126
|
+
// A chain 5 hops deep, but bounded to 2 — never reaches the declared root.
|
|
127
|
+
const objects: Record<string, K8sObject> = {};
|
|
128
|
+
for (let i = 0; i < 5; i++) {
|
|
129
|
+
objects[`n${i}`] = {
|
|
130
|
+
apiVersion: "v1",
|
|
131
|
+
kind: "Widget",
|
|
132
|
+
metadata: { name: `n${i}`, namespace: "prod", uid: `uid-${i}`, ownerReferences: [ownerRef("Widget", `n${i + 1}`, `uid-${i + 1}`)] },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const reader = {
|
|
136
|
+
readIfPresent: vi.fn(async ({ name }: { name: string }) => objects[name]),
|
|
137
|
+
};
|
|
138
|
+
const start: K8sObject = {
|
|
139
|
+
apiVersion: "v1",
|
|
140
|
+
kind: "Widget",
|
|
141
|
+
metadata: { name: "start", namespace: "prod", uid: "start-uid", ownerReferences: [ownerRef("Widget", "n0", "uid-0")] },
|
|
142
|
+
};
|
|
143
|
+
const result = await resolveK8sOwnerChain(start, {
|
|
144
|
+
declaredByUid: new Map([["uid-4", "deep-entity"]]), // reachable, but past the bound
|
|
145
|
+
reader,
|
|
146
|
+
namespace: "prod",
|
|
147
|
+
maxDepth: 2,
|
|
148
|
+
});
|
|
149
|
+
expect(result).toEqual({ root: "unknown" });
|
|
150
|
+
expect(reader.readIfPresent.mock.calls.length).toBeLessThanOrEqual(3); // maxDepth + 1
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a starting object without a uid cannot be classified — unknown", async () => {
|
|
154
|
+
const reader = { readIfPresent: vi.fn() };
|
|
155
|
+
const noUid: K8sObject = { apiVersion: "v1", kind: "Pod", metadata: { name: "p", namespace: "prod" } };
|
|
156
|
+
const result = await resolveK8sOwnerChain(noUid, { declaredByUid: new Map(), reader, namespace: "prod" });
|
|
157
|
+
expect(result).toEqual({ root: "unknown" });
|
|
158
|
+
expect(reader.readIfPresent).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("picks the controller:true owner reference when several are present", async () => {
|
|
162
|
+
const reader = { readIfPresent: vi.fn() };
|
|
163
|
+
const refs = [ownerRef("OtherOwner", "shared", "shared-uid", false), ownerRef("Deployment", "web", "deploy-uid", true)];
|
|
164
|
+
const result = await resolveK8sOwnerChain(pod("pod-uid", refs), {
|
|
165
|
+
declaredByUid: new Map([["deploy-uid", "web"]]),
|
|
166
|
+
reader,
|
|
167
|
+
namespace: "prod",
|
|
168
|
+
});
|
|
169
|
+
expect(result).toEqual({ root: "declared", entity: "web" });
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolving a live Kubernetes object's `ownerReferences` chain up to a
|
|
3
|
+
* declared, chant-observed entity (chant #1077).
|
|
4
|
+
*
|
|
5
|
+
* There is no server-side "who ultimately owns this object" query — Kubernetes'
|
|
6
|
+
* own garbage collector does not have one either; it walks the same chain one
|
|
7
|
+
* hop at a time, reading each intermediate owner. A Pod's `ownerReferences`
|
|
8
|
+
* names its ReplicaSet, which chant never declared; the ReplicaSet's own
|
|
9
|
+
* `ownerReferences` names the Deployment, which is declared. Reaching that
|
|
10
|
+
* declared entity takes reading the ReplicaSet in between.
|
|
11
|
+
*
|
|
12
|
+
* This module's only job is assembling that chain — fetching each hop through
|
|
13
|
+
* the typed client, bounded and cycle-guarded so a corrupt or adversarial
|
|
14
|
+
* chain cannot hang an observation. The bounded/cycle-safe *interpretation* of
|
|
15
|
+
* the assembled chain (declared / unowned / foreign / unknown) is core's
|
|
16
|
+
* `classifyOwnerChain` (`@intentius/chant/owner-chain`) — the issue's own
|
|
17
|
+
* division of labor: core owns the category, the lexicon supplies the chain.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { K8sClient, K8sObject } from "@intentius/chant-k8s-client";
|
|
21
|
+
import {
|
|
22
|
+
classifyOwnerChain,
|
|
23
|
+
DEFAULT_MAX_OWNER_CHAIN_DEPTH,
|
|
24
|
+
type OwnerChainNode,
|
|
25
|
+
type OwnerChainVerdict,
|
|
26
|
+
} from "@intentius/chant/owner-chain";
|
|
27
|
+
|
|
28
|
+
interface RawOwnerRef {
|
|
29
|
+
apiVersion?: string;
|
|
30
|
+
kind?: string;
|
|
31
|
+
name?: string;
|
|
32
|
+
uid?: string;
|
|
33
|
+
controller?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ownerRefs(obj: K8sObject): RawOwnerRef[] {
|
|
37
|
+
const refs = obj.metadata?.ownerReferences;
|
|
38
|
+
return Array.isArray(refs) ? (refs as RawOwnerRef[]) : [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The reference Kubernetes' own garbage collector treats as the controlling
|
|
43
|
+
* parent: the entry with `controller: true`, else the first. An object with
|
|
44
|
+
* several owner references (rare — usually a shared, non-controller owner
|
|
45
|
+
* alongside the controlling one) is walked through its controller, matching
|
|
46
|
+
* what actually recreates the object were it deleted.
|
|
47
|
+
*/
|
|
48
|
+
function controllingRef(obj: K8sObject): RawOwnerRef | undefined {
|
|
49
|
+
const refs = ownerRefs(obj);
|
|
50
|
+
return refs.find((r) => r.controller === true) ?? refs[0];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The client surface this module needs — a single object read, addressed by
|
|
54
|
+
* an owner reference's own coordinates. */
|
|
55
|
+
export type OwnerChainReader = Pick<K8sClient, "readIfPresent">;
|
|
56
|
+
|
|
57
|
+
export interface ResolveOwnerChainOptions {
|
|
58
|
+
/** uid → declared chant entity name, from this observation's own resolved entities. */
|
|
59
|
+
declaredByUid: ReadonlyMap<string, string>;
|
|
60
|
+
/** Reads each intermediate owner. */
|
|
61
|
+
reader: OwnerChainReader;
|
|
62
|
+
/**
|
|
63
|
+
* Namespace the starting object lives in. Owner references are same-namespace
|
|
64
|
+
* only — a namespaced object cannot be owned by an object in another
|
|
65
|
+
* namespace, the same rule Kubernetes' own garbage collector enforces —so
|
|
66
|
+
* every hop is read in this namespace.
|
|
67
|
+
*/
|
|
68
|
+
namespace: string | undefined;
|
|
69
|
+
maxDepth?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Walk `obj`'s owner-reference chain up to a declared entity, a foreign root,
|
|
74
|
+
* or a bound (#1077). Fetches at most `maxDepth + 1` objects (the starting
|
|
75
|
+
* object plus up to `maxDepth` ancestors) — the same bound `classifyOwnerChain`
|
|
76
|
+
* enforces when interpreting the result, so nothing is fetched that the
|
|
77
|
+
* interpretation would not have used anyway.
|
|
78
|
+
*/
|
|
79
|
+
export async function resolveK8sOwnerChain(
|
|
80
|
+
obj: K8sObject,
|
|
81
|
+
options: ResolveOwnerChainOptions,
|
|
82
|
+
): Promise<OwnerChainVerdict> {
|
|
83
|
+
const startUid = obj.metadata?.uid;
|
|
84
|
+
if (!startUid) return { root: "unknown" };
|
|
85
|
+
|
|
86
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_OWNER_CHAIN_DEPTH;
|
|
87
|
+
const nodes = new Map<string, OwnerChainNode>();
|
|
88
|
+
const visited = new Set<string>();
|
|
89
|
+
|
|
90
|
+
let uid: string | undefined = startUid;
|
|
91
|
+
let object: K8sObject | undefined = obj;
|
|
92
|
+
|
|
93
|
+
for (let depth = 0; uid !== undefined && depth <= maxDepth; depth++) {
|
|
94
|
+
if (visited.has(uid)) break; // cycle — classifyOwnerChain reads it off `nodes`
|
|
95
|
+
visited.add(uid);
|
|
96
|
+
|
|
97
|
+
const declaredEntity = options.declaredByUid.get(uid);
|
|
98
|
+
if (declaredEntity) {
|
|
99
|
+
nodes.set(uid, { declaredEntity });
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
if (!object) {
|
|
103
|
+
nodes.set(uid, { ownerUnreadable: true });
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const ref = controllingRef(object);
|
|
108
|
+
if (!ref?.uid || !ref.kind || !ref.apiVersion || !ref.name) {
|
|
109
|
+
nodes.set(uid, {}); // no further owner — a real, live root
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
nodes.set(uid, { ownerId: ref.uid });
|
|
113
|
+
|
|
114
|
+
if (visited.has(ref.uid)) {
|
|
115
|
+
// About to cycle back to an already-visited node — let the loop's own
|
|
116
|
+
// check classify it next iteration rather than issuing a wasted read.
|
|
117
|
+
uid = ref.uid;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (options.declaredByUid.has(ref.uid)) {
|
|
121
|
+
// Already known to be a declared entity from this observation's own
|
|
122
|
+
// resolved set — no need to read it, the next iteration resolves it
|
|
123
|
+
// from `declaredByUid` directly.
|
|
124
|
+
uid = ref.uid;
|
|
125
|
+
object = undefined;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
object = await options.reader.readIfPresent({
|
|
131
|
+
apiVersion: ref.apiVersion,
|
|
132
|
+
kind: ref.kind,
|
|
133
|
+
name: ref.name,
|
|
134
|
+
namespace: options.namespace,
|
|
135
|
+
});
|
|
136
|
+
} catch {
|
|
137
|
+
object = undefined;
|
|
138
|
+
}
|
|
139
|
+
if (!object) nodes.set(ref.uid, { ownerUnreadable: true });
|
|
140
|
+
uid = ref.uid;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return classifyOwnerChain(startUid, nodes, maxDepth);
|
|
144
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kinds chant sweeps when nothing narrows the question.
|
|
3
|
+
*
|
|
4
|
+
* A *product* decision — what a bare `chant import` should pull back, and
|
|
5
|
+
* (chant #1075) which kinds an ownership-scoped prune has to look at to notice
|
|
6
|
+
* that a whole kind was removed from source. Not an addressing limit: since
|
|
7
|
+
* chant #1074 removed `KUBECTL_RESOURCE`, a `--selector type=<entity type>`
|
|
8
|
+
* import can name any of the ~180 types the generated operation surface
|
|
9
|
+
* carries, CRDs included.
|
|
10
|
+
*
|
|
11
|
+
* It lives in its own module, with no imports of its own, because both
|
|
12
|
+
* consumers reach it from different directions — `../export-resources.ts`
|
|
13
|
+
* pulls in the whole import parser, and a Temporal worker loading the apply
|
|
14
|
+
* activity should not.
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_IMPORT_TYPES: readonly string[] = [
|
|
17
|
+
"K8s::Apps::Deployment",
|
|
18
|
+
"K8s::Apps::StatefulSet",
|
|
19
|
+
"K8s::Apps::DaemonSet",
|
|
20
|
+
"K8s::Apps::ReplicaSet",
|
|
21
|
+
"K8s::Core::Service",
|
|
22
|
+
"K8s::Core::ConfigMap",
|
|
23
|
+
"K8s::Core::Secret",
|
|
24
|
+
"K8s::Core::Namespace",
|
|
25
|
+
"K8s::Core::Pod",
|
|
26
|
+
"K8s::Core::PersistentVolumeClaim",
|
|
27
|
+
"K8s::Core::ServiceAccount",
|
|
28
|
+
"K8s::Batch::Job",
|
|
29
|
+
"K8s::Batch::CronJob",
|
|
30
|
+
"K8s::Networking::Ingress",
|
|
31
|
+
"K8s::Networking::NetworkPolicy",
|
|
32
|
+
"K8s::Rbac::Role",
|
|
33
|
+
"K8s::Rbac::RoleBinding",
|
|
34
|
+
"K8s::Rbac::ClusterRole",
|
|
35
|
+
"K8s::Rbac::ClusterRoleBinding",
|
|
36
|
+
];
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The k8s lexicon's *static* deep-observation noise rules (#1076, epic #1073).
|
|
3
|
+
*
|
|
4
|
+
* Split out from `./deep-observe.ts` for one reason: this file must be safe
|
|
5
|
+
* to import from `plugin.ts` at module load time, because
|
|
6
|
+
* `LexiconPlugin.deepNormalizationHooks` is plain data core reads to
|
|
7
|
+
* normalize the *declared* tree — the half of the contract that runs whether
|
|
8
|
+
* or not a cluster is ever touched (`lifecycle diff` without `--live`,
|
|
9
|
+
* `chant build`, tests that only exercise normalization). `./deep-observe.ts`
|
|
10
|
+
* itself imports `@intentius/chant-k8s-client` for the live read, and chant
|
|
11
|
+
* #1074 made that package's reachability from the build path a structural
|
|
12
|
+
* property (`examples/k8s-client-boundary.test.ts`) rather than a lint rule —
|
|
13
|
+
* so nothing this file exports may pull that package in, directly or
|
|
14
|
+
* transitively. It imports nothing but the core contract's own types.
|
|
15
|
+
*
|
|
16
|
+
* What lives here is deliberately the *entityType-keyed, resource-agnostic*
|
|
17
|
+
* half of the rules: which fields the API server always populates regardless
|
|
18
|
+
* of what a manager wrote (`status`, `metadata.uid`, …), which fields
|
|
19
|
+
* Kubernetes defaults when a manifest is silent about them, and which arrays
|
|
20
|
+
* are sets addressed by a well-known identity (containers by name, ports by
|
|
21
|
+
* containerPort+protocol). None of that needs a live object in hand.
|
|
22
|
+
*
|
|
23
|
+
* What does *not* live here is the managed-fields prune — whether one
|
|
24
|
+
* specific field on one specific live object is chant-owned, foreign-owned,
|
|
25
|
+
* or contested. That is inherently per-object (it depends on *that* object's
|
|
26
|
+
* `metadata.managedFields`, which the declared tree never carries and which
|
|
27
|
+
* differs between two Deployments of the same type), so it cannot be
|
|
28
|
+
* expressed as a fixed rule keyed only by entity type and path — the shape
|
|
29
|
+
* every other hook in this file takes. `./deep-observe.ts` computes it once
|
|
30
|
+
* per resource and layers it on top of the rules below.
|
|
31
|
+
*
|
|
32
|
+
* The *entity-type-agnostic* half of these rules — which fields every
|
|
33
|
+
* Kubernetes API object carries regardless of kind, and the well-known
|
|
34
|
+
* list-map-key ordering conventions (containers/env/volumes/ports) — lives in
|
|
35
|
+
* `@intentius/chant/managed-fields` (chant #1087), because a GCP Config
|
|
36
|
+
* Connector custom resource is a Kubernetes object too and needs the exact
|
|
37
|
+
* same rules without depending on this lexicon's package. What stays here is
|
|
38
|
+
* only what's genuinely k8s-*lexicon*-specific: {@link K8S_SERVICE_DEFAULTS},
|
|
39
|
+
* keyed by chant's own k8s entityType catalog.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import type { DeepNode, DeepNormalizationHooks } from "@intentius/chant/lexicon";
|
|
43
|
+
import { K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS, k8sListMapOrderKey } from "@intentius/chant/managed-fields";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Kubernetes-defaulted fields, per entity type, as index-erased property
|
|
47
|
+
* paths. Subtracted only where source never declared the property
|
|
48
|
+
* (`side === "live" && counterpart === "absent"`) — cdk-real-drift's default
|
|
49
|
+
* subtraction, same convention as AWS/Azure/Temporal's tables.
|
|
50
|
+
*
|
|
51
|
+
* Sparse and evidence-based rather than derived from the generated schema:
|
|
52
|
+
* the k8s OpenAPI spec this lexicon's codegen consumes
|
|
53
|
+
* (`lexicons/k8s/src/spec/parse.ts`) does not carry a `default` value for
|
|
54
|
+
* these fields the way ARM's schema sometimes does, so "per discovery" is not
|
|
55
|
+
* actually expressible today. Widening this table is additive and needs no
|
|
56
|
+
* contract change.
|
|
57
|
+
*
|
|
58
|
+
* `spec.strategy` is listed whole, not as `spec.strategy.type`, for the same
|
|
59
|
+
* reason Temporal's `TEMPORAL_SCHEDULE_DEFAULTS` lists `state` whole: pruning
|
|
60
|
+
* only the leaf would still recurse into the object, and a nested default the
|
|
61
|
+
* table does not separately name (`rollingUpdate.maxSurge`/`maxUnavailable`,
|
|
62
|
+
* both `"25%"` when `spec.strategy` is omitted entirely) would leave behind an
|
|
63
|
+
* empty `strategy: {}` — a value distinct from no `strategy` key at all, and
|
|
64
|
+
* itself a spurious "undeclared" finding. Matching the whole node first, before
|
|
65
|
+
* its children are ever visited, drops the wrapper outright when every field
|
|
66
|
+
* under it is exactly at its default.
|
|
67
|
+
*/
|
|
68
|
+
export const K8S_SERVICE_DEFAULTS: Record<string, Record<string, unknown>> = {
|
|
69
|
+
"K8s::Apps::Deployment": {
|
|
70
|
+
"spec.strategy": { type: "RollingUpdate", rollingUpdate: { maxSurge: "25%", maxUnavailable: "25%" } },
|
|
71
|
+
"spec.revisionHistoryLimit": 10,
|
|
72
|
+
"spec.progressDeadlineSeconds": 600,
|
|
73
|
+
"spec.template.spec.dnsPolicy": "ClusterFirst",
|
|
74
|
+
"spec.template.spec.restartPolicy": "Always",
|
|
75
|
+
"spec.template.spec.terminationGracePeriodSeconds": 30,
|
|
76
|
+
"spec.template.spec.schedulerName": "default-scheduler",
|
|
77
|
+
},
|
|
78
|
+
"K8s::Core::Service": {
|
|
79
|
+
"spec.sessionAffinity": "None",
|
|
80
|
+
"spec.type": "ClusterIP",
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Stable JSON with sorted keys — the fallback ordering key for a set-like array without a natural identity field. */
|
|
85
|
+
function canonicalJson(value: unknown): string {
|
|
86
|
+
return (
|
|
87
|
+
JSON.stringify(value, (_k, v: unknown) =>
|
|
88
|
+
v && typeof v === "object" && !Array.isArray(v)
|
|
89
|
+
? Object.fromEntries(Object.entries(v as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
|
|
90
|
+
: v,
|
|
91
|
+
) ?? ""
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The k8s lexicon's static noise rules: the generic Kubernetes object
|
|
97
|
+
* envelope (unconditional, by pattern, `@intentius/chant/managed-fields`) and
|
|
98
|
+
* Kubernetes-defaulted fields (gated on `counterpart === "absent"`), plus the
|
|
99
|
+
* array orderings the acceptance criteria name — `x-kubernetes-patch-merge-
|
|
100
|
+
* key`/`list-map-keys` conventions the generated surface does not currently
|
|
101
|
+
* carry (see the module doc), so these are the "else named-by-name
|
|
102
|
+
* conventions" the issue calls for: containers by `name`, `env` by `name`,
|
|
103
|
+
* `volumes` by `name`, container/service `ports` by `containerPort`/`port` +
|
|
104
|
+
* `protocol` — the same conventions `@intentius/chant/managed-fields`'s
|
|
105
|
+
* `k8sListMapOrderKey` implements, reused verbatim.
|
|
106
|
+
*
|
|
107
|
+
* This is the object `k8sPlugin.deepNormalizationHooks` is. It is also what
|
|
108
|
+
* `./deep-observe.ts` layers its per-resource managed-fields prune on top of,
|
|
109
|
+
* so the two normalization passes (the reader's own, and core's later
|
|
110
|
+
* re-normalization of both the declared and the already-normalized live tree
|
|
111
|
+
* — see `packages/core/src/lifecycle/deep-observe.ts`) apply the identical
|
|
112
|
+
* entityType-keyed rules either way.
|
|
113
|
+
*/
|
|
114
|
+
export const k8sDeepNormalizationHooks: DeepNormalizationHooks = {
|
|
115
|
+
prune(node: DeepNode): boolean {
|
|
116
|
+
if (K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS.has(node.pattern)) return true;
|
|
117
|
+
|
|
118
|
+
if (node.side !== "live" || node.counterpart !== "absent") return false;
|
|
119
|
+
const defaults = K8S_SERVICE_DEFAULTS[node.entityType];
|
|
120
|
+
if (!defaults || !Object.prototype.hasOwnProperty.call(defaults, node.pattern)) return false;
|
|
121
|
+
return canonicalJson(defaults[node.pattern]) === canonicalJson(node.value);
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
orderKey: k8sListMapOrderKey,
|
|
125
|
+
};
|