@intentius/chant-lexicon-k8s 0.124.31 → 0.124.32
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/actions/apps.js +18 -0
- package/dist/actions/batch.js +6 -0
- package/dist/actions/core.js +48 -0
- package/dist/actions/index.js +13 -0
- package/dist/actions/networking.js +11 -0
- package/dist/actions/rbac.js +8 -0
- package/dist/api/classify.js +123 -0
- package/dist/api/connect.js +62 -0
- package/dist/api/fake-cluster.js +175 -0
- package/dist/api/operation-surface.d.ts.map +1 -1
- package/dist/api/operation-surface.js +109 -0
- package/dist/api/owner-chain.js +100 -0
- package/dist/api/sweep-types.js +35 -0
- package/dist/behaviour-kinds.js +82 -0
- package/dist/codegen/docs-cli.js +2 -0
- package/dist/codegen/docs.js +146 -0
- package/dist/codegen/generate-cli.js +30 -0
- package/dist/codegen/generate-lexicon.js +74 -0
- package/dist/codegen/generate-list-map-keys.js +88 -0
- package/dist/codegen/generate-operations.js +53 -0
- package/dist/codegen/generate-typescript.js +124 -0
- package/dist/codegen/generate.js +173 -0
- package/dist/codegen/naming.js +174 -0
- package/dist/codegen/package.js +36 -0
- package/dist/codegen/patches.js +88 -0
- package/dist/codegen/typecheck.js +4 -0
- package/dist/codegen/versions.js +41 -0
- package/dist/components/argo-app.js +51 -0
- package/dist/components/builders.js +22 -0
- package/dist/components/capability-plugin.js +31 -0
- package/dist/components/flux-reconcile.js +61 -0
- package/dist/components/index.d.ts +1 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +18 -0
- package/dist/components/kubectl-apply.d.ts +83 -1
- package/dist/components/kubectl-apply.d.ts.map +1 -1
- package/dist/components/kubectl-apply.js +274 -0
- package/dist/components/kustomize-apply.js +25 -0
- package/dist/composites/adot-collector.js +184 -0
- package/dist/composites/agic-ingress.js +85 -0
- package/dist/composites/aks-external-dns-agent.js +146 -0
- package/dist/composites/alb-ingress.js +86 -0
- package/dist/composites/argo-app.js +199 -0
- package/dist/composites/autoscaled-service.js +174 -0
- package/dist/composites/azure-disk-storage-class.js +47 -0
- package/dist/composites/azure-file-storage-class.js +48 -0
- package/dist/composites/azure-monitor-collector.js +176 -0
- package/dist/composites/batch-job.js +139 -0
- package/dist/composites/catalog.js +4110 -0
- package/dist/composites/cockroachdb-cluster.js +371 -0
- package/dist/composites/cockroachdb-region-stack.js +369 -0
- package/dist/composites/config-connector-context.js +40 -0
- package/dist/composites/configured-app.js +135 -0
- package/dist/composites/cron-schedule.js +38 -0
- package/dist/composites/cron-workload.js +109 -0
- package/dist/composites/ebs-storage-class.js +54 -0
- package/dist/composites/efs-storage-class.js +46 -0
- package/dist/composites/external-dns-agent.js +122 -0
- package/dist/composites/filestore-storage-class.js +49 -0
- package/dist/composites/fluent-bit-agent.js +157 -0
- package/dist/composites/flux-app.js +156 -0
- package/dist/composites/gce-ingress.js +83 -0
- package/dist/composites/gce-pd-storage-class.js +49 -0
- package/dist/composites/gke-external-dns-agent.js +129 -0
- package/dist/composites/gke-fluent-bit-agent.js +165 -0
- package/dist/composites/gke-gateway.js +91 -0
- package/dist/composites/gke-otel-collector.js +94 -0
- package/dist/composites/index.js +51 -0
- package/dist/composites/inference-service.js +76 -0
- package/dist/composites/irsa-service-account.js +77 -0
- package/dist/composites/member-defaults.js +14 -0
- package/dist/composites/metrics-server.js +181 -0
- package/dist/composites/model.js +92 -0
- package/dist/composites/monitored-service.js +152 -0
- package/dist/composites/namespace-env.js +114 -0
- package/dist/composites/network-isolated-app.js +124 -0
- package/dist/composites/node-agent.js +149 -0
- package/dist/composites/operator-stack.js +268 -0
- package/dist/composites/otel-collector-agent.js +142 -0
- package/dist/composites/otel-collector-gateway.js +216 -0
- package/dist/composites/otel-collector-node.js +176 -0
- package/dist/composites/otel-collector-rbac.js +115 -0
- package/dist/composites/otel-collector-shape.js +128 -0
- package/dist/composites/otel-collector.js +115 -0
- package/dist/composites/otel-operator-collector.js +123 -0
- package/dist/composites/ray-cluster.js +378 -0
- package/dist/composites/ray-job.js +136 -0
- package/dist/composites/ray-service.js +178 -0
- package/dist/composites/secure-ingress.js +100 -0
- package/dist/composites/security-context.js +1 -0
- package/dist/composites/sidecar-app.js +105 -0
- package/dist/composites/stateful-app.js +111 -0
- package/dist/composites/vllm-serving-runtime.js +114 -0
- package/dist/composites/web-app.js +157 -0
- package/dist/composites/worker-pool.js +167 -0
- package/dist/composites/workload-identity-sa.js +81 -0
- package/dist/composites/workload-identity-service-account.js +77 -0
- package/dist/config-schema.js +72 -0
- package/dist/config.js +59 -0
- package/dist/coverage.js +27 -0
- package/dist/crd/crd-sources.js +615 -0
- package/dist/crd/loader.js +181 -0
- package/dist/crd/oci-chart.js +144 -0
- package/dist/crd/parser.js +382 -0
- package/dist/crd/project-codegen.js +273 -0
- package/dist/crd/types.js +7 -0
- package/dist/deep-observe-hooks.js +306 -0
- package/dist/deep-observe.js +234 -0
- package/dist/default-labels.js +87 -0
- package/dist/describe-resources.js +699 -0
- package/dist/describe-stack-status.js +99 -0
- package/dist/detect.js +11 -0
- package/dist/effect-receipt-row.js +205 -0
- package/dist/export-resources.js +39 -0
- package/dist/generated/index.js +1092 -0
- package/dist/generated/runtime.js +4 -0
- package/dist/group-namespace.js +96 -0
- package/dist/import/embedded.js +197 -0
- package/dist/import/generator.js +288 -0
- package/dist/import/live-export.js +85 -0
- package/dist/import/parser.js +204 -0
- package/dist/import/testdata/embedded/fixtures.js +60 -0
- package/dist/include/entity.js +78 -0
- package/dist/include/resolve.js +170 -0
- package/dist/index.js +59 -0
- package/dist/integrity.json +3 -3
- package/dist/kube/apply.js +90 -0
- package/dist/kube/connect.js +46 -0
- package/dist/kube/delete.js +138 -0
- package/dist/kube/describe.js +169 -0
- package/dist/kube/events.js +132 -0
- package/dist/kube/flags.js +91 -0
- package/dist/kube/get.js +159 -0
- package/dist/kube/group.js +123 -0
- package/dist/kube/logs.js +82 -0
- package/dist/kube/project.js +94 -0
- package/dist/kube/render.js +227 -0
- package/dist/kube/source.js +122 -0
- package/dist/kube/target.js +63 -0
- package/dist/kube/testing.js +28 -0
- package/dist/kube/top.js +99 -0
- package/dist/kube/verdict.js +72 -0
- package/dist/kube/wait.js +129 -0
- package/dist/kustomize/render.js +73 -0
- package/dist/kustomize/rendered-entity.js +67 -0
- package/dist/kustomize/root.js +68 -0
- package/dist/lint/audit-catalog.js +61 -0
- package/dist/lint/audit-lineage.js +118 -0
- package/dist/lint/post-synth/argo-helpers.js +36 -0
- package/dist/lint/post-synth/argo002.js +31 -0
- package/dist/lint/post-synth/argo003.js +53 -0
- package/dist/lint/post-synth/argo005.js +54 -0
- package/dist/lint/post-synth/crd-schema-helpers.d.ts.map +1 -1
- package/dist/lint/post-synth/crd-schema-helpers.js +214 -0
- package/dist/lint/post-synth/flux002.js +56 -0
- package/dist/lint/post-synth/flux003.js +50 -0
- package/dist/lint/post-synth/index.js +104 -0
- package/dist/lint/post-synth/k8s-helpers.js +186 -0
- package/dist/lint/post-synth/monitoring-selection-helpers.js +163 -0
- package/dist/lint/post-synth/otel-placement-helpers.js +293 -0
- package/dist/lint/post-synth/sops-helpers.js +43 -0
- package/dist/lint/post-synth/wk8005.js +44 -0
- package/dist/lint/post-synth/wk8006.js +56 -0
- package/dist/lint/post-synth/wk8041.js +60 -0
- package/dist/lint/post-synth/wk8042.js +36 -0
- package/dist/lint/post-synth/wk8101.js +52 -0
- package/dist/lint/post-synth/wk8102.js +30 -0
- package/dist/lint/post-synth/wk8103.js +33 -0
- package/dist/lint/post-synth/wk8104.js +58 -0
- package/dist/lint/post-synth/wk8105.js +33 -0
- package/dist/lint/post-synth/wk8201.js +44 -0
- package/dist/lint/post-synth/wk8202.js +34 -0
- package/dist/lint/post-synth/wk8203.js +34 -0
- package/dist/lint/post-synth/wk8204.js +71 -0
- package/dist/lint/post-synth/wk8205.js +38 -0
- package/dist/lint/post-synth/wk8207.js +33 -0
- package/dist/lint/post-synth/wk8208.js +33 -0
- package/dist/lint/post-synth/wk8209.js +33 -0
- package/dist/lint/post-synth/wk8301.js +49 -0
- package/dist/lint/post-synth/wk8302.js +34 -0
- package/dist/lint/post-synth/wk8303.js +68 -0
- package/dist/lint/post-synth/wk8304.js +58 -0
- package/dist/lint/post-synth/wk8305.js +96 -0
- package/dist/lint/post-synth/wk8306.js +38 -0
- package/dist/lint/post-synth/wk8401.js +86 -0
- package/dist/lint/post-synth/wk8402.js +31 -0
- package/dist/lint/post-synth/wk8403.js +49 -0
- package/dist/lint/post-synth/wk8404.js +36 -0
- package/dist/lint/post-synth/wk8405.js +65 -0
- package/dist/lint/post-synth/wk8406.js +50 -0
- package/dist/lint/post-synth/wk8407.js +58 -0
- package/dist/lint/post-synth/wk8501.js +36 -0
- package/dist/lint/post-synth/wk8502.js +35 -0
- package/dist/lint/post-synth/wk8503.js +164 -0
- package/dist/lint/post-synth/wk8504.js +20 -0
- package/dist/lint/post-synth/wk8505.js +31 -0
- package/dist/lint/post-synth/wk8601.js +40 -0
- package/dist/lint/post-synth/wk8602.js +120 -0
- package/dist/lint/post-synth/wk8603.js +63 -0
- package/dist/lint/post-synth/wk8604.js +31 -0
- package/dist/lint/post-synth/wk8605.js +124 -0
- package/dist/lint/post-synth/wk8606.js +22 -0
- package/dist/lint/post-synth/wk8701.js +31 -0
- package/dist/lint/post-synth/wk8702.js +37 -0
- package/dist/lint/post-synth/wk8703.js +31 -0
- package/dist/lint/post-synth/wk8704.js +135 -0
- package/dist/lint/post-synth/wk8705.js +38 -0
- package/dist/lint/rules/argo-appset-single-project.js +56 -0
- package/dist/lint/rules/argo-ast.js +94 -0
- package/dist/lint/rules/argo-automated-prune.js +59 -0
- package/dist/lint/rules/flux-source-ref-pin.js +44 -0
- package/dist/lint/rules/hardcoded-namespace.js +57 -0
- package/dist/lint/rules/latest-image-tag.js +103 -0
- package/dist/lint/rules/missing-resource-limits.js +85 -0
- package/dist/lsp/completions.d.ts.map +1 -1
- package/dist/lsp/completions.js +18 -0
- package/dist/lsp/hover.d.ts.map +1 -1
- package/dist/lsp/hover.js +57 -0
- package/dist/manifest-entity.js +71 -0
- package/dist/manifest.json +1 -1
- package/dist/micro-time.js +49 -0
- package/dist/op/activities/apply-diff.d.ts +31 -0
- package/dist/op/activities/apply-diff.d.ts.map +1 -0
- package/dist/op/activities/apply-diff.js +79 -0
- package/dist/op/activities/argo.js +98 -0
- package/dist/op/activities/ensure-secret.js +38 -0
- package/dist/op/activities/index.js +51 -0
- package/dist/op/activities/kubectl.d.ts +64 -0
- package/dist/op/activities/kubectl.d.ts.map +1 -1
- package/dist/op/activities/kubectl.js +517 -0
- package/dist/op/activities/wait-for-ready.js +229 -0
- package/dist/op/builders.js +68 -0
- package/dist/package-cli.js +21 -0
- package/dist/patch/json-patch.js +187 -0
- package/dist/patch/rendered-patch.js +94 -0
- package/dist/plugin.js +741 -0
- package/dist/project-kinds.js +56 -0
- package/dist/receipt-store.js +216 -0
- package/dist/rules/crd-schema-helpers.ts +2 -1
- package/dist/secret-labels.js +33 -0
- package/dist/secret-store.js +116 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/serializer.js +578 -0
- package/dist/sops/encrypted-secret-file.js +194 -0
- package/dist/sops/entity.js +32 -0
- package/dist/spec/fetch.js +58 -0
- package/dist/spec/parse.js +622 -0
- package/dist/subscribe-changes.js +168 -0
- package/dist/teardown.js +197 -0
- package/dist/telemetry-release.js +86 -0
- package/dist/validate-cli.js +17 -0
- package/dist/validate.js +47 -0
- package/dist/variables.js +34 -0
- package/dist/whoami.js +109 -0
- package/package.json +12 -12
- package/src/api/operation-surface.ts +2 -1
- package/src/codegen/package.ts +2 -2
- package/src/components/index.ts +3 -0
- package/src/components/kubectl-apply-plan.k3d.e2e.test.ts +127 -0
- package/src/components/kubectl-apply.test.ts +262 -0
- package/src/components/kubectl-apply.ts +341 -12
- package/src/deep-observe-hooks.ts +2 -2
- package/src/import/roundtrip-build.test.ts +46 -0
- package/src/lint/post-synth/crd-schema-helpers.ts +2 -1
- package/src/lsp/completions.ts +2 -1
- package/src/lsp/hover.ts +2 -1
- package/src/op/activities/apply-diff.ts +87 -0
- package/src/op/activities/kubectl-plan.test.ts +224 -0
- package/src/op/activities/kubectl.ts +204 -16
- package/src/serializer.ts +2 -1
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The k8s change signal, `LexiconPlugin.subscribeChanges` (chant #1981).
|
|
3
|
+
*
|
|
4
|
+
* Kubernetes is the one substrate chant reaches where a change stream is
|
|
5
|
+
* complete, trustworthy, and needs nothing deployed into the cluster being
|
|
6
|
+
* observed: the Watch API is served for every kind the API server serves, is
|
|
7
|
+
* `resourceVersion`-based so a reconnect has a defined resume point, and is
|
|
8
|
+
* authorized by the same read credentials `describeResources` already uses.
|
|
9
|
+
* Every cloud substrate fails on that last point. Subscribing to EventBridge,
|
|
10
|
+
* Cloud Asset Inventory or Event Grid means writing infrastructure into the
|
|
11
|
+
* account being watched, which inverts the property that makes a read-only
|
|
12
|
+
* watch safe to point at production. The verdict table in the operator guide
|
|
13
|
+
* records that per lexicon; this file is the one place it came out `yes`.
|
|
14
|
+
*
|
|
15
|
+
* ## What this is allowed to conclude: nothing
|
|
16
|
+
*
|
|
17
|
+
* A watch event never becomes an observation. The frames are read, and then
|
|
18
|
+
* discarded. The only thing that leaves this module is a no-argument
|
|
19
|
+
* `onChange()`, which wakes an operator tick that re-observes the estate from
|
|
20
|
+
* scratch through the ordinary read path. There is no code here that could
|
|
21
|
+
* turn a `DELETED` frame into a proposed `create`, because there is no channel
|
|
22
|
+
* from a frame to anything but a function call with no parameters.
|
|
23
|
+
*
|
|
24
|
+
* That is also why a missed event costs nothing. A `410 Gone`, a dropped
|
|
25
|
+
* connection, a subscription that never got established: all of them slow
|
|
26
|
+
* detection back to the operator's timer, and none of them make the estate
|
|
27
|
+
* read as clean.
|
|
28
|
+
*
|
|
29
|
+
* ## Scope
|
|
30
|
+
*
|
|
31
|
+
* One watch per (kind, namespace) the declared entities name. The kinds come
|
|
32
|
+
* from the same generated operation surface `describeResources` addresses
|
|
33
|
+
* entities through; the namespaces come from the declarations themselves,
|
|
34
|
+
* falling back to the client's own default for a namespaced entity that
|
|
35
|
+
* declares none. Nothing widens that: a project declaring three Deployments in
|
|
36
|
+
* one namespace opens one connection, not a cluster-wide firehose.
|
|
37
|
+
*/
|
|
38
|
+
import { defaultK8sConnector } from "./api/connect.js";
|
|
39
|
+
import { operationFor } from "./api/operation-surface.js";
|
|
40
|
+
/**
|
|
41
|
+
* The most connections one subscription will hold open.
|
|
42
|
+
*
|
|
43
|
+
* A watch is a long-lived HTTP/2 stream against the API server, and one per
|
|
44
|
+
* (kind, namespace) is cheap right up until an estate declares eighty kinds
|
|
45
|
+
* across a dozen namespaces. Past this ceiling the honest move is to refuse
|
|
46
|
+
* the whole subscription and say so, rather than to open some arbitrary
|
|
47
|
+
* prefix of it: a partial watch is a signal that goes quiet for exactly the
|
|
48
|
+
* resources nobody chose to drop. The operator then runs on its timer, which
|
|
49
|
+
* is what it did before this existed.
|
|
50
|
+
*/
|
|
51
|
+
export const MAX_WATCHES = 32;
|
|
52
|
+
/**
|
|
53
|
+
* The distinct (kind, namespace) pairs a declared estate implies.
|
|
54
|
+
*
|
|
55
|
+
* Deterministic order, so a refusal past {@link MAX_WATCHES} names the same
|
|
56
|
+
* scope every time and a test can assert on it.
|
|
57
|
+
*/
|
|
58
|
+
export function watchTargets(entities, defaultNamespace) {
|
|
59
|
+
const byKey = new Map();
|
|
60
|
+
const unaddressable = new Set();
|
|
61
|
+
for (const [, entity] of entities) {
|
|
62
|
+
const operation = operationFor(entity.entityType);
|
|
63
|
+
if (!operation) {
|
|
64
|
+
// chant knows no API address for this type, the same hole
|
|
65
|
+
// `describeResources` reports as `unsupported-kind`. Nothing to watch,
|
|
66
|
+
// and never a reason to widen to something else.
|
|
67
|
+
unaddressable.add(entity.entityType);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const declared = entity.props.metadata?.namespace;
|
|
71
|
+
const namespace = operation.scope === "Namespaced" ? (declared ?? defaultNamespace) : undefined;
|
|
72
|
+
const key = `${operation.apiVersion}|${operation.kind}|${namespace ?? ""}`;
|
|
73
|
+
if (!byKey.has(key)) {
|
|
74
|
+
byKey.set(key, {
|
|
75
|
+
apiVersion: operation.apiVersion,
|
|
76
|
+
kind: operation.kind,
|
|
77
|
+
...(namespace ? { namespace } : {}),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
targets: [...byKey.values()].sort((a, b) => `${a.apiVersion}|${a.kind}|${a.namespace ?? ""}`.localeCompare(`${b.apiVersion}|${b.kind}|${b.namespace ?? ""}`)),
|
|
83
|
+
unaddressable: [...unaddressable].sort(),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Human phrasing of one target, for a log line. */
|
|
87
|
+
function targetText(target) {
|
|
88
|
+
return `${target.apiVersion} ${target.kind}${target.namespace ? ` in ${target.namespace}` : ""}`;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Open one watch per declared (kind, namespace) and report every event as a
|
|
92
|
+
* bare `onChange()`.
|
|
93
|
+
*
|
|
94
|
+
* Throws only for a failure that makes the whole subscription impossible: no
|
|
95
|
+
* entities in scope, a cluster binding that will not resolve, a scope past the
|
|
96
|
+
* ceiling. The operator turns that into one logged line and keeps polling.
|
|
97
|
+
* Once the subscription is live, nothing throws: a watch that dies reports
|
|
98
|
+
* through `onError` and the operator re-subscribes on its next round.
|
|
99
|
+
*/
|
|
100
|
+
export async function subscribeChanges(options, connect = defaultK8sConnector) {
|
|
101
|
+
const entities = options.entities;
|
|
102
|
+
if (!entities || entities.size === 0) {
|
|
103
|
+
throw new Error("no declared k8s entities in scope. A change signal watches what the project declares, and there is nothing to watch");
|
|
104
|
+
}
|
|
105
|
+
// The same connect path the read takes, so the cluster a signal comes from
|
|
106
|
+
// is the cluster a tick would read. A binding that refuses, refuses here.
|
|
107
|
+
const { client } = await connect({
|
|
108
|
+
environment: options.environment,
|
|
109
|
+
cwd: options.cwd,
|
|
110
|
+
});
|
|
111
|
+
const { targets, unaddressable } = watchTargets(entities, client.defaultNamespace);
|
|
112
|
+
if (unaddressable.length > 0) {
|
|
113
|
+
// A hole in the signal, reported the way a hole in an observation is:
|
|
114
|
+
// named, not swallowed. The timer still covers these kinds.
|
|
115
|
+
options.onError?.(`no API address for ${unaddressable.join(", ")}, so changes to those kinds are found on the timer alone`);
|
|
116
|
+
}
|
|
117
|
+
if (targets.length === 0) {
|
|
118
|
+
throw new Error("no watchable k8s kinds among the declared entities");
|
|
119
|
+
}
|
|
120
|
+
if (targets.length > MAX_WATCHES) {
|
|
121
|
+
throw new Error(`the declared estate needs ${targets.length} watch connections, past the ${MAX_WATCHES} ceiling, so it is ` +
|
|
122
|
+
"running on the operator's timer rather than opening a partial watch that would go quiet for the rest");
|
|
123
|
+
}
|
|
124
|
+
const handles = [];
|
|
125
|
+
/** Reported once for the whole subscription: the operator re-subscribes as a whole. */
|
|
126
|
+
let reported = false;
|
|
127
|
+
const reportOnce = (message) => {
|
|
128
|
+
if (reported)
|
|
129
|
+
return;
|
|
130
|
+
reported = true;
|
|
131
|
+
options.onError?.(message);
|
|
132
|
+
};
|
|
133
|
+
const stopOnAbort = () => {
|
|
134
|
+
void closeAll();
|
|
135
|
+
};
|
|
136
|
+
let closing;
|
|
137
|
+
const closeAll = () => {
|
|
138
|
+
if (!closing) {
|
|
139
|
+
options.signal.removeEventListener("abort", stopOnAbort);
|
|
140
|
+
const open = handles.splice(0, handles.length);
|
|
141
|
+
closing = Promise.allSettled(open.map((h) => h.close())).then(() => undefined);
|
|
142
|
+
}
|
|
143
|
+
return closing;
|
|
144
|
+
};
|
|
145
|
+
try {
|
|
146
|
+
for (const target of targets) {
|
|
147
|
+
handles.push(await client.watch({ apiVersion: target.apiVersion, kind: target.kind }, {
|
|
148
|
+
...(target.namespace ? { namespace: target.namespace } : {}),
|
|
149
|
+
signal: options.signal,
|
|
150
|
+
// The whole consumption of a watch event, in one line: it happened,
|
|
151
|
+
// so look again. The frame is not read, not stored, not passed on.
|
|
152
|
+
onEvent: () => options.onChange(),
|
|
153
|
+
onError: (message) => reportOnce(`watch on ${targetText(target)} ended: ${message}`),
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
// A partial open is not a subscription. Unwind what did open, and let the
|
|
159
|
+
// operator report one failure and keep its timer.
|
|
160
|
+
await closeAll();
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
if (options.signal.aborted)
|
|
164
|
+
await closeAll();
|
|
165
|
+
else
|
|
166
|
+
options.signal.addEventListener("abort", stopOnAbort, { once: true });
|
|
167
|
+
return { close: closeAll };
|
|
168
|
+
}
|
package/dist/teardown.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env teardown for the k8s lexicon (chant #1222) — both halves of the
|
|
3
|
+
* `teardownOwned` / `executeTeardown` capability pair.
|
|
4
|
+
*
|
|
5
|
+
* Selection is the prune's marker selector (op/activities/kubectl.ts) with the
|
|
6
|
+
* env dimension added: `app.kubernetes.io/managed-by=chant` +
|
|
7
|
+
* `chant.intentius.io/stack=<stack>` + `chant.intentius.io/env=<env>`, sent to
|
|
8
|
+
* the server as a label selector and re-checked object by object with
|
|
9
|
+
* `readOwnership` — the server-side filter narrows the read, the client-side
|
|
10
|
+
* re-check is the one that matters, because a delete is not undoable.
|
|
11
|
+
*
|
|
12
|
+
* Swept kinds are chant's default sweep set (`DEFAULT_IMPORT_TYPES`, the same
|
|
13
|
+
* list the ownership-scoped prune unions in), listed cluster-wide: env
|
|
14
|
+
* teardown has no apply set to scope namespaces by, and the marker selector is
|
|
15
|
+
* the scope. A kind the cluster's discovery does not serve cannot hold a
|
|
16
|
+
* marked object and is skipped; a kind that resolves but fails to list is a
|
|
17
|
+
* hole (#1089) — unknown, never absent.
|
|
18
|
+
*
|
|
19
|
+
* Execution deletes the handed-over candidates through the typed client's own
|
|
20
|
+
* `delete()` — the identical delete the prune and `chant kube delete` use —
|
|
21
|
+
* with namespaces last, so a namespace's own members get individual outcomes
|
|
22
|
+
* before the cascade would have swallowed them. Before each delete the live
|
|
23
|
+
* object is re-read and its marker re-verified; an object that vanished is
|
|
24
|
+
* `deleted` (teardown is idempotent), one whose identity no longer matches is
|
|
25
|
+
* `not-prunable`, never deleted.
|
|
26
|
+
*/
|
|
27
|
+
import { LABEL_OWNERSHIP_KEYS, OWNERSHIP_MANAGED_BY_VALUE, readOwnership, } from "@intentius/chant/ownership";
|
|
28
|
+
import { DEFAULT_IMPORT_TYPES } from "./api/sweep-types.js";
|
|
29
|
+
import { operationFor } from "./api/operation-surface.js";
|
|
30
|
+
import { defaultK8sConnector } from "./api/connect.js";
|
|
31
|
+
import { isGeneratedOnce } from "./secret-labels.js";
|
|
32
|
+
const NAMESPACE_TYPE = "K8s::Core::Namespace";
|
|
33
|
+
/** The label selector that scopes everything here: managed-by + stack + env. */
|
|
34
|
+
function markerSelector(marker) {
|
|
35
|
+
return [
|
|
36
|
+
`${LABEL_OWNERSHIP_KEYS.managedBy}=${OWNERSHIP_MANAGED_BY_VALUE}`,
|
|
37
|
+
`${LABEL_OWNERSHIP_KEYS.stack}=${marker.stack}`,
|
|
38
|
+
...(marker.env ? [`${LABEL_OWNERSHIP_KEYS.env}=${marker.env}`] : []),
|
|
39
|
+
].join(",");
|
|
40
|
+
}
|
|
41
|
+
/** True when a live object's own labels carry exactly the requested identity. */
|
|
42
|
+
function matchesMarker(labels, marker) {
|
|
43
|
+
const read = readOwnership(labels, LABEL_OWNERSHIP_KEYS);
|
|
44
|
+
return read !== undefined && read.stack === marker.stack && read.env === marker.env;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Enumerate the cluster's marker-matching objects — the k8s half of
|
|
48
|
+
* `chant lifecycle teardown <env>` planning.
|
|
49
|
+
*/
|
|
50
|
+
export async function teardownOwned(options, connect = defaultK8sConnector) {
|
|
51
|
+
const { client } = await connect({ environment: options.environment });
|
|
52
|
+
const selector = markerSelector(options.marker);
|
|
53
|
+
const candidates = [];
|
|
54
|
+
const holes = [];
|
|
55
|
+
for (const entityType of DEFAULT_IMPORT_TYPES) {
|
|
56
|
+
const operation = operationFor(entityType);
|
|
57
|
+
if (!operation)
|
|
58
|
+
continue;
|
|
59
|
+
const ref = { apiVersion: operation.apiVersion, kind: operation.kind };
|
|
60
|
+
let served;
|
|
61
|
+
try {
|
|
62
|
+
served = (await client.resolve(ref)) !== undefined;
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
holes.push({
|
|
66
|
+
name: entityType,
|
|
67
|
+
type: entityType,
|
|
68
|
+
reason: "read-failed",
|
|
69
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
70
|
+
});
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
// A kind this cluster's discovery does not serve cannot hold a marked
|
|
74
|
+
// object — absence of the kind is knowledge, not a hole.
|
|
75
|
+
if (!served)
|
|
76
|
+
continue;
|
|
77
|
+
let items;
|
|
78
|
+
try {
|
|
79
|
+
items = await client.list(ref, { labelSelector: selector });
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
holes.push({
|
|
83
|
+
name: entityType,
|
|
84
|
+
type: entityType,
|
|
85
|
+
reason: "read-failed",
|
|
86
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
87
|
+
});
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
for (const item of items) {
|
|
91
|
+
const name = item.metadata?.name;
|
|
92
|
+
if (!name)
|
|
93
|
+
continue;
|
|
94
|
+
// The re-check that matters: a server (or fake) that ignored the label
|
|
95
|
+
// selector must not widen the set past the requested identity.
|
|
96
|
+
if (!matchesMarker(item.metadata?.labels, options.marker))
|
|
97
|
+
continue;
|
|
98
|
+
// Already terminating — deleting again is noise, not progress.
|
|
99
|
+
if (item.metadata?.deletionTimestamp !== undefined)
|
|
100
|
+
continue;
|
|
101
|
+
const namespace = item.metadata?.namespace;
|
|
102
|
+
candidates.push({
|
|
103
|
+
name: namespace ? `${namespace}/${name}` : name,
|
|
104
|
+
type: entityType,
|
|
105
|
+
...(item.metadata?.uid ? { physicalId: String(item.metadata.uid) } : {}),
|
|
106
|
+
marker: readOwnership(item.metadata?.labels, LABEL_OWNERSHIP_KEYS),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { candidates, ...(holes.length > 0 ? { holes } : {}) };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Delete the handed-over candidates, namespaces last. One outcome per
|
|
114
|
+
* candidate, always.
|
|
115
|
+
*/
|
|
116
|
+
export async function executeTeardown(options, connect = defaultK8sConnector) {
|
|
117
|
+
const { client } = await connect({ environment: options.environment });
|
|
118
|
+
// Namespaces last: their members get individual deletes (and individual
|
|
119
|
+
// outcomes) before the namespace cascade sweeps whatever is left.
|
|
120
|
+
const ordered = [
|
|
121
|
+
...options.candidates.filter((c) => c.type !== NAMESPACE_TYPE),
|
|
122
|
+
...options.candidates.filter((c) => c.type === NAMESPACE_TYPE),
|
|
123
|
+
];
|
|
124
|
+
const outcomes = [];
|
|
125
|
+
for (const candidate of ordered) {
|
|
126
|
+
outcomes.push(await deleteCandidate(client, candidate, options.marker));
|
|
127
|
+
}
|
|
128
|
+
return { outcomes };
|
|
129
|
+
}
|
|
130
|
+
async function deleteCandidate(client, candidate, marker) {
|
|
131
|
+
const base = {
|
|
132
|
+
name: candidate.name,
|
|
133
|
+
type: candidate.type,
|
|
134
|
+
...(candidate.physicalId ? { physicalId: candidate.physicalId } : {}),
|
|
135
|
+
};
|
|
136
|
+
const operation = operationFor(candidate.type);
|
|
137
|
+
if (!operation) {
|
|
138
|
+
return { ...base, outcome: "not-prunable", detail: `no operation mapping for type ${candidate.type}` };
|
|
139
|
+
}
|
|
140
|
+
let info;
|
|
141
|
+
try {
|
|
142
|
+
info = await client.resolve({ apiVersion: operation.apiVersion, kind: operation.kind });
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
return { ...base, outcome: "failed", detail: err instanceof Error ? err.message : String(err) };
|
|
146
|
+
}
|
|
147
|
+
if (!info) {
|
|
148
|
+
return { ...base, outcome: "not-prunable", detail: `the cluster does not serve ${operation.kind}` };
|
|
149
|
+
}
|
|
150
|
+
const slash = candidate.name.indexOf("/");
|
|
151
|
+
const namespace = info.namespaced && slash > 0 ? candidate.name.slice(0, slash) : undefined;
|
|
152
|
+
const name = info.namespaced && slash > 0 ? candidate.name.slice(slash + 1) : candidate.name;
|
|
153
|
+
const ref = {
|
|
154
|
+
apiVersion: operation.apiVersion,
|
|
155
|
+
kind: operation.kind,
|
|
156
|
+
name,
|
|
157
|
+
...(namespace !== undefined ? { namespace } : {}),
|
|
158
|
+
};
|
|
159
|
+
// Re-read and re-verify the identity right before deleting: the enumeration
|
|
160
|
+
// is a moment old at best, and a delete is not undoable.
|
|
161
|
+
let live;
|
|
162
|
+
try {
|
|
163
|
+
live = await client.readIfPresent(ref);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
return { ...base, outcome: "failed", detail: err instanceof Error ? err.message : String(err) };
|
|
167
|
+
}
|
|
168
|
+
if (live === undefined) {
|
|
169
|
+
return { ...base, outcome: "deleted", detail: "already absent" };
|
|
170
|
+
}
|
|
171
|
+
if (!matchesMarker(live.metadata?.labels, marker)) {
|
|
172
|
+
return {
|
|
173
|
+
...base,
|
|
174
|
+
outcome: "not-prunable",
|
|
175
|
+
detail: "the live object no longer carries the requested marker identity",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
// A generated-once Secret survives every sweep, env teardown included
|
|
179
|
+
// (#1830, epic #1365 decision 5): the stored bytes are the only copy of
|
|
180
|
+
// material chant never held. `retained` is the loud keep — the row says the
|
|
181
|
+
// env is not clean and why. Deletion is an explicit act (`kubectl delete
|
|
182
|
+
// secret <name>`, or a future gated op), never a teardown's.
|
|
183
|
+
if (operation.kind === "Secret" && isGeneratedOnce(live.metadata?.labels)) {
|
|
184
|
+
return {
|
|
185
|
+
...base,
|
|
186
|
+
outcome: "retained",
|
|
187
|
+
detail: "generated-once secret — deliberately kept, never swept; delete it explicitly (kubectl delete) if you mean to",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
await client.delete(ref);
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
return { ...base, outcome: "failed", detail: err instanceof Error ? err.message : String(err) };
|
|
195
|
+
}
|
|
196
|
+
return { ...base, outcome: "deleted" };
|
|
197
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a workload keeps its pod spec, and the release attributes a deploy
|
|
3
|
+
* writes onto it (#3059, #3061, ws-081).
|
|
4
|
+
*
|
|
5
|
+
* The build stamps each container of a workload with `OTEL_SERVICE_NAME` and
|
|
6
|
+
* `OTEL_RESOURCE_ATTRIBUTES` (the serializer), and with
|
|
7
|
+
* `CHANT_RELEASE_ATTRIBUTES` read through the downward API from the pod
|
|
8
|
+
* annotation `chant.intentius.io/release-attributes`, which
|
|
9
|
+
* `OTEL_RESOURCE_ATTRIBUTES` ends with as `$(CHANT_RELEASE_ATTRIBUTES)`. An
|
|
10
|
+
* absent annotation reads as empty, so the built manifest works as it is. A
|
|
11
|
+
* release deploy (`kubectl-apply` with a release on its context) sets the
|
|
12
|
+
* annotation on the pod template of each stamped workload in what it applies,
|
|
13
|
+
* leaving the committed manifest the same bytes.
|
|
14
|
+
*
|
|
15
|
+
* Kept free of the API client and the serializer so the capability module can
|
|
16
|
+
* import it without loading either.
|
|
17
|
+
*/
|
|
18
|
+
import { RELEASE_ATTRIBUTES_ANNOTATION, RELEASE_ATTRIBUTES_VARIABLE } from "@intentius/chant/telemetry-attribution";
|
|
19
|
+
/** Where each workload kind keeps its pod spec. */
|
|
20
|
+
export const POD_SPEC_PATHS = {
|
|
21
|
+
Pod: ["spec"],
|
|
22
|
+
Deployment: ["spec", "template", "spec"],
|
|
23
|
+
StatefulSet: ["spec", "template", "spec"],
|
|
24
|
+
DaemonSet: ["spec", "template", "spec"],
|
|
25
|
+
ReplicaSet: ["spec", "template", "spec"],
|
|
26
|
+
Job: ["spec", "template", "spec"],
|
|
27
|
+
CronJob: ["spec", "jobTemplate", "spec", "template", "spec"],
|
|
28
|
+
};
|
|
29
|
+
/** The core API groups those kinds live in; a CRD that reuses a kind name is not a workload. */
|
|
30
|
+
const WORKLOAD_API_VERSIONS = new Set(["v1", "apps/v1", "batch/v1"]);
|
|
31
|
+
function isObj(value) {
|
|
32
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
function at(value, path) {
|
|
35
|
+
let cur = value;
|
|
36
|
+
for (const key of path) {
|
|
37
|
+
if (!isObj(cur))
|
|
38
|
+
return undefined;
|
|
39
|
+
cur = cur[key];
|
|
40
|
+
}
|
|
41
|
+
return cur;
|
|
42
|
+
}
|
|
43
|
+
/** A workload's pod spec, or undefined for anything else. */
|
|
44
|
+
export function podSpecOf(manifest) {
|
|
45
|
+
const path = POD_SPEC_PATHS[manifest.kind];
|
|
46
|
+
if (!path || !WORKLOAD_API_VERSIONS.has(manifest.apiVersion))
|
|
47
|
+
return undefined;
|
|
48
|
+
const spec = at(manifest, path);
|
|
49
|
+
return isObj(spec) ? spec : undefined;
|
|
50
|
+
}
|
|
51
|
+
/** The env entry that reads the release attributes from the pod annotation. */
|
|
52
|
+
export function releaseAttributesEnv() {
|
|
53
|
+
return {
|
|
54
|
+
name: RELEASE_ATTRIBUTES_VARIABLE,
|
|
55
|
+
valueFrom: { fieldRef: { fieldPath: `metadata.annotations['${RELEASE_ATTRIBUTES_ANNOTATION}']` } },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** True when some container of the workload reads the release attributes, i.e. the build stamped it. */
|
|
59
|
+
function readsRelease(podSpec) {
|
|
60
|
+
const containers = podSpec.containers;
|
|
61
|
+
if (!Array.isArray(containers))
|
|
62
|
+
return false;
|
|
63
|
+
return containers.some((c) => isObj(c) && Array.isArray(c.env) && c.env.some((e) => isObj(e) && e.name === RELEASE_ATTRIBUTES_VARIABLE && isObj(e.valueFrom)));
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Set the release-attributes annotation on the pod template (or the Pod) of
|
|
67
|
+
* every stamped workload in `documents`, in place, and return how many it
|
|
68
|
+
* set. A workload the build did not stamp is left alone.
|
|
69
|
+
*/
|
|
70
|
+
export function annotateRelease(documents, suffix) {
|
|
71
|
+
let count = 0;
|
|
72
|
+
for (const doc of documents) {
|
|
73
|
+
const podSpec = podSpecOf(doc);
|
|
74
|
+
if (!podSpec || !readsRelease(podSpec))
|
|
75
|
+
continue;
|
|
76
|
+
const path = POD_SPEC_PATHS[doc.kind];
|
|
77
|
+
const owner = path.length === 1 ? doc : at(doc, path.slice(0, -1));
|
|
78
|
+
if (!isObj(owner))
|
|
79
|
+
continue;
|
|
80
|
+
const metadata = isObj(owner.metadata) ? owner.metadata : {};
|
|
81
|
+
const annotations = isObj(metadata.annotations) ? metadata.annotations : {};
|
|
82
|
+
owner.metadata = { ...metadata, annotations: { ...annotations, [RELEASE_ATTRIBUTES_ANNOTATION]: suffix } };
|
|
83
|
+
count++;
|
|
84
|
+
}
|
|
85
|
+
return count;
|
|
86
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* CLI entry point for Kubernetes lexicon validation.
|
|
4
|
+
*/
|
|
5
|
+
import { validate } from "./validate.js";
|
|
6
|
+
import { printValidationResult } from "@intentius/chant/codegen/validate";
|
|
7
|
+
async function main() {
|
|
8
|
+
const result = await validate();
|
|
9
|
+
printValidationResult(result);
|
|
10
|
+
if (!result.success) {
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
main().catch((err) => {
|
|
15
|
+
console.error("Validation failed:", err);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
});
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate generated lexicon-k8s artifacts.
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper around the core validation framework
|
|
5
|
+
* with Kubernetes-specific configuration.
|
|
6
|
+
*/
|
|
7
|
+
import { dirname } from "path";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
import { validateLexiconArtifacts } from "@intentius/chant/codegen/validate";
|
|
10
|
+
const REQUIRED_NAMES = [
|
|
11
|
+
// Core resources
|
|
12
|
+
"Pod", "Service", "ConfigMap", "Secret", "Namespace",
|
|
13
|
+
"ServiceAccount", "PersistentVolume", "PersistentVolumeClaim",
|
|
14
|
+
// Apps
|
|
15
|
+
"Deployment", "StatefulSet", "DaemonSet", "ReplicaSet",
|
|
16
|
+
// Batch
|
|
17
|
+
"Job", "CronJob",
|
|
18
|
+
// Networking
|
|
19
|
+
"Ingress", "NetworkPolicy",
|
|
20
|
+
// RBAC
|
|
21
|
+
"Role", "ClusterRole", "RoleBinding", "ClusterRoleBinding",
|
|
22
|
+
// Autoscaling
|
|
23
|
+
"HorizontalPodAutoscaler",
|
|
24
|
+
// Policy
|
|
25
|
+
"PodDisruptionBudget",
|
|
26
|
+
// Property types
|
|
27
|
+
"Container", "Volume", "VolumeMount", "EnvVar", "ServicePort",
|
|
28
|
+
"Probe", "ResourceRequirements", "SecurityContext",
|
|
29
|
+
];
|
|
30
|
+
/**
|
|
31
|
+
* Validate the generated lexicon-k8s artifacts.
|
|
32
|
+
*/
|
|
33
|
+
export async function validate(opts) {
|
|
34
|
+
const basePath = opts?.basePath ?? dirname(dirname(fileURLToPath(import.meta.url)));
|
|
35
|
+
return validateLexiconArtifacts({
|
|
36
|
+
lexiconJsonFilename: "lexicon-k8s.json",
|
|
37
|
+
requiredNames: REQUIRED_NAMES,
|
|
38
|
+
basePath,
|
|
39
|
+
// chant #1475 — the baseline was re-baselined with the k3s CRD work
|
|
40
|
+
// (#1605). k8s generates from a pinned kubernetes release tag plus CRDs
|
|
41
|
+
// vendored in this repo, so a fresh generate is deterministic and the
|
|
42
|
+
// surface can only move through a change here. Gate on every validate,
|
|
43
|
+
// not just under CHANT_RELEASE_GATE, so a CRD batch can no longer leave
|
|
44
|
+
// the snapshot behind the way #1319/#1320/#1321 did.
|
|
45
|
+
checkSurfaceSnapshot: "always",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kubernetes pseudo-parameters / well-known variables.
|
|
3
|
+
*
|
|
4
|
+
* Unlike AWS which has built-in pseudo-parameters (AWS::Region, etc.),
|
|
5
|
+
* Kubernetes doesn't have equivalent built-in variables. This module
|
|
6
|
+
* provides common label key constants for use in resource definitions.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Well-known Kubernetes label keys from the standard label taxonomy.
|
|
10
|
+
* These are string constants, not runtime-resolved pseudo-parameters.
|
|
11
|
+
*/
|
|
12
|
+
export const K8sLabels = {
|
|
13
|
+
/** app.kubernetes.io/name */
|
|
14
|
+
AppName: "app.kubernetes.io/name",
|
|
15
|
+
/** app.kubernetes.io/instance */
|
|
16
|
+
AppInstance: "app.kubernetes.io/instance",
|
|
17
|
+
/** app.kubernetes.io/version */
|
|
18
|
+
AppVersion: "app.kubernetes.io/version",
|
|
19
|
+
/** app.kubernetes.io/component */
|
|
20
|
+
AppComponent: "app.kubernetes.io/component",
|
|
21
|
+
/** app.kubernetes.io/part-of */
|
|
22
|
+
AppPartOf: "app.kubernetes.io/part-of",
|
|
23
|
+
/** app.kubernetes.io/managed-by */
|
|
24
|
+
AppManagedBy: "app.kubernetes.io/managed-by",
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Well-known Kubernetes annotation keys.
|
|
28
|
+
*/
|
|
29
|
+
export const K8sAnnotations = {
|
|
30
|
+
/** kubernetes.io/change-cause */
|
|
31
|
+
ChangeCause: "kubernetes.io/change-cause",
|
|
32
|
+
/** kubernetes.io/description */
|
|
33
|
+
Description: "kubernetes.io/description",
|
|
34
|
+
};
|
package/dist/whoami.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who chant would act as in a cluster — the lexicon half of `chant lifecycle
|
|
3
|
+
* whoami` (chant #1982).
|
|
4
|
+
*
|
|
5
|
+
* The connector already resolves all of this and hands it back on every
|
|
6
|
+
* observation: `defaultK8sConnector` picks a context from
|
|
7
|
+
* `k8s.profiles.<env>.context` or from the ambient kubeconfig, the client
|
|
8
|
+
* records the server it resolved and which credential path authorized it, and
|
|
9
|
+
* `describeResources` then throws the lot away. This reports it, and reports
|
|
10
|
+
* it BEFORE a read rather than after a wrong one.
|
|
11
|
+
*
|
|
12
|
+
* Same connector, same environment, same options, so the server named here is
|
|
13
|
+
* the server the read talks to. A test pins that: a whoami that names a
|
|
14
|
+
* cluster the read does not use would be worse than no whoami at all.
|
|
15
|
+
*
|
|
16
|
+
* ## The principal is the API server's answer, not the kubeconfig's
|
|
17
|
+
*
|
|
18
|
+
* A kubeconfig `user` entry is a local alias. The cluster may map that
|
|
19
|
+
* credential onto any subject it likes, which is exactly why "am I acting as
|
|
20
|
+
* the identity I think I am" is a real question. So the principal comes from
|
|
21
|
+
* `SelfSubjectReview` — the call behind `kubectl auth whoami` — which the API
|
|
22
|
+
* server answers with the subject it authenticated. It creates nothing.
|
|
23
|
+
*
|
|
24
|
+
* A cluster older than the review API serves no version of it. That is
|
|
25
|
+
* `read-failed` with a detail saying so, not a fallback to the local alias
|
|
26
|
+
* dressed up as an identity.
|
|
27
|
+
*
|
|
28
|
+
* ## No credential in the answer
|
|
29
|
+
*
|
|
30
|
+
* The credential path is reported by NAME — `exec-plugin` and its command,
|
|
31
|
+
* `token`, `client-certificate`. A kubeconfig holding a static bearer token is
|
|
32
|
+
* the case where the identity signal IS a secret, and the report says
|
|
33
|
+
* `credential: token` and stops there. The token, the client key and the exec
|
|
34
|
+
* plugin's output never leave the client.
|
|
35
|
+
*/
|
|
36
|
+
import { defaultK8sConnector } from "./api/connect.js";
|
|
37
|
+
import { classifyApiFailure, isMissingClientPackage, MISSING_CLIENT_DETAIL } from "./api/classify.js";
|
|
38
|
+
/** Everything about the caller's credential except the credential. */
|
|
39
|
+
function credentialNote(credential, execCommand) {
|
|
40
|
+
return execCommand ? `credential ${credential} (${execCommand})` : `credential ${credential}`;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The k8s `describeIdentity`. Connects the way the read connects, asks the API
|
|
44
|
+
* server who it is talking to, and reports the context that binding came from.
|
|
45
|
+
*/
|
|
46
|
+
export async function describeIdentity(options, connect = defaultK8sConnector) {
|
|
47
|
+
let connected;
|
|
48
|
+
try {
|
|
49
|
+
connected = await connect({ environment: options.environment, cwd: options.cwd });
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
if (isMissingClientPackage(err)) {
|
|
53
|
+
return { unresolved: { reason: "read-failed", detail: MISSING_CLIENT_DETAIL } };
|
|
54
|
+
}
|
|
55
|
+
const outcome = classifyApiFailure(err);
|
|
56
|
+
return {
|
|
57
|
+
unresolved: outcome.kind === "unobserved"
|
|
58
|
+
? { reason: outcome.reason, detail: outcome.detail }
|
|
59
|
+
: { reason: "no-binding", detail: err instanceof Error ? err.message : String(err) },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const { client, target } = connected;
|
|
63
|
+
const provenance = client.provenance;
|
|
64
|
+
const context = provenance.context ?? "(unset)";
|
|
65
|
+
const scope = `${context} ns=${client.defaultNamespace}`;
|
|
66
|
+
// Where the context came from, in the project's own vocabulary. An ambient
|
|
67
|
+
// context is not a failure — but it is the one an operator most often did
|
|
68
|
+
// not mean, so it says which binding is missing.
|
|
69
|
+
const binding = target.source === "bound"
|
|
70
|
+
? `k8s.profiles.${options.environment}.context`
|
|
71
|
+
: `ambient kubeconfig current-context (no k8s.profiles.${options.environment} binding)`;
|
|
72
|
+
const source = [
|
|
73
|
+
binding,
|
|
74
|
+
credentialNote(provenance.credential, provenance.execCommand),
|
|
75
|
+
`kubeconfig ${provenance.kubeconfigSource}`,
|
|
76
|
+
].join("; ");
|
|
77
|
+
let subject;
|
|
78
|
+
try {
|
|
79
|
+
subject = await client.selfSubjectReview();
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
const outcome = classifyApiFailure(err);
|
|
83
|
+
return {
|
|
84
|
+
unresolved: {
|
|
85
|
+
reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
|
|
86
|
+
detail: outcome.kind === "unobserved"
|
|
87
|
+
? `SelfSubjectReview against ${provenance.server}: ${outcome.detail}`
|
|
88
|
+
: `SelfSubjectReview against ${provenance.server} did not answer`,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (!subject) {
|
|
93
|
+
return {
|
|
94
|
+
unresolved: {
|
|
95
|
+
reason: "read-failed",
|
|
96
|
+
detail: `${provenance.server} serves no authentication.k8s.io SelfSubjectReview, so the API server ` +
|
|
97
|
+
`cannot be asked who it authenticated (the API landed in Kubernetes 1.26). The kubeconfig ` +
|
|
98
|
+
`context is "${context}" with ${credentialNote(provenance.credential, provenance.execCommand)}, ` +
|
|
99
|
+
`which names a local credential entry, not the subject the cluster resolves it to.`,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
identity: subject.username,
|
|
105
|
+
scope,
|
|
106
|
+
source,
|
|
107
|
+
endpoint: provenance.server,
|
|
108
|
+
};
|
|
109
|
+
}
|