@intentius/chant-lexicon-k8s 0.30.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 +65 -0
- package/dist/api/classify.d.ts.map +1 -0
- package/dist/api/connect.d.ts +58 -0
- package/dist/api/connect.d.ts.map +1 -0
- package/dist/api/fake-cluster.d.ts +55 -0
- package/dist/api/fake-cluster.d.ts.map +1 -0
- package/dist/api/operation-surface.d.ts +64 -0
- package/dist/api/operation-surface.d.ts.map +1 -0
- 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/codegen/generate-operations.d.ts +29 -0
- package/dist/codegen/generate-operations.d.ts.map +1 -0
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/config.d.ts +17 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/crd/parser.d.ts.map +1 -1
- package/dist/crd/types.d.ts +7 -0
- package/dist/crd/types.d.ts.map +1 -1
- 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 +58 -26
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/export-resources.d.ts +31 -1
- 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 +7 -4
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/kubectl.d.ts +120 -2
- package/dist/op/activities/kubectl.d.ts.map +1 -1
- package/dist/op/activities/wait-for-ready.d.ts +30 -3
- package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/spec/parse.d.ts +42 -0
- package/dist/spec/parse.d.ts.map +1 -1
- package/package.json +5 -2
- package/src/api/classify.test.ts +133 -0
- package/src/api/classify.ts +144 -0
- package/src/api/connect.ts +104 -0
- package/src/api/fake-cluster.ts +218 -0
- package/src/api/operation-surface.test.ts +116 -0
- package/src/api/operation-surface.ts +129 -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/codegen/generate-operations.ts +56 -0
- package/src/codegen/generate.ts +9 -0
- package/src/config.ts +17 -0
- package/src/crd/parser.ts +8 -0
- package/src/crd/types.ts +7 -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 +619 -191
- package/src/describe-resources.ts +218 -117
- package/src/export-resources-io.test.ts +76 -51
- package/src/export-resources.ts +47 -35
- package/src/generated/operations.json +2156 -0
- 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/lifecycle-integration.test.ts +132 -92
- package/src/op/activities/index.ts +7 -3
- package/src/op/activities/kubectl.test.ts +420 -0
- package/src/op/activities/kubectl.ts +379 -13
- package/src/op/activities/wait-for-ready.test.ts +94 -0
- package/src/op/activities/wait-for-ready.ts +66 -15
- package/src/plugin.test.ts +58 -1
- package/src/plugin.ts +29 -0
- package/src/spec/parse.ts +93 -1
|
@@ -2,79 +2,69 @@
|
|
|
2
2
|
* Live introspection of a Kubernetes cluster — implements the
|
|
3
3
|
* LexiconPlugin.describeResources() contract for the k8s lexicon.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
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).
|
|
5
|
+
* ## What changed, and why it matters (chant #1074)
|
|
9
6
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* reason. Extending the KUBECTL_RESOURCE map converts unsupported-kind holes into
|
|
17
|
-
* real reads; until then they are holes chant admits to.
|
|
7
|
+
* This used to run `kubectl get <kind> <name> -o json` once per declared
|
|
8
|
+
* entity, serially, resolved through a hardcoded twenty-entry
|
|
9
|
+
* `KUBECTL_RESOURCE` map. Every CRD and every uncommon type fell off the end
|
|
10
|
+
* of that map and came back as a hole. Coverage, concurrency and error quality
|
|
11
|
+
* were all capped by it, and `lifecycle diff --live`, `lifecycle plan` and
|
|
12
|
+
* behold's overlay inherited the cap.
|
|
18
13
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
14
|
+
* Now:
|
|
15
|
+
*
|
|
16
|
+
* - **Coverage.** An entity type becomes an API address through the generated
|
|
17
|
+
* operation surface (`./api/operation-surface.ts`), which the same codegen
|
|
18
|
+
* pass that emits the declarable classes writes — 184 types rather than 20,
|
|
19
|
+
* CRDs included. The address is then confirmed against the cluster's *own*
|
|
20
|
+
* discovery, which is what knows the plural and the scope for the version
|
|
21
|
+
* that cluster actually serves.
|
|
22
|
+
* - **Concurrency.** Reads run through the client's bounded pool. A hundred
|
|
23
|
+
* entities are a hundred concurrent HTTP GETs sharing one connection and one
|
|
24
|
+
* cached credential, not a hundred serial process spawns.
|
|
25
|
+
* - **Error quality.** Failures arrive as typed errors carrying the API
|
|
26
|
+
* server's own `code` and `reason`, so the tri-state verdict is read off a
|
|
27
|
+
* field instead of matched against English on stderr.
|
|
28
|
+
*
|
|
29
|
+
* ## What did not change
|
|
30
|
+
*
|
|
31
|
+
* The observation tri-state (chant #1089) and the cluster binding (chant
|
|
32
|
+
* #1100/#1155) are the same contracts they were. A genuine `NotFound` — and a
|
|
33
|
+
* kind the cluster's discovery does not serve, where no instance can exist —
|
|
34
|
+
* is an absence, and only an absence becomes a `create`. Everything else is
|
|
35
|
+
* NOT-OBSERVED with a reason. A declared `k8s.profiles.<env>.context` that
|
|
36
|
+
* disagrees with the ambient one still refuses before a single resource is
|
|
37
|
+
* touched, via the same `resolveClusterTarget` the GCP lexicon shares.
|
|
38
|
+
*
|
|
39
|
+
* ## Runtime children (chant #1077)
|
|
40
|
+
*
|
|
41
|
+
* A Pod a declared Deployment's controller created is live and undeclared —
|
|
42
|
+
* exactly the shape `lifecycle diff --live` has always classified `orphan`.
|
|
43
|
+
* On Kubernetes that is wrong: it is expected runtime, not drift, and will be
|
|
44
|
+
* recreated the moment it is deleted. After the declared-entity reads above
|
|
45
|
+
* resolve, this scans Pods in the namespaces they actually live in and walks
|
|
46
|
+
* each one's `ownerReferences` chain (`./api/owner-chain.ts`) against the
|
|
47
|
+
* entities this very call resolved. A chain reaching one of them is reported
|
|
48
|
+
* with `ownerChain: { root: "declared", ... }`; core's diff/change-set engine
|
|
49
|
+
* reads that as `runtime` instead of `orphan`. Deliberately scoped to Pods —
|
|
50
|
+
* the concrete case the issue and its acceptance criteria name — and to the
|
|
51
|
+
* namespaces this observation already touched, not a cluster-wide sweep;
|
|
52
|
+
* widening to other controller-spawned kinds is the same walk repeated.
|
|
26
53
|
*/
|
|
27
54
|
|
|
28
|
-
import { exec } from "node:child_process";
|
|
29
|
-
import { promisify } from "node:util";
|
|
30
55
|
import type { ObservationResult, ResourceMetadata, UnobservedEntity } from "@intentius/chant/lexicon";
|
|
31
|
-
import { observation } from "@intentius/chant/observation";
|
|
56
|
+
import { observation, unobservedAll } from "@intentius/chant/observation";
|
|
32
57
|
import { hasOwnershipMarker, classifyOwnership, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"K8s::Apps::Deployment": "deployment.apps",
|
|
44
|
-
"K8s::Apps::StatefulSet": "statefulset.apps",
|
|
45
|
-
"K8s::Apps::DaemonSet": "daemonset.apps",
|
|
46
|
-
"K8s::Apps::ReplicaSet": "replicaset.apps",
|
|
47
|
-
"K8s::Core::Service": "service",
|
|
48
|
-
"K8s::Core::ConfigMap": "configmap",
|
|
49
|
-
"K8s::Core::Secret": "secret",
|
|
50
|
-
"K8s::Core::Namespace": "namespace",
|
|
51
|
-
"K8s::Core::Pod": "pod",
|
|
52
|
-
"K8s::Core::PersistentVolumeClaim": "persistentvolumeclaim",
|
|
53
|
-
"K8s::Core::ServiceAccount": "serviceaccount",
|
|
54
|
-
"K8s::Batch::Job": "job.batch",
|
|
55
|
-
"K8s::Batch::CronJob": "cronjob.batch",
|
|
56
|
-
"K8s::Networking::Ingress": "ingress.networking.k8s.io",
|
|
57
|
-
"K8s::Networking::NetworkPolicy": "networkpolicy.networking.k8s.io",
|
|
58
|
-
"K8s::Rbac::Role": "role.rbac.authorization.k8s.io",
|
|
59
|
-
"K8s::Rbac::RoleBinding": "rolebinding.rbac.authorization.k8s.io",
|
|
60
|
-
"K8s::Rbac::ClusterRole": "clusterrole.rbac.authorization.k8s.io",
|
|
61
|
-
"K8s::Rbac::ClusterRoleBinding": "clusterrolebinding.rbac.authorization.k8s.io",
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
interface KubectlResponse {
|
|
65
|
-
metadata?: {
|
|
66
|
-
name?: string;
|
|
67
|
-
namespace?: string;
|
|
68
|
-
uid?: string;
|
|
69
|
-
creationTimestamp?: string;
|
|
70
|
-
labels?: Record<string, string>;
|
|
71
|
-
resourceVersion?: string;
|
|
72
|
-
};
|
|
73
|
-
status?: {
|
|
74
|
-
phase?: string;
|
|
75
|
-
[k: string]: unknown;
|
|
76
|
-
};
|
|
77
|
-
}
|
|
58
|
+
import type { K8sClient, K8sObject } from "@intentius/chant-k8s-client";
|
|
59
|
+
import { defaultK8sConnector, type K8sConnector } from "./api/connect";
|
|
60
|
+
import {
|
|
61
|
+
classifyApiFailure,
|
|
62
|
+
isMissingClientPackage,
|
|
63
|
+
isWholeLexiconFailure,
|
|
64
|
+
MISSING_CLIENT_DETAIL,
|
|
65
|
+
} from "./api/classify";
|
|
66
|
+
import { operationFor } from "./api/operation-surface";
|
|
67
|
+
import { resolveK8sOwnerChain } from "./api/owner-chain";
|
|
78
68
|
|
|
79
69
|
function pruneUndefined<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
|
|
80
70
|
const out: Record<string, unknown> = {};
|
|
@@ -84,51 +74,98 @@ function pruneUndefined<T extends Record<string, unknown>>(obj: T): Record<strin
|
|
|
84
74
|
return out;
|
|
85
75
|
}
|
|
86
76
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Collapse a live object to one status word. Unchanged from the kubectl path
|
|
79
|
+
* — the shape of the response is the same JSON either way.
|
|
80
|
+
*/
|
|
81
|
+
export function statusFromObject(obj: K8sObject): string {
|
|
82
|
+
const status = obj.status;
|
|
83
|
+
const phase = status?.phase;
|
|
91
84
|
if (typeof phase === "string") return phase;
|
|
92
|
-
// Deployment/StatefulSet — readyReplicas == replicas → READY
|
|
93
|
-
const status = obj.status as Record<string, unknown> | undefined;
|
|
94
85
|
if (status && typeof status.readyReplicas === "number" && typeof status.replicas === "number") {
|
|
95
|
-
return status.readyReplicas === status.replicas
|
|
86
|
+
return status.readyReplicas === status.replicas
|
|
87
|
+
? "READY"
|
|
88
|
+
: `PROGRESSING(${status.readyReplicas}/${status.replicas})`;
|
|
96
89
|
}
|
|
97
90
|
return "PRESENT";
|
|
98
91
|
}
|
|
99
92
|
|
|
100
|
-
|
|
93
|
+
interface Declared {
|
|
94
|
+
entityName: string;
|
|
95
|
+
entityType: string;
|
|
96
|
+
props: Record<string, unknown>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface DescribeResourcesOptions {
|
|
101
100
|
environment: string;
|
|
102
101
|
buildOutput: string;
|
|
103
102
|
entityNames: string[];
|
|
104
103
|
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
105
104
|
owned?: boolean;
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
/** Directory whose `chant.config.ts` carries the cluster binding. Defaults to cwd. */
|
|
106
|
+
cwd?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function describeResources(
|
|
110
|
+
options: DescribeResourcesOptions,
|
|
111
|
+
connect: K8sConnector = defaultK8sConnector,
|
|
112
|
+
): Promise<ObservationResult> {
|
|
113
|
+
const resources: Record<string, ResourceMetadata> = {};
|
|
108
114
|
const unobserved: Record<string, UnobservedEntity> = {};
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
115
|
+
|
|
116
|
+
const declared: Declared[] = [...options.entities].map(([entityName, entity]) => ({
|
|
117
|
+
entityName,
|
|
118
|
+
entityType: entity.entityType,
|
|
119
|
+
props: entity.props,
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
// Connect first. The binding check lives here, so a bound-but-mismatched
|
|
123
|
+
// context throws before any resource is read — core turns that into
|
|
124
|
+
// NOT-OBSERVED for every declared entity rather than an empty result.
|
|
125
|
+
let client;
|
|
126
|
+
try {
|
|
127
|
+
({ client } = await connect({ environment: options.environment, cwd: options.cwd }));
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (isMissingClientPackage(err)) {
|
|
130
|
+
return observation(
|
|
131
|
+
{},
|
|
132
|
+
unobservedAll(
|
|
133
|
+
declared.map((d) => d.entityName),
|
|
134
|
+
"read-failed",
|
|
135
|
+
MISSING_CLIENT_DETAIL,
|
|
136
|
+
options.entities,
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (isWholeLexiconFailure(err)) {
|
|
141
|
+
const outcome = classifyApiFailure(err);
|
|
142
|
+
return observation(
|
|
143
|
+
{},
|
|
144
|
+
unobservedAll(
|
|
145
|
+
declared.map((d) => d.entityName),
|
|
146
|
+
outcome.kind === "unobserved" ? outcome.reason : "read-failed",
|
|
147
|
+
outcome.kind === "unobserved" ? outcome.detail : undefined,
|
|
148
|
+
options.entities,
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
// A cluster-binding mismatch (chant #1100) belongs here: it is a loud
|
|
153
|
+
// refusal, and core's whole-lexicon handling is what turns it into an
|
|
154
|
+
// honest hole per entity.
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await client.concurrently(declared, async ({ entityName, entityType, props }) => {
|
|
159
|
+
const operation = operationFor(entityType);
|
|
160
|
+
if (!operation) {
|
|
161
|
+
// chant knows no API address for this type at all — not even its group.
|
|
162
|
+
// The object may well exist, so this is a hole, never an absence.
|
|
126
163
|
unobserved[entityName] = {
|
|
127
164
|
type: entityType,
|
|
128
165
|
reason: "unsupported-kind",
|
|
129
|
-
detail: `no
|
|
166
|
+
detail: `no generated operation surface for ${entityType} — run \`chant generate\` in the k8s lexicon, or declare the CRD as a codegen source`,
|
|
130
167
|
};
|
|
131
|
-
|
|
168
|
+
return;
|
|
132
169
|
}
|
|
133
170
|
|
|
134
171
|
const metadata = props.metadata as { name?: string; namespace?: string } | undefined;
|
|
@@ -140,18 +177,20 @@ export async function describeResources(options: {
|
|
|
140
177
|
reason: "read-failed",
|
|
141
178
|
detail: "declared entity has no metadata.name to query by",
|
|
142
179
|
};
|
|
143
|
-
|
|
180
|
+
return;
|
|
144
181
|
}
|
|
145
182
|
|
|
146
|
-
const nsArg = metadata.namespace ? ["-n", metadata.namespace] : [];
|
|
147
|
-
const cmd = ["kubectl", "get", kubectlResource, name, ...nsArg, ...ctxArg, "-o", "json"].join(" ");
|
|
148
|
-
|
|
149
183
|
try {
|
|
150
|
-
const
|
|
151
|
-
|
|
184
|
+
const obj = await client.read({
|
|
185
|
+
apiVersion: operation.apiVersion,
|
|
186
|
+
kind: operation.kind,
|
|
187
|
+
name,
|
|
188
|
+
...(metadata?.namespace ? { namespace: metadata.namespace } : {}),
|
|
189
|
+
});
|
|
190
|
+
|
|
152
191
|
// owned filter: withhold resources not carrying chant's marker label.
|
|
153
|
-
// Withheld is not absent (#1089) — this object exists, it just
|
|
154
|
-
// chant's, and dropping it silently is how `--owned` used to turn a
|
|
192
|
+
// Withheld is not absent (chant #1089) — this object exists, it just
|
|
193
|
+
// isn't chant's, and dropping it silently is how `--owned` used to turn a
|
|
155
194
|
// declared-but-foreign resource into a proposed `create`.
|
|
156
195
|
if (options.owned && !hasOwnershipMarker(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS)) {
|
|
157
196
|
unobserved[entityName] = {
|
|
@@ -159,12 +198,13 @@ export async function describeResources(options: {
|
|
|
159
198
|
reason: "filtered",
|
|
160
199
|
detail: "live object carries no chant ownership marker and --owned was requested",
|
|
161
200
|
};
|
|
162
|
-
|
|
201
|
+
return;
|
|
163
202
|
}
|
|
164
|
-
|
|
203
|
+
|
|
204
|
+
resources[entityName] = {
|
|
165
205
|
type: entityType,
|
|
166
206
|
physicalId: obj.metadata?.uid,
|
|
167
|
-
status:
|
|
207
|
+
status: statusFromObject(obj),
|
|
168
208
|
lastUpdated: obj.metadata?.creationTimestamp,
|
|
169
209
|
ownership: classifyOwnership(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS),
|
|
170
210
|
attributes: pruneUndefined({
|
|
@@ -174,22 +214,83 @@ export async function describeResources(options: {
|
|
|
174
214
|
}),
|
|
175
215
|
};
|
|
176
216
|
} catch (err) {
|
|
177
|
-
|
|
178
|
-
// read as missing and the plan as create). Auth, connectivity, and every
|
|
179
|
-
// other failure prove nothing about existence and are reported as such.
|
|
180
|
-
const outcome = classifyKubectlFailure(err);
|
|
217
|
+
const outcome = classifyApiFailure(err);
|
|
181
218
|
if (outcome.kind === "unobserved") {
|
|
182
219
|
unobserved[entityName] = { type: entityType, reason: outcome.reason, detail: outcome.detail };
|
|
183
220
|
}
|
|
221
|
+
// `absent` deliberately records nothing: in neither map is how the
|
|
222
|
+
// contract spells "asked, and it is not there".
|
|
184
223
|
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await addRuntimeChildren(client, resources, options.owned);
|
|
227
|
+
|
|
228
|
+
return observation(resources, unobserved);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Scan Pods in the namespaces of entities this call just resolved, and
|
|
233
|
+
* classify each one's owner-reference chain (#1077). Mutates `resources` in
|
|
234
|
+
* place, adding an entry per Pod whose chain was worth reporting.
|
|
235
|
+
*
|
|
236
|
+
* Best-effort and additive: a namespace this scan cannot list (RBAC denial,
|
|
237
|
+
* a transient error) is simply skipped rather than failing the observation —
|
|
238
|
+
* Pods are not declared entities, so there is no NOT-OBSERVED axis for them
|
|
239
|
+
* to report against, the same way an out-of-band AWS child resource has none
|
|
240
|
+
* either.
|
|
241
|
+
*/
|
|
242
|
+
async function addRuntimeChildren(
|
|
243
|
+
client: K8sClient,
|
|
244
|
+
resources: Record<string, ResourceMetadata>,
|
|
245
|
+
owned: boolean | undefined,
|
|
246
|
+
): Promise<void> {
|
|
247
|
+
const declaredByUid = new Map<string, string>();
|
|
248
|
+
for (const [entityName, meta] of Object.entries(resources)) {
|
|
249
|
+
if (meta.physicalId) declaredByUid.set(meta.physicalId, entityName);
|
|
185
250
|
}
|
|
186
251
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
);
|
|
252
|
+
const namespaces = new Set<string>();
|
|
253
|
+
for (const meta of Object.values(resources)) {
|
|
254
|
+
const namespace = (meta.attributes as { namespace?: string } | undefined)?.namespace;
|
|
255
|
+
if (namespace) namespaces.add(namespace);
|
|
192
256
|
}
|
|
257
|
+
if (namespaces.size === 0) return;
|
|
258
|
+
|
|
259
|
+
await client.concurrently([...namespaces], async (namespace) => {
|
|
260
|
+
let pods: K8sObject[];
|
|
261
|
+
try {
|
|
262
|
+
pods = await client.list({ apiVersion: "v1", kind: "Pod" }, { namespace });
|
|
263
|
+
} catch {
|
|
264
|
+
return; // best-effort — see the function doc
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
await client.concurrently(pods, async (pod) => {
|
|
268
|
+
const uid = pod.metadata?.uid;
|
|
269
|
+
const name = pod.metadata?.name;
|
|
270
|
+
if (!uid || !name || declaredByUid.has(uid)) return; // declared directly, or unaddressable
|
|
193
271
|
|
|
194
|
-
|
|
272
|
+
const ownerChain = await resolveK8sOwnerChain(pod, { declaredByUid, reader: client, namespace });
|
|
273
|
+
|
|
274
|
+
// `--owned`: withhold a Pod that is neither a runtime child of a
|
|
275
|
+
// declared entity nor carrying chant's own marker — the same rule the
|
|
276
|
+
// declared-entity read above applies, extended to the undeclared axis
|
|
277
|
+
// this scan introduces.
|
|
278
|
+
const marker = hasOwnershipMarker(pod.metadata?.labels, LABEL_OWNERSHIP_KEYS);
|
|
279
|
+
if (owned && ownerChain.root !== "declared" && !marker) return;
|
|
280
|
+
|
|
281
|
+
resources[`${namespace}/${name}`] = {
|
|
282
|
+
type: "K8s::Core::Pod",
|
|
283
|
+
physicalId: uid,
|
|
284
|
+
status: statusFromObject(pod),
|
|
285
|
+
lastUpdated: pod.metadata?.creationTimestamp,
|
|
286
|
+
ownership: classifyOwnership(pod.metadata?.labels, LABEL_OWNERSHIP_KEYS),
|
|
287
|
+
ownerChain,
|
|
288
|
+
attributes: pruneUndefined({
|
|
289
|
+
namespace,
|
|
290
|
+
labels: pod.metadata?.labels,
|
|
291
|
+
resourceVersion: pod.metadata?.resourceVersion,
|
|
292
|
+
}),
|
|
293
|
+
};
|
|
294
|
+
});
|
|
295
|
+
});
|
|
195
296
|
}
|
|
@@ -1,27 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
queueMicrotask(() =>
|
|
16
|
-
r instanceof Error
|
|
17
|
-
? cb(r, { stdout: "", stderr: "" })
|
|
18
|
-
: cb(null, r as { stdout: string; stderr: string }),
|
|
19
|
-
);
|
|
20
|
-
},
|
|
21
|
-
};
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
const { exportResources } = await import("./export-resources");
|
|
1
|
+
/**
|
|
2
|
+
* `exportResources` I/O glue (#160), over the typed API client (chant #1074).
|
|
3
|
+
*
|
|
4
|
+
* `KUBECTL_RESOURCE` used to be two things at once: the set of kinds a bare
|
|
5
|
+
* `chant import` sweeps, and the only way the lexicon knew how to address
|
|
6
|
+
* anything. #1074 removed the second job — addressing now comes from the
|
|
7
|
+
* generated operation surface plus the cluster's discovery — so what is left
|
|
8
|
+
* here is the product decision about what a default import covers, and a
|
|
9
|
+
* `--selector type=` import can now name any generated type, CRDs included.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, test, expect } from "vitest";
|
|
12
|
+
import { exportResources, DEFAULT_IMPORT_TYPES } from "./export-resources";
|
|
13
|
+
import { fakeCluster, objectKey } from "./api/fake-cluster";
|
|
14
|
+
import { statusBody } from "@intentius/chant-k8s-client/testing";
|
|
25
15
|
|
|
26
16
|
const liveDeployment = {
|
|
27
17
|
apiVersion: "apps/v1",
|
|
@@ -30,43 +20,78 @@ const liveDeployment = {
|
|
|
30
20
|
spec: { replicas: 3, selector: { matchLabels: { app: "web" } } },
|
|
31
21
|
};
|
|
32
22
|
|
|
33
|
-
const
|
|
23
|
+
const liveRayCluster = {
|
|
24
|
+
apiVersion: "ray.io/v1",
|
|
25
|
+
kind: "RayCluster",
|
|
26
|
+
metadata: { name: "ml", namespace: "ray", uid: "r-1" },
|
|
27
|
+
spec: { rayVersion: "2.9.0" },
|
|
28
|
+
};
|
|
34
29
|
|
|
35
30
|
describe("k8s exportResources I/O glue (#160)", () => {
|
|
36
|
-
|
|
31
|
+
test("sweeps the default types by LIST and maps to IR", async () => {
|
|
32
|
+
const cluster = fakeCluster({
|
|
33
|
+
objects: { [objectKey("apps/v1", "Deployment", "web", "default")]: liveDeployment },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const ir = await exportResources({ environment: "prod" }, cluster.connector);
|
|
37
37
|
|
|
38
|
-
test("sweeps known kinds via `kubectl get <kind> -A -o json` and maps to IR", async () => {
|
|
39
|
-
execMock.mockImplementation((cmd?: string) =>
|
|
40
|
-
cmd?.includes("get deployment.apps")
|
|
41
|
-
? { stdout: JSON.stringify({ items: [liveDeployment] }), stderr: "" }
|
|
42
|
-
: emptyList,
|
|
43
|
-
);
|
|
44
|
-
const ir = await exportResources({ environment: "prod" });
|
|
45
|
-
const cmds = execMock.mock.calls.map((c) => c[0] as string).filter(Boolean);
|
|
46
|
-
expect(cmds.every((c) => c.startsWith("kubectl get ") && c.endsWith("-A -o json"))).toBe(true);
|
|
47
|
-
expect(cmds).toContain("kubectl get deployment.apps -A -o json");
|
|
48
38
|
expect(ir.resources.map((r) => r.type)).toContain("K8s::Apps::Deployment");
|
|
39
|
+
// Cluster-wide collection paths, which is what `kubectl get -A` was doing.
|
|
40
|
+
expect(cluster.layer.paths()).toContain("/apis/apps/v1/deployments");
|
|
41
|
+
expect(cluster.layer.paths()).toContain("/api/v1/services");
|
|
42
|
+
expect(cluster.layer.paths().some((p) => p.includes("/namespaces/"))).toBe(false);
|
|
49
43
|
});
|
|
50
44
|
|
|
51
45
|
test("a kind that errors is skipped; the rest of the sweep still maps", async () => {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
return emptyList;
|
|
46
|
+
const cluster = fakeCluster({
|
|
47
|
+
objects: { [objectKey("apps/v1", "Deployment", "web", "default")]: liveDeployment },
|
|
48
|
+
respond: (req) =>
|
|
49
|
+
req.path === "/api/v1/secrets" ? { status: 403, body: statusBody(403, "Forbidden", "rbac") } : undefined,
|
|
58
50
|
});
|
|
59
|
-
|
|
51
|
+
|
|
52
|
+
const ir = await exportResources({ environment: "prod" }, cluster.connector);
|
|
60
53
|
expect(ir.resources.map((r) => r.type)).toContain("K8s::Apps::Deployment");
|
|
61
54
|
});
|
|
62
55
|
|
|
63
|
-
test("a type selector narrows the sweep to
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
await exportResources({ environment: "prod", selector: { type: "K8s::Apps::Deployment" } });
|
|
69
|
-
|
|
70
|
-
|
|
56
|
+
test("a type selector narrows the sweep to one kind", async () => {
|
|
57
|
+
const cluster = fakeCluster({
|
|
58
|
+
objects: { [objectKey("apps/v1", "Deployment", "web", "default")]: liveDeployment },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await exportResources({ environment: "prod", selector: { type: "K8s::Apps::Deployment" } }, cluster.connector);
|
|
62
|
+
|
|
63
|
+
// One discovery request plus one collection read, and nothing else.
|
|
64
|
+
expect(cluster.layer.paths()).toEqual(["/apis/apps/v1", "/apis/apps/v1/deployments"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a CRD can now be imported by name — it never could through the twenty-entry map", async () => {
|
|
68
|
+
const cluster = fakeCluster({
|
|
69
|
+
objects: { [objectKey("ray.io/v1", "RayCluster", "ml", "ray")]: liveRayCluster },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await exportResources({ environment: "prod", selector: { type: "K8s::Ray::RayCluster" } }, cluster.connector);
|
|
73
|
+
expect(cluster.layer.paths()).toContain("/apis/ray.io/v1/rayclusters");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("an unknown selector type sweeps nothing rather than everything", async () => {
|
|
77
|
+
const cluster = fakeCluster();
|
|
78
|
+
const ir = await exportResources(
|
|
79
|
+
{ environment: "prod", selector: { type: "K8s::NotAGroupChantKnows::Thing" } },
|
|
80
|
+
cluster.connector,
|
|
81
|
+
);
|
|
82
|
+
expect(ir.resources).toEqual([]);
|
|
83
|
+
expect(cluster.layer.requests).toHaveLength(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("the default sweep still covers the workload, config, networking and RBAC kinds", () => {
|
|
87
|
+
for (const type of [
|
|
88
|
+
"K8s::Apps::Deployment",
|
|
89
|
+
"K8s::Core::Service",
|
|
90
|
+
"K8s::Core::ConfigMap",
|
|
91
|
+
"K8s::Networking::Ingress",
|
|
92
|
+
"K8s::Rbac::ClusterRole",
|
|
93
|
+
]) {
|
|
94
|
+
expect(DEFAULT_IMPORT_TYPES).toContain(type);
|
|
95
|
+
}
|
|
71
96
|
});
|
|
72
97
|
});
|