@intentius/chant-lexicon-k8s 0.124.30 → 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,699 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live introspection of a Kubernetes cluster — implements the
|
|
3
|
+
* LexiconPlugin.describeResources() contract for the k8s lexicon.
|
|
4
|
+
*
|
|
5
|
+
* ## What changed, and why it matters (chant #1074)
|
|
6
|
+
*
|
|
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.
|
|
13
|
+
*
|
|
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 sweeps candidate kinds in the namespaces they actually live in
|
|
46
|
+
* and walks each object's `ownerReferences` chain (`./api/owner-chain.ts`)
|
|
47
|
+
* against the entities this very call resolved. A chain reaching one of them
|
|
48
|
+
* is reported with `ownerChain: { root: "declared", ... }`; core's
|
|
49
|
+
* diff/change-set engine reads that as `runtime` instead of `orphan`.
|
|
50
|
+
*
|
|
51
|
+
* Which kinds are candidates is the cluster's own API discovery's answer
|
|
52
|
+
* (#1517): every listable kind it serves, one version per group. The sweep's
|
|
53
|
+
* bound is the namespaces the declared estate touches, never a kind table —
|
|
54
|
+
* see `addRuntimeChildren`.
|
|
55
|
+
*/
|
|
56
|
+
import { observation, unobservedAll } from "@intentius/chant/observation";
|
|
57
|
+
import { hasOwnershipMarker, classifyOwnership, readOwnership, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
|
|
58
|
+
import { defaultK8sConnector } from "./api/connect.js";
|
|
59
|
+
import { classifyApiFailure, isMissingClientPackage, isWholeLexiconFailure, MISSING_CLIENT_DETAIL, } from "./api/classify.js";
|
|
60
|
+
import { operationFor } from "./api/operation-surface.js";
|
|
61
|
+
import { resolveK8sOwnerChain } from "./api/owner-chain.js";
|
|
62
|
+
import { gvkToTypeName } from "./spec/parse.js";
|
|
63
|
+
import { observeReceiptRows, receiptRowsFor } from "./receipt-store.js";
|
|
64
|
+
function pruneUndefined(obj) {
|
|
65
|
+
const out = {};
|
|
66
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
67
|
+
if (v !== undefined)
|
|
68
|
+
out[k] = v;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Collapse a live object to one status word. Unchanged from the kubectl path
|
|
74
|
+
* — the shape of the response is the same JSON either way.
|
|
75
|
+
*/
|
|
76
|
+
export function statusFromObject(obj) {
|
|
77
|
+
const status = obj.status;
|
|
78
|
+
// The most specific FAILING signal wins over the outermost one (#1397).
|
|
79
|
+
//
|
|
80
|
+
// A Pod's phase is `Running` from the moment the kubelet admits it and one
|
|
81
|
+
// container starts — so a crashlooping Pod reported `Running`, which every
|
|
82
|
+
// consumer classifying that word read as healthy. The failure lives one level
|
|
83
|
+
// down, in `containerStatuses[].state.waiting.reason`, and never reached the
|
|
84
|
+
// wire, so nothing downstream could recover it.
|
|
85
|
+
const waiting = failingContainerReason(status);
|
|
86
|
+
if (waiting)
|
|
87
|
+
return waiting;
|
|
88
|
+
// Scheduling failure is a condition, not a phase: an unschedulable Pod is
|
|
89
|
+
// `Pending`, which is true and useless next to `Unschedulable`.
|
|
90
|
+
const blocked = failingConditionReason(status);
|
|
91
|
+
if (blocked)
|
|
92
|
+
return blocked;
|
|
93
|
+
const phase = status?.phase;
|
|
94
|
+
if (typeof phase === "string")
|
|
95
|
+
return phase;
|
|
96
|
+
if (status && typeof status.readyReplicas === "number" && typeof status.replicas === "number") {
|
|
97
|
+
return status.readyReplicas === status.replicas
|
|
98
|
+
? "READY"
|
|
99
|
+
: `PROGRESSING(${status.readyReplicas}/${status.replicas})`;
|
|
100
|
+
}
|
|
101
|
+
// The kstatus rung (#1549): an object with NO phase and NO replica counts —
|
|
102
|
+
// a Flux Kustomization/HelmRelease, cert-manager, any conditions-first CRD —
|
|
103
|
+
// used to fall straight to PRESENT, so a wedged reconciler read exactly like
|
|
104
|
+
// a healthy one. `Ready` is the contract those objects speak: False → its
|
|
105
|
+
// reason (`BuildFailed`, `UpgradeFailed`, …), True → READY. Deliberately
|
|
106
|
+
// BELOW the phase/replica rungs so no Pod or workload word changes — for
|
|
107
|
+
// those objects a False Ready is already visible through the rungs above.
|
|
108
|
+
const ready = readyConditionWord(status);
|
|
109
|
+
if (ready)
|
|
110
|
+
return ready;
|
|
111
|
+
// Argo's Application speaks health/sync, not a Ready condition (the same
|
|
112
|
+
// split the readiness registry encodes) — `Degraded` / `OutOfSync` beats
|
|
113
|
+
// PRESENT for the one object whose whole job is convergence.
|
|
114
|
+
const argo = argoStatusWord(status);
|
|
115
|
+
if (argo)
|
|
116
|
+
return argo;
|
|
117
|
+
return "PRESENT";
|
|
118
|
+
}
|
|
119
|
+
/** The `Ready` condition rendered as a status word: False → its reason (or
|
|
120
|
+
* NOT-READY when the controller gave none), True → READY, absent → nothing. */
|
|
121
|
+
function readyConditionWord(status) {
|
|
122
|
+
const conditions = status?.conditions;
|
|
123
|
+
if (!Array.isArray(conditions))
|
|
124
|
+
return undefined;
|
|
125
|
+
for (const c of conditions) {
|
|
126
|
+
const cond = c;
|
|
127
|
+
if (cond.type !== "Ready")
|
|
128
|
+
continue;
|
|
129
|
+
if (cond.status === "True")
|
|
130
|
+
return "READY";
|
|
131
|
+
if (cond.status === "False") {
|
|
132
|
+
return typeof cond.reason === "string" && cond.reason.length > 0 ? cond.reason : "NOT-READY";
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
/** Argo Application health/sync as a status word: anything but the happy pair
|
|
138
|
+
* surfaces the unhappy half; Healthy+Synced reads as READY. */
|
|
139
|
+
function argoStatusWord(status) {
|
|
140
|
+
const s = status;
|
|
141
|
+
const health = typeof s?.health?.status === "string" ? s.health.status : undefined;
|
|
142
|
+
const sync = typeof s?.sync?.status === "string" ? s.sync.status : undefined;
|
|
143
|
+
if (!health && !sync)
|
|
144
|
+
return undefined;
|
|
145
|
+
if (health && health !== "Healthy")
|
|
146
|
+
return health;
|
|
147
|
+
if (sync && sync !== "Synced")
|
|
148
|
+
return sync;
|
|
149
|
+
return "READY";
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The reason a container is stuck, if one is — `CrashLoopBackOff`,
|
|
153
|
+
* `ImagePullBackOff`, `CreateContainerConfigError`.
|
|
154
|
+
*
|
|
155
|
+
* Only a WAITING container counts. A terminated one may have exited cleanly as
|
|
156
|
+
* part of a Job, and a running one is not stuck; neither should outrank the
|
|
157
|
+
* phase.
|
|
158
|
+
*/
|
|
159
|
+
function failingContainerReason(status) {
|
|
160
|
+
const containers = status?.containerStatuses;
|
|
161
|
+
if (!Array.isArray(containers))
|
|
162
|
+
return undefined;
|
|
163
|
+
for (const c of containers) {
|
|
164
|
+
const reason = c?.state?.waiting?.reason;
|
|
165
|
+
// `ContainerCreating` and `PodInitializing` are ordinary startup, not
|
|
166
|
+
// failure — they already classify as progressing, and promoting them over
|
|
167
|
+
// the phase would just be noisier.
|
|
168
|
+
if (typeof reason === "string" && reason.length > 0 && reason !== "ContainerCreating" && reason !== "PodInitializing") {
|
|
169
|
+
return reason;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The reason a blocking condition is False — `Unschedulable` and the like.
|
|
176
|
+
*
|
|
177
|
+
* Only conditions whose truth means "this object is usable"; a False `Ready` on
|
|
178
|
+
* its own is already visible through the phase and the container states above.
|
|
179
|
+
*/
|
|
180
|
+
function failingConditionReason(status) {
|
|
181
|
+
const conditions = status?.conditions;
|
|
182
|
+
if (!Array.isArray(conditions))
|
|
183
|
+
return undefined;
|
|
184
|
+
for (const c of conditions) {
|
|
185
|
+
const cond = c;
|
|
186
|
+
if (cond.type !== "PodScheduled")
|
|
187
|
+
continue;
|
|
188
|
+
if (cond.status !== "False")
|
|
189
|
+
continue;
|
|
190
|
+
if (typeof cond.reason === "string" && cond.reason.length > 0)
|
|
191
|
+
return cond.reason;
|
|
192
|
+
}
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The unhappy conditions on a live object, as `Type=Reason: message` (#1401).
|
|
197
|
+
*
|
|
198
|
+
* #1397 made the status WORD honest — a crashlooping Pod reports
|
|
199
|
+
* `CrashLoopBackOff` rather than `Running`. This carries the part that says
|
|
200
|
+
* what to do about it: `Unschedulable` is the reason, and
|
|
201
|
+
* `0/3 nodes are available: 1 node(s) had untolerated taint` is the answer, and
|
|
202
|
+
* only the first reached the wire.
|
|
203
|
+
*
|
|
204
|
+
* Only conditions that are NOT in their happy state are reported. A `Ready=True`
|
|
205
|
+
* says nothing an operator needs and would bury the one line that does — and a
|
|
206
|
+
* condition's happy polarity is per type: `Ready`/`Available`/`PodScheduled`
|
|
207
|
+
* are good when True, while `Unschedulable`-style and the standard
|
|
208
|
+
* `*Pressure`/`NetworkUnavailable` node conditions are good when False.
|
|
209
|
+
*
|
|
210
|
+
* Absent when everything is happy, so a consumer can treat presence as "this
|
|
211
|
+
* object has something to say".
|
|
212
|
+
*/
|
|
213
|
+
const CONDITIONS_GOOD_WHEN_FALSE = /pressure$|^networkunavailable$|^unschedulable$|failed/i;
|
|
214
|
+
export function unhappyConditions(obj) {
|
|
215
|
+
const conditions = obj.status?.conditions;
|
|
216
|
+
if (!Array.isArray(conditions))
|
|
217
|
+
return undefined;
|
|
218
|
+
const out = [];
|
|
219
|
+
for (const c of conditions) {
|
|
220
|
+
const cond = c;
|
|
221
|
+
if (typeof cond.type !== "string")
|
|
222
|
+
continue;
|
|
223
|
+
if (typeof cond.status === "string") {
|
|
224
|
+
const goodWhenFalse = CONDITIONS_GOOD_WHEN_FALSE.test(cond.type);
|
|
225
|
+
const happy = goodWhenFalse ? cond.status === "False" : cond.status === "True";
|
|
226
|
+
if (happy)
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
else if (!(typeof cond.message === "string" && cond.message)) {
|
|
230
|
+
// No status, no message: nothing to say. But a status-less condition
|
|
231
|
+
// WITH a message is an object speaking — Argo's ApplicationCondition is
|
|
232
|
+
// `{type, message, lastTransitionTime}`, written only for problems, and
|
|
233
|
+
// the polarity test above has nothing to read on it (#1644).
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const reason = typeof cond.reason === "string" && cond.reason ? `=${cond.reason}` : "";
|
|
237
|
+
const message = typeof cond.message === "string" && cond.message ? `: ${cond.message}` : "";
|
|
238
|
+
out.push(`${cond.type}${reason}${message}`);
|
|
239
|
+
}
|
|
240
|
+
return out.length > 0 ? out : undefined;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* The revisions a GitOps controller writes onto its own object (#1632).
|
|
244
|
+
*
|
|
245
|
+
* Conditions lag. A Flux Kustomization mid-rollout still reports the previous
|
|
246
|
+
* reconcile's `Ready=True` for as long as the new one takes, so a consumer
|
|
247
|
+
* reading only conditions calls a half-applied estate converged. The
|
|
248
|
+
* controller's own definition of converged is a revision comparison:
|
|
249
|
+
* `lastAppliedRevision` against `lastAttemptedRevision` on the same object
|
|
250
|
+
* (attempted-but-not-applied is in flight), and a Kustomization's
|
|
251
|
+
* `lastAppliedRevision` against its source's `artifact.revision` (the
|
|
252
|
+
* cross-object half, which a consumer joins once both ends are on the wire).
|
|
253
|
+
* None of the three reached an observed entity's attributes before this.
|
|
254
|
+
*
|
|
255
|
+
* Read by path rather than by kind, which is the module's standing rule
|
|
256
|
+
* everywhere else: `status.lastAppliedRevision` / `status.lastAttemptedRevision`
|
|
257
|
+
* are what kustomize-controller and helm-controller write, and
|
|
258
|
+
* `status.artifact.revision` is what every source kind (GitRepository,
|
|
259
|
+
* OCIRepository, Bucket, HelmChart) writes. An object that has none of them —
|
|
260
|
+
* every non-GitOps kind — contributes nothing, so no kind table has to name
|
|
261
|
+
* which kinds those are, and a CRD speaking the same convention is carried for
|
|
262
|
+
* free. `artifactRevision` flattens the one nested path so every revision on
|
|
263
|
+
* the wire is a plain string next to the others.
|
|
264
|
+
*/
|
|
265
|
+
export function revisionAttributes(obj) {
|
|
266
|
+
const status = obj.status;
|
|
267
|
+
const str = (v) => (typeof v === "string" && v.length > 0 ? v : undefined);
|
|
268
|
+
return {
|
|
269
|
+
lastAppliedRevision: str(status?.lastAppliedRevision),
|
|
270
|
+
lastAttemptedRevision: str(status?.lastAttemptedRevision),
|
|
271
|
+
artifactRevision: str(status?.artifact?.revision),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The declared repository protocol of a Flux HelmRepository (behold#298).
|
|
276
|
+
*
|
|
277
|
+
* A HelmRepository with `spec.type: oci` is never reconciled by
|
|
278
|
+
* source-controller — no conditions, no artifact, by design — so a consumer
|
|
279
|
+
* classifying health off conditions reads a healthy OCI repository exactly
|
|
280
|
+
* like one whose controller has not shown up. `spec.type` is the field that
|
|
281
|
+
* separates the two, and no spec field reached the wire before this. It rides
|
|
282
|
+
* the attributes flattened, as `type`, the same way `artifactRevision`
|
|
283
|
+
* flattens `status.artifact.revision`: every scalar on the wire is a plain
|
|
284
|
+
* key next to the others, and the wire stays lean — one field, not the spec.
|
|
285
|
+
*
|
|
286
|
+
* Kind-gated, unlike the module's usual read-by-path rule, because the PATH
|
|
287
|
+
* is not distinctive: a core Service's `spec.type` is
|
|
288
|
+
* `ClusterIP`/`LoadBalancer`, and forwarding that would change the wire shape
|
|
289
|
+
* of a kind this was never about. Only source.toolkit.fluxcd.io's
|
|
290
|
+
* HelmRepository speaks this meaning of the field.
|
|
291
|
+
*/
|
|
292
|
+
export function helmRepositoryTypeAttribute(gvk, obj) {
|
|
293
|
+
if (gvk.kind !== "HelmRepository" || !gvk.apiVersion.startsWith("source.toolkit.fluxcd.io/"))
|
|
294
|
+
return undefined;
|
|
295
|
+
const type = obj.spec?.type;
|
|
296
|
+
return typeof type === "string" && type.length > 0 ? type : undefined;
|
|
297
|
+
}
|
|
298
|
+
export async function describeResources(options, connect = defaultK8sConnector) {
|
|
299
|
+
const resources = {};
|
|
300
|
+
const unobserved = {};
|
|
301
|
+
// The resolved query address per declared entity (#1620) — the exact request
|
|
302
|
+
// path each read was issued against, with the namespace the client actually
|
|
303
|
+
// defaulted to when the declaration carries none. This is what lets an
|
|
304
|
+
// absence explain itself: a Flux-deployed object declaring no
|
|
305
|
+
// metadata.namespace reads from `default`, correctly comes back 404, and
|
|
306
|
+
// without the address the report says "not there" when the truth is "not
|
|
307
|
+
// looked for where it lives".
|
|
308
|
+
const queried = {};
|
|
309
|
+
// Effect receipt rows (#2074) are read by their own leg below: the applier
|
|
310
|
+
// never wrote them (#1832), and their declaration carries no props, so the
|
|
311
|
+
// generic sweep has no `metadata.name` to query by and would report a hole
|
|
312
|
+
// where the receipt leg has a real answer. Their addresses come from the
|
|
313
|
+
// build output's receipt comment, which is the serializer's one rendering of
|
|
314
|
+
// the derivation (./effect-receipt-row.ts).
|
|
315
|
+
const receiptRows = receiptRowsFor(options.entityNames, options.buildOutput);
|
|
316
|
+
const declared = [...options.entities]
|
|
317
|
+
.filter(([entityName]) => !receiptRows.has(entityName))
|
|
318
|
+
.map(([entityName, entity]) => ({
|
|
319
|
+
entityName,
|
|
320
|
+
entityType: entity.entityType,
|
|
321
|
+
props: entity.props,
|
|
322
|
+
}));
|
|
323
|
+
// A whole-lexicon failure below is a hole for the receipts too: nobody
|
|
324
|
+
// looked at those either, and a connect that never happened proves nothing
|
|
325
|
+
// about a receipt's presence.
|
|
326
|
+
const everyName = [...declared.map((d) => d.entityName), ...receiptRows.keys()];
|
|
327
|
+
// Connect first. The binding check lives here, so a bound-but-mismatched
|
|
328
|
+
// context throws before any resource is read — core turns that into
|
|
329
|
+
// NOT-OBSERVED for every declared entity rather than an empty result.
|
|
330
|
+
let client;
|
|
331
|
+
try {
|
|
332
|
+
({ client } = await connect({ environment: options.environment, cwd: options.cwd }));
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
if (isMissingClientPackage(err)) {
|
|
336
|
+
return observation({}, unobservedAll(everyName, "read-failed", MISSING_CLIENT_DETAIL, options.entities));
|
|
337
|
+
}
|
|
338
|
+
if (isWholeLexiconFailure(err)) {
|
|
339
|
+
const outcome = classifyApiFailure(err);
|
|
340
|
+
return observation({}, unobservedAll(everyName, outcome.kind === "unobserved" ? outcome.reason : "read-failed", outcome.kind === "unobserved" ? outcome.detail : undefined, options.entities));
|
|
341
|
+
}
|
|
342
|
+
// A cluster-binding mismatch (chant #1100) belongs here: it is a loud
|
|
343
|
+
// refusal, and core's whole-lexicon handling is what turns it into an
|
|
344
|
+
// honest hole per entity.
|
|
345
|
+
throw err;
|
|
346
|
+
}
|
|
347
|
+
await client.concurrently(declared, async ({ entityName, entityType, props }) => {
|
|
348
|
+
const operation = operationFor(entityType);
|
|
349
|
+
if (!operation) {
|
|
350
|
+
// chant knows no API address for this type at all — not even its group.
|
|
351
|
+
// The object may well exist, so this is a hole, never an absence.
|
|
352
|
+
unobserved[entityName] = {
|
|
353
|
+
type: entityType,
|
|
354
|
+
reason: "unsupported-kind",
|
|
355
|
+
detail: `no generated operation surface for ${entityType} — run \`chant generate\` in the k8s lexicon, or declare the CRD as a codegen source`,
|
|
356
|
+
};
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const metadata = props.metadata;
|
|
360
|
+
const name = metadata?.name;
|
|
361
|
+
if (!name) {
|
|
362
|
+
// Nothing to query by. Not an absence — chant never issued a read.
|
|
363
|
+
unobserved[entityName] = {
|
|
364
|
+
type: entityType,
|
|
365
|
+
reason: "read-failed",
|
|
366
|
+
detail: "declared entity has no metadata.name to query by",
|
|
367
|
+
};
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
// The declared namespace wins; the caller's override only fills the hole a
|
|
371
|
+
// declaration left, and only for a kind that HAS a namespace (#1629). With
|
|
372
|
+
// neither, the client defaults to `default` exactly as it did.
|
|
373
|
+
const namespace = metadata?.namespace ?? (operation.scope === "Namespaced" ? options.namespace : undefined);
|
|
374
|
+
const ref = {
|
|
375
|
+
apiVersion: operation.apiVersion,
|
|
376
|
+
kind: operation.kind,
|
|
377
|
+
name,
|
|
378
|
+
...(namespace ? { namespace } : {}),
|
|
379
|
+
};
|
|
380
|
+
// Resolve the address BEFORE the read, so every verdict below — present,
|
|
381
|
+
// absent, filtered, read-failed — carries the same record of what was
|
|
382
|
+
// asked. Resolution rides the client's cached discovery; a kind discovery
|
|
383
|
+
// does not serve has no path, and a resolution failure classifies through
|
|
384
|
+
// the read below, never here.
|
|
385
|
+
try {
|
|
386
|
+
const address = typeof client.pathFor === "function" ? await client.pathFor(ref) : undefined;
|
|
387
|
+
if (address)
|
|
388
|
+
queried[entityName] = address;
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
// The read below hits the same resolution and classifies its failure.
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
const obj = await client.read(ref);
|
|
395
|
+
// owned filter: withhold resources not carrying chant's marker label.
|
|
396
|
+
// Withheld is not absent (chant #1089) — this object exists, it just
|
|
397
|
+
// isn't chant's, and dropping it silently is how `--owned` used to turn a
|
|
398
|
+
// declared-but-foreign resource into a proposed `create`.
|
|
399
|
+
if (options.owned && !hasOwnershipMarker(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS)) {
|
|
400
|
+
unobserved[entityName] = {
|
|
401
|
+
type: entityType,
|
|
402
|
+
reason: "filtered",
|
|
403
|
+
detail: "live object carries no chant ownership marker and --owned was requested",
|
|
404
|
+
...(queried[entityName] ? { queried: queried[entityName] } : {}),
|
|
405
|
+
};
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
resources[entityName] = {
|
|
409
|
+
type: entityType,
|
|
410
|
+
physicalId: obj.metadata?.uid,
|
|
411
|
+
status: statusFromObject(obj),
|
|
412
|
+
lastUpdated: obj.metadata?.creationTimestamp,
|
|
413
|
+
ownership: classifyOwnership(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS),
|
|
414
|
+
// Marker identity (#1222): the stack/env labels stamped at synthesis,
|
|
415
|
+
// read back verbatim. Undefined when the managed-by label is absent.
|
|
416
|
+
marker: readOwnership(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS),
|
|
417
|
+
attributes: pruneUndefined({
|
|
418
|
+
namespace: obj.metadata?.namespace,
|
|
419
|
+
labels: obj.metadata?.labels,
|
|
420
|
+
resourceVersion: obj.metadata?.resourceVersion,
|
|
421
|
+
// What the object's own controller says is wrong with it (#1401).
|
|
422
|
+
conditions: unhappyConditions(obj),
|
|
423
|
+
// What a GitOps controller says it has applied and is applying
|
|
424
|
+
// (#1632) — the convergence signal conditions cannot carry.
|
|
425
|
+
...revisionAttributes(obj),
|
|
426
|
+
// A HelmRepository's spec.type — `oci` means "no conditions is
|
|
427
|
+
// healthy", and only this field can say so (behold#298).
|
|
428
|
+
type: helmRepositoryTypeAttribute(operation, obj),
|
|
429
|
+
}),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
const outcome = classifyApiFailure(err);
|
|
434
|
+
if (outcome.kind === "unobserved") {
|
|
435
|
+
unobserved[entityName] = {
|
|
436
|
+
type: entityType,
|
|
437
|
+
reason: outcome.reason,
|
|
438
|
+
detail: outcome.detail,
|
|
439
|
+
...(queried[entityName] ? { queried: queried[entityName] } : {}),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
// `absent` deliberately records nothing: in neither map is how the
|
|
443
|
+
// contract spells "asked, and it is not there". Its address stays in
|
|
444
|
+
// `queried` (#1620), which is the only record an absence gets.
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
await addRuntimeChildren(client, resources, unobserved, options.owned, declared);
|
|
448
|
+
// The receipt leg last, so its answers are the ones that stand for the
|
|
449
|
+
// receipt entities, because nothing above ever looked at one.
|
|
450
|
+
if (receiptRows.size > 0) {
|
|
451
|
+
const receiptObs = await observeReceiptRows(client, receiptRows);
|
|
452
|
+
Object.assign(resources, receiptObs.resources);
|
|
453
|
+
Object.assign(unobserved, receiptObs.unobserved);
|
|
454
|
+
}
|
|
455
|
+
return observation(resources, unobserved, queried);
|
|
456
|
+
}
|
|
457
|
+
function gitopsIndex(declared) {
|
|
458
|
+
const byRef = new Map();
|
|
459
|
+
const argoByName = new Map();
|
|
460
|
+
const targetNamespaces = new Set();
|
|
461
|
+
let present = false;
|
|
462
|
+
for (const { entityName, entityType, props } of declared) {
|
|
463
|
+
const isArgo = entityType === "K8s::Argo::Application";
|
|
464
|
+
const isFlux = entityType === "K8s::Flux::Kustomization" || entityType === "K8s::Flux::HelmRelease";
|
|
465
|
+
if (!isArgo && !isFlux)
|
|
466
|
+
continue;
|
|
467
|
+
present = true;
|
|
468
|
+
const meta = props.metadata;
|
|
469
|
+
const spec = props.spec;
|
|
470
|
+
const name = meta?.name;
|
|
471
|
+
if (!name)
|
|
472
|
+
continue;
|
|
473
|
+
if (isFlux) {
|
|
474
|
+
byRef.set(`${entityType}\0${meta?.namespace ?? "default"}\0${name}`, entityName);
|
|
475
|
+
if (typeof spec?.targetNamespace === "string")
|
|
476
|
+
targetNamespaces.add(spec.targetNamespace);
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
argoByName.set(name, argoByName.has(name) ? null : entityName);
|
|
480
|
+
if (typeof spec?.destination?.namespace === "string")
|
|
481
|
+
targetNamespaces.add(spec.destination.namespace);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return { present, byRef, argoByName, targetNamespaces };
|
|
485
|
+
}
|
|
486
|
+
/** The declared GitOps CR a swept object's controller labels resolve to, or
|
|
487
|
+
* undefined. Flux's label pairs are exact; Argo's bare instance label claims
|
|
488
|
+
* only a UNIQUE declared Application. */
|
|
489
|
+
function gitopsOwner(labels, gitops) {
|
|
490
|
+
if (!labels || !gitops.present)
|
|
491
|
+
return undefined;
|
|
492
|
+
const ksName = labels["kustomize.toolkit.fluxcd.io/name"];
|
|
493
|
+
const ksNs = labels["kustomize.toolkit.fluxcd.io/namespace"];
|
|
494
|
+
if (ksName && ksNs) {
|
|
495
|
+
const hit = gitops.byRef.get(`K8s::Flux::Kustomization\0${ksNs}\0${ksName}`);
|
|
496
|
+
if (hit)
|
|
497
|
+
return hit;
|
|
498
|
+
}
|
|
499
|
+
const hrName = labels["helm.toolkit.fluxcd.io/name"];
|
|
500
|
+
const hrNs = labels["helm.toolkit.fluxcd.io/namespace"];
|
|
501
|
+
if (hrName && hrNs) {
|
|
502
|
+
const hit = gitops.byRef.get(`K8s::Flux::HelmRelease\0${hrNs}\0${hrName}`);
|
|
503
|
+
if (hit)
|
|
504
|
+
return hit;
|
|
505
|
+
}
|
|
506
|
+
const instance = labels["app.kubernetes.io/instance"];
|
|
507
|
+
if (instance) {
|
|
508
|
+
const hit = gitops.argoByName.get(instance);
|
|
509
|
+
if (hit)
|
|
510
|
+
return hit; // null (ambiguous) and undefined both decline
|
|
511
|
+
}
|
|
512
|
+
return undefined;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Kinds the sweep skips even though discovery serves them. Not a kind table
|
|
516
|
+
* creeping back in: each entry is a kind that CANNOT attribute to a declared
|
|
517
|
+
* owner — an Event names its subject via `involvedObject`, never
|
|
518
|
+
* `ownerReferences` — and churns by the thousands in any active namespace,
|
|
519
|
+
* so listing it can only cost and never attach.
|
|
520
|
+
*/
|
|
521
|
+
function isSweepNoise(info) {
|
|
522
|
+
return info.kind === "Event" && (info.group === "" || info.group === "events.k8s.io");
|
|
523
|
+
}
|
|
524
|
+
async function addRuntimeChildren(client, resources, unobserved, owned, declared = []) {
|
|
525
|
+
const declaredByUid = new Map();
|
|
526
|
+
for (const [entityName, meta] of Object.entries(resources)) {
|
|
527
|
+
if (meta.physicalId)
|
|
528
|
+
declaredByUid.set(meta.physicalId, entityName);
|
|
529
|
+
}
|
|
530
|
+
// GitOps attribution (#1549): Argo and Flux stamp their managed objects with
|
|
531
|
+
// labels, not ownerReferences, so the chain walk never reaches a declared
|
|
532
|
+
// entity for them. The label channel resolves against the DECLARED CRs by
|
|
533
|
+
// (type, namespace, name) — exact for Flux (its labels carry both halves) —
|
|
534
|
+
// and by unique name for Argo (its instance label carries no namespace, and
|
|
535
|
+
// it doubles as a generic Helm label, so anything ambiguous resolves to
|
|
536
|
+
// nothing: the module's standing conservatism).
|
|
537
|
+
const gitops = gitopsIndex(declared);
|
|
538
|
+
const namespaces = new Set();
|
|
539
|
+
let clusterScopedDeclared = false;
|
|
540
|
+
for (const meta of Object.values(resources)) {
|
|
541
|
+
const namespace = meta.attributes?.namespace;
|
|
542
|
+
if (namespace)
|
|
543
|
+
namespaces.add(namespace);
|
|
544
|
+
else
|
|
545
|
+
clusterScopedDeclared = true;
|
|
546
|
+
}
|
|
547
|
+
// The namespaces a declared GitOps CR TARGETS are declared literals too
|
|
548
|
+
// (Argo `spec.destination.namespace`, Flux `spec.targetNamespace`) — the
|
|
549
|
+
// controller puts the workloads there, so the scan must look there, and
|
|
550
|
+
// reading it off the declaration keeps this bounded by the estate, never a
|
|
551
|
+
// cluster-wide sweep.
|
|
552
|
+
for (const ns of gitops.targetNamespaces)
|
|
553
|
+
namespaces.add(ns);
|
|
554
|
+
if (namespaces.size === 0 && !clusterScopedDeclared)
|
|
555
|
+
return;
|
|
556
|
+
// Every kind the cluster serves, from its own discovery (#1517): one
|
|
557
|
+
// groupVersion per group, every listable kind, split by scope. Failing to
|
|
558
|
+
// enumerate is a hole the observation must admit — silently sweeping
|
|
559
|
+
// nothing would read as "no runtime children", which was never established.
|
|
560
|
+
let groupVersions;
|
|
561
|
+
try {
|
|
562
|
+
groupVersions = await client.preferredGroupVersions();
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
const outcome = classifyApiFailure(err);
|
|
566
|
+
unobserved["runtime-sweep"] = {
|
|
567
|
+
reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
|
|
568
|
+
detail: "cluster API discovery failed, so runtime children were not swept" +
|
|
569
|
+
(outcome.kind === "unobserved" ? `: ${outcome.detail}` : ""),
|
|
570
|
+
};
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const perGroup = await client.concurrently(groupVersions, async (gv) => {
|
|
574
|
+
try {
|
|
575
|
+
return await client.resources(gv);
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
const outcome = classifyApiFailure(err);
|
|
579
|
+
if (outcome.kind === "unobserved") {
|
|
580
|
+
unobserved[`runtime-sweep:${gv}`] = {
|
|
581
|
+
reason: outcome.reason,
|
|
582
|
+
detail: `discovery for ${gv} failed, so its kinds were not swept: ${outcome.detail}`,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
return [];
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
const namespacedKinds = [];
|
|
589
|
+
const clusterKinds = [];
|
|
590
|
+
for (const infos of perGroup) {
|
|
591
|
+
for (const info of infos) {
|
|
592
|
+
if (!info.verbs.includes("list") || isSweepNoise(info))
|
|
593
|
+
continue;
|
|
594
|
+
const swept = {
|
|
595
|
+
apiVersion: info.apiVersion,
|
|
596
|
+
kind: info.kind,
|
|
597
|
+
typeName: gvkToTypeName({ group: info.group, version: info.version, kind: info.kind }),
|
|
598
|
+
};
|
|
599
|
+
(info.namespaced ? namespacedKinds : clusterKinds).push(swept);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
// A failed list is recorded once per kind, naming every scope it failed in,
|
|
603
|
+
// rather than once per (kind, namespace) — one RBAC rule denying a kind
|
|
604
|
+
// across N namespaces is one hole, not N.
|
|
605
|
+
const listFailures = new Map();
|
|
606
|
+
const sweep = async (swept, namespace) => {
|
|
607
|
+
let objects;
|
|
608
|
+
try {
|
|
609
|
+
objects = await client.list({ apiVersion: swept.apiVersion, kind: swept.kind }, namespace ? { namespace } : {});
|
|
610
|
+
}
|
|
611
|
+
catch (err) {
|
|
612
|
+
const outcome = classifyApiFailure(err);
|
|
613
|
+
// `absent` (a list 404: the kind vanished between discovery and now)
|
|
614
|
+
// proves no instance exists — nothing to record.
|
|
615
|
+
if (outcome.kind === "unobserved") {
|
|
616
|
+
const key = `runtime-sweep:${swept.apiVersion}/${swept.kind}`;
|
|
617
|
+
const scope = namespace ? `namespace ${namespace}` : "cluster scope";
|
|
618
|
+
const failure = listFailures.get(key);
|
|
619
|
+
if (failure)
|
|
620
|
+
failure.scopes.push(scope);
|
|
621
|
+
else
|
|
622
|
+
listFailures.set(key, { typeName: swept.typeName, reason: outcome.reason, scopes: [scope], detail: outcome.detail });
|
|
623
|
+
}
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
await client.concurrently(objects, async (obj) => {
|
|
627
|
+
const uid = obj.metadata?.uid;
|
|
628
|
+
const name = obj.metadata?.name;
|
|
629
|
+
if (!uid || !name || declaredByUid.has(uid))
|
|
630
|
+
return; // declared directly, or unaddressable
|
|
631
|
+
// The kind is part of the identity: a Deployment, Service and Endpoints
|
|
632
|
+
// all named `app-a` in namespace `app-a` are three objects, and a
|
|
633
|
+
// kind-less key reported only whichever kind swept first (#1643).
|
|
634
|
+
const key = namespace ? `${swept.kind}/${namespace}/${name}` : `cluster:${swept.kind}/${name}`;
|
|
635
|
+
if (resources[key])
|
|
636
|
+
return; // re-swept object, not a new report
|
|
637
|
+
let ownerChain = await resolveK8sOwnerChain(obj, { declaredByUid, reader: client, namespace });
|
|
638
|
+
// The label channel (#1549) — only when the chain itself said nothing:
|
|
639
|
+
// a verdict the walk DID reach is never overridden.
|
|
640
|
+
if (ownerChain.root !== "declared") {
|
|
641
|
+
const entity = gitopsOwner(obj.metadata?.labels, gitops);
|
|
642
|
+
if (entity)
|
|
643
|
+
ownerChain = { root: "declared", entity };
|
|
644
|
+
}
|
|
645
|
+
// `--owned`: withhold an object that is neither a runtime child of a
|
|
646
|
+
// declared entity nor carrying chant's own marker — the same rule the
|
|
647
|
+
// declared-entity read above applies, extended to the undeclared axis
|
|
648
|
+
// this scan introduces.
|
|
649
|
+
const marker = hasOwnershipMarker(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS);
|
|
650
|
+
if (owned && ownerChain.root !== "declared" && !marker)
|
|
651
|
+
return;
|
|
652
|
+
// An undeclared object with NO owner chain at all is not a runtime
|
|
653
|
+
// child — reporting every loose object the cluster serves would turn
|
|
654
|
+
// the sweep into an inventory. Pods keep their pre-#1517 reporting
|
|
655
|
+
// (their chain verdicts, including foreign, were already part of
|
|
656
|
+
// #1077's contract); every other swept kind only reports when the
|
|
657
|
+
// chain reaches a declared entity.
|
|
658
|
+
if (swept.kind !== "Pod" && ownerChain.root !== "declared")
|
|
659
|
+
return;
|
|
660
|
+
resources[key] = {
|
|
661
|
+
type: swept.typeName,
|
|
662
|
+
physicalId: uid,
|
|
663
|
+
status: statusFromObject(obj),
|
|
664
|
+
lastUpdated: obj.metadata?.creationTimestamp,
|
|
665
|
+
ownership: classifyOwnership(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS),
|
|
666
|
+
// Marker identity (#1222), same rule as the declared-entity read above.
|
|
667
|
+
marker: readOwnership(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS),
|
|
668
|
+
ownerChain,
|
|
669
|
+
attributes: pruneUndefined({
|
|
670
|
+
namespace,
|
|
671
|
+
labels: obj.metadata?.labels,
|
|
672
|
+
resourceVersion: obj.metadata?.resourceVersion,
|
|
673
|
+
conditions: unhappyConditions(obj),
|
|
674
|
+
// The same one spec field the declared read forwards (behold#298):
|
|
675
|
+
// a swept HelmRepository (Flux's own bootstrap manages them through
|
|
676
|
+
// a Kustomization) must not read differently from a declared one.
|
|
677
|
+
type: helmRepositoryTypeAttribute(swept, obj),
|
|
678
|
+
}),
|
|
679
|
+
};
|
|
680
|
+
});
|
|
681
|
+
};
|
|
682
|
+
await client.concurrently([...namespaces], async (namespace) => {
|
|
683
|
+
for (const swept of namespacedKinds)
|
|
684
|
+
await sweep(swept, namespace);
|
|
685
|
+
});
|
|
686
|
+
// Cluster-scoped kinds are swept once (their lists are cluster-wide by
|
|
687
|
+
// nature), and only when a declared cluster-scoped entity exists for a
|
|
688
|
+
// chain to reach — a cluster-scoped object's owners are cluster-scoped.
|
|
689
|
+
if (clusterScopedDeclared) {
|
|
690
|
+
await client.concurrently(clusterKinds, async (swept) => sweep(swept, undefined));
|
|
691
|
+
}
|
|
692
|
+
for (const [key, failure] of listFailures) {
|
|
693
|
+
unobserved[key] = {
|
|
694
|
+
type: failure.typeName,
|
|
695
|
+
reason: failure.reason,
|
|
696
|
+
detail: `the runtime-children sweep could not list this kind in ${failure.scopes.join(", ")}: ${failure.detail}`,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
}
|