@intentius/chant-lexicon-k8s 0.44.4 → 0.44.6
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.
|
@@ -62,6 +62,34 @@ import { type K8sConnector } from "./api/connect.js";
|
|
|
62
62
|
*/
|
|
63
63
|
export declare function statusFromObject(obj: K8sObject): string;
|
|
64
64
|
export declare function unhappyConditions(obj: K8sObject): string[] | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* The revisions a GitOps controller writes onto its own object (#1632).
|
|
67
|
+
*
|
|
68
|
+
* Conditions lag. A Flux Kustomization mid-rollout still reports the previous
|
|
69
|
+
* reconcile's `Ready=True` for as long as the new one takes, so a consumer
|
|
70
|
+
* reading only conditions calls a half-applied estate converged. The
|
|
71
|
+
* controller's own definition of converged is a revision comparison:
|
|
72
|
+
* `lastAppliedRevision` against `lastAttemptedRevision` on the same object
|
|
73
|
+
* (attempted-but-not-applied is in flight), and a Kustomization's
|
|
74
|
+
* `lastAppliedRevision` against its source's `artifact.revision` (the
|
|
75
|
+
* cross-object half, which a consumer joins once both ends are on the wire).
|
|
76
|
+
* None of the three reached an observed entity's attributes before this.
|
|
77
|
+
*
|
|
78
|
+
* Read by path rather than by kind, which is the module's standing rule
|
|
79
|
+
* everywhere else: `status.lastAppliedRevision` / `status.lastAttemptedRevision`
|
|
80
|
+
* are what kustomize-controller and helm-controller write, and
|
|
81
|
+
* `status.artifact.revision` is what every source kind (GitRepository,
|
|
82
|
+
* OCIRepository, Bucket, HelmChart) writes. An object that has none of them —
|
|
83
|
+
* every non-GitOps kind — contributes nothing, so no kind table has to name
|
|
84
|
+
* which kinds those are, and a CRD speaking the same convention is carried for
|
|
85
|
+
* free. `artifactRevision` flattens the one nested path so every revision on
|
|
86
|
+
* the wire is a plain string next to the others.
|
|
87
|
+
*/
|
|
88
|
+
export declare function revisionAttributes(obj: K8sObject): {
|
|
89
|
+
lastAppliedRevision?: string;
|
|
90
|
+
lastAttemptedRevision?: string;
|
|
91
|
+
artifactRevision?: string;
|
|
92
|
+
};
|
|
65
93
|
export interface DescribeResourcesOptions {
|
|
66
94
|
environment: string;
|
|
67
95
|
buildOutput: string;
|
|
@@ -73,6 +101,25 @@ export interface DescribeResourcesOptions {
|
|
|
73
101
|
owned?: boolean;
|
|
74
102
|
/** Directory whose `chant.config.ts` carries the cluster binding. Defaults to cwd. */
|
|
75
103
|
cwd?: string;
|
|
104
|
+
/**
|
|
105
|
+
* Namespace to read a namespaced entity from when it declares none (#1629).
|
|
106
|
+
*
|
|
107
|
+
* A GitOps estate splits the binding from the objects: the control-plane
|
|
108
|
+
* project declares a Kustomization with `spec.targetNamespace: app-b`, the
|
|
109
|
+
* app project declares bare Deployments and Services with no
|
|
110
|
+
* `metadata.namespace` at all — the controller stamps it at apply time. The
|
|
111
|
+
* live read of the app project had no way to be told that, so it fell
|
|
112
|
+
* through to the client's `default` and reported an estate that is running
|
|
113
|
+
* as entirely absent (#1620's `queried` line is what made that visible).
|
|
114
|
+
*
|
|
115
|
+
* A DEFAULT, never a rewrite: an entity with an explicit
|
|
116
|
+
* `metadata.namespace` reads from the namespace it declares, override or
|
|
117
|
+
* not — that declaration is the estate's own statement of where the object
|
|
118
|
+
* lives, and overruling it would make the option a way to read the wrong
|
|
119
|
+
* object rather than the right one. Cluster-scoped kinds are untouched:
|
|
120
|
+
* they have no namespace to default.
|
|
121
|
+
*/
|
|
122
|
+
namespace?: string;
|
|
76
123
|
}
|
|
77
124
|
export declare function describeResources(options: DescribeResourcesOptions, connect?: K8sConnector): Promise<ObservationResult>;
|
|
78
125
|
//# sourceMappingURL=describe-resources.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAwD,MAAM,0BAA0B,CAAC;AAGxH,OAAO,KAAK,EAAa,SAAS,EAAE,MAAM,6BAA6B,CAAC;AACxE,OAAO,EAAuB,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAmBvE;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,SAAS,GAAG,MAAM,CA2CvD;AA4FD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,SAAS,GAAG,MAAM,EAAE,GAAG,SAAS,CAetE;AAQD,MAAM,WAAW,wBAAwB;IACvC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IAC9E,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,sFAAsF;IACtF,GAAG,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAwD,MAAM,0BAA0B,CAAC;AAGxH,OAAO,KAAK,EAAa,SAAS,EAAE,MAAM,6BAA6B,CAAC;AACxE,OAAO,EAAuB,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAmBvE;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,SAAS,GAAG,MAAM,CA2CvD;AA4FD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,SAAS,GAAG,MAAM,EAAE,GAAG,SAAS,CAetE;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,SAAS,GAAG;IAClD,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B,CAUA;AAQD,MAAM,WAAW,wBAAwB;IACvC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IAC9E,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,sFAAsF;IACtF,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;;;;;;;;;;;;;OAiBG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,wBAAwB,EACjC,OAAO,GAAE,YAAkC,GAC1C,OAAO,CAAC,iBAAiB,CAAC,CA6J5B"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "c4b378460b192f75c29f7daad4779c0cf63d5ab56edc9ae31431e90833f71e18",
|
|
5
5
|
"meta.json": "f568fb8a0cbd4adf041baccaa3e1164c2e3c5c0090186236f887f0084f5521a8",
|
|
6
6
|
"types/index.d.ts": "ef2c9df6a4d1ebfbe33f96c094fbbe3fc7909522ea38e086666fbf3c8ac477fd",
|
|
7
7
|
"rules/argo-appset-single-project.ts": "afa9f310753aa2d475f35012b13135b2dfaebed2a35d44edbe185ebc07673674",
|
|
@@ -54,5 +54,5 @@
|
|
|
54
54
|
"skills/chant-k8s-argo.md": "b1a0b826559d8c5033a479c5781efaf650320f0aee4419d8841170bd3393cea5",
|
|
55
55
|
"skills/chant-k8s-flux.md": "9670efc2e9854bd14686b5adeb707396b9030a4dcaee6529b30abf56efe2c50c"
|
|
56
56
|
},
|
|
57
|
-
"composite": "
|
|
57
|
+
"composite": "3e4f2e1e01baea8c1204bde8b257bbab0c1508eb1a1718f883a93b664ffc202f"
|
|
58
58
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-k8s",
|
|
3
|
-
"version": "0.44.
|
|
3
|
+
"version": "0.44.6",
|
|
4
4
|
"description": "Kubernetes lexicon for chant — declarative IaC in TypeScript",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"@types/js-yaml": "^4.0.9"
|
|
77
77
|
},
|
|
78
78
|
"optionalDependencies": {
|
|
79
|
-
"@intentius/chant-k8s-client": "^0.44.
|
|
79
|
+
"@intentius/chant-k8s-client": "^0.44.6"
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
82
|
"@intentius/chant": "*",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
},
|
|
85
85
|
"peerDependencies": {
|
|
86
86
|
"zod": "^4.3.6",
|
|
87
|
-
"@intentius/chant": "^0.44.
|
|
87
|
+
"@intentius/chant": "^0.44.6",
|
|
88
88
|
"typescript": "^5.9.3"
|
|
89
89
|
}
|
|
90
90
|
}
|
|
@@ -15,7 +15,7 @@ vi.mock("@intentius/chant/config", () => ({
|
|
|
15
15
|
loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args),
|
|
16
16
|
}));
|
|
17
17
|
|
|
18
|
-
const { describeResources, statusFromObject, unhappyConditions } = await import("./describe-resources");
|
|
18
|
+
const { describeResources, statusFromObject, unhappyConditions, revisionAttributes } = await import("./describe-resources");
|
|
19
19
|
const { fakeCluster, objectKey, ownedObject } = await import("./api/fake-cluster");
|
|
20
20
|
const { defaultK8sConnector } = await import("./api/connect");
|
|
21
21
|
const { fakeKubeconfig, statusBody } = await import("@intentius/chant-k8s-client/testing");
|
|
@@ -1399,3 +1399,277 @@ describe("unhappyConditions (#1401)", () => {
|
|
|
1399
1399
|
expect(unhappyConditions({} as never)).toBeUndefined();
|
|
1400
1400
|
});
|
|
1401
1401
|
});
|
|
1402
|
+
|
|
1403
|
+
describe("revisionAttributes — the GitOps convergence fields (#1632)", () => {
|
|
1404
|
+
test("carries a Kustomization's applied and attempted revisions", () => {
|
|
1405
|
+
expect(
|
|
1406
|
+
revisionAttributes({
|
|
1407
|
+
status: {
|
|
1408
|
+
lastAppliedRevision: "main@sha1:1a2b3c4d",
|
|
1409
|
+
lastAttemptedRevision: "main@sha1:5e6f7a8b",
|
|
1410
|
+
conditions: [{ type: "Ready", status: "True" }],
|
|
1411
|
+
},
|
|
1412
|
+
} as never),
|
|
1413
|
+
).toMatchObject({ lastAppliedRevision: "main@sha1:1a2b3c4d", lastAttemptedRevision: "main@sha1:5e6f7a8b" });
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
test("flattens a source kind's status.artifact.revision", () => {
|
|
1417
|
+
expect(revisionAttributes({ status: { artifact: { revision: "main@sha1:1a2b3c4d" } } } as never)).toMatchObject({
|
|
1418
|
+
artifactRevision: "main@sha1:1a2b3c4d",
|
|
1419
|
+
});
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
test("says nothing for an object that carries none of them", () => {
|
|
1423
|
+
// Every field undefined, so `pruneUndefined` drops all three and the
|
|
1424
|
+
// attributes of a Deployment or a Service are byte-for-byte what they were.
|
|
1425
|
+
expect(revisionAttributes({ status: { readyReplicas: 1, replicas: 1 } } as never)).toEqual({
|
|
1426
|
+
lastAppliedRevision: undefined,
|
|
1427
|
+
lastAttemptedRevision: undefined,
|
|
1428
|
+
artifactRevision: undefined,
|
|
1429
|
+
});
|
|
1430
|
+
expect(revisionAttributes({} as never)).toEqual({
|
|
1431
|
+
lastAppliedRevision: undefined,
|
|
1432
|
+
lastAttemptedRevision: undefined,
|
|
1433
|
+
artifactRevision: undefined,
|
|
1434
|
+
});
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
test("ignores non-string and empty revisions", () => {
|
|
1438
|
+
expect(
|
|
1439
|
+
revisionAttributes({ status: { lastAppliedRevision: "", lastAttemptedRevision: 7, artifact: { revision: null } } } as never),
|
|
1440
|
+
).toEqual({ lastAppliedRevision: undefined, lastAttemptedRevision: undefined, artifactRevision: undefined });
|
|
1441
|
+
});
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
describe("observed attributes carry the Flux revisions (#1632)", () => {
|
|
1445
|
+
const applied = "main@sha1:1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b";
|
|
1446
|
+
const attempted = "main@sha1:5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d";
|
|
1447
|
+
|
|
1448
|
+
test("a Kustomization mid-rollout reports applied != attempted even with Ready=True", async () => {
|
|
1449
|
+
const kustomization = {
|
|
1450
|
+
apiVersion: "kustomize.toolkit.fluxcd.io/v1",
|
|
1451
|
+
kind: "Kustomization",
|
|
1452
|
+
metadata: { name: "apps", namespace: "flux-system", uid: "ks-uid", resourceVersion: "42" },
|
|
1453
|
+
status: {
|
|
1454
|
+
lastAppliedRevision: applied,
|
|
1455
|
+
lastAttemptedRevision: attempted,
|
|
1456
|
+
conditions: [{ type: "Ready", status: "True", reason: "ReconciliationSucceeded" }],
|
|
1457
|
+
},
|
|
1458
|
+
};
|
|
1459
|
+
const source = {
|
|
1460
|
+
apiVersion: "source.toolkit.fluxcd.io/v1",
|
|
1461
|
+
kind: "GitRepository",
|
|
1462
|
+
metadata: { name: "infra", namespace: "flux-system", uid: "gr-uid" },
|
|
1463
|
+
status: {
|
|
1464
|
+
artifact: { revision: attempted, path: "gitrepository/flux-system/infra/x.tar.gz" },
|
|
1465
|
+
conditions: [{ type: "Ready", status: "True" }],
|
|
1466
|
+
},
|
|
1467
|
+
};
|
|
1468
|
+
const cluster = fakeCluster({
|
|
1469
|
+
objects: {
|
|
1470
|
+
[objectKey("kustomize.toolkit.fluxcd.io/v1", "Kustomization", "apps", "flux-system")]: kustomization,
|
|
1471
|
+
[objectKey("source.toolkit.fluxcd.io/v1", "GitRepository", "infra", "flux-system")]: source,
|
|
1472
|
+
},
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
const result = await describeResources(
|
|
1476
|
+
{
|
|
1477
|
+
environment: "prod",
|
|
1478
|
+
buildOutput: "",
|
|
1479
|
+
entityNames: ["apps", "infra"],
|
|
1480
|
+
entities: makeEntities([
|
|
1481
|
+
{
|
|
1482
|
+
name: "apps",
|
|
1483
|
+
entityType: "K8s::Flux::Kustomization",
|
|
1484
|
+
props: { metadata: { name: "apps", namespace: "flux-system" } },
|
|
1485
|
+
},
|
|
1486
|
+
{
|
|
1487
|
+
name: "infra",
|
|
1488
|
+
entityType: "K8s::Flux::GitRepository",
|
|
1489
|
+
props: { metadata: { name: "infra", namespace: "flux-system" } },
|
|
1490
|
+
},
|
|
1491
|
+
]),
|
|
1492
|
+
},
|
|
1493
|
+
cluster.connector,
|
|
1494
|
+
);
|
|
1495
|
+
|
|
1496
|
+
// The per-node half: attempted is ahead of applied, so this object is
|
|
1497
|
+
// mid-flight however cheerful its Ready condition is.
|
|
1498
|
+
expect(result.resources.apps?.attributes).toMatchObject({
|
|
1499
|
+
lastAppliedRevision: applied,
|
|
1500
|
+
lastAttemptedRevision: attempted,
|
|
1501
|
+
});
|
|
1502
|
+
// The cross-node half: the source's artifact revision, which a consumer
|
|
1503
|
+
// joins against the Kustomization's applied revision.
|
|
1504
|
+
expect(result.resources.infra?.attributes).toMatchObject({ artifactRevision: attempted });
|
|
1505
|
+
expect(result.resources.apps?.status).toBe("READY");
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
test("a HelmRelease's revisions ride the same path, and other kinds are untouched", async () => {
|
|
1509
|
+
const helmRelease = {
|
|
1510
|
+
apiVersion: "helm.toolkit.fluxcd.io/v2",
|
|
1511
|
+
kind: "HelmRelease",
|
|
1512
|
+
metadata: { name: "podinfo", namespace: "prod", uid: "hr-uid" },
|
|
1513
|
+
status: { lastAppliedRevision: "6.5.4", lastAttemptedRevision: "6.5.4", conditions: [{ type: "Ready", status: "True" }] },
|
|
1514
|
+
};
|
|
1515
|
+
const cluster = fakeCluster({
|
|
1516
|
+
objects: {
|
|
1517
|
+
[objectKey("helm.toolkit.fluxcd.io/v2", "HelmRelease", "podinfo", "prod")]: helmRelease,
|
|
1518
|
+
[objectKey("apps/v1", "Deployment", "web", "prod")]: web,
|
|
1519
|
+
},
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
const result = await describeResources(
|
|
1523
|
+
{
|
|
1524
|
+
environment: "prod",
|
|
1525
|
+
buildOutput: "",
|
|
1526
|
+
entityNames: ["podinfo", "web"],
|
|
1527
|
+
entities: makeEntities([
|
|
1528
|
+
{
|
|
1529
|
+
name: "podinfo",
|
|
1530
|
+
entityType: "K8s::Flux::HelmRelease",
|
|
1531
|
+
props: { metadata: { name: "podinfo", namespace: "prod" } },
|
|
1532
|
+
},
|
|
1533
|
+
{ name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
|
|
1534
|
+
]),
|
|
1535
|
+
},
|
|
1536
|
+
cluster.connector,
|
|
1537
|
+
);
|
|
1538
|
+
|
|
1539
|
+
expect(result.resources.podinfo?.attributes).toMatchObject({
|
|
1540
|
+
lastAppliedRevision: "6.5.4",
|
|
1541
|
+
lastAttemptedRevision: "6.5.4",
|
|
1542
|
+
});
|
|
1543
|
+
// The string wire form for every other kind is what it was — no empty
|
|
1544
|
+
// revision keys appear on a Deployment (#1401's shape, unchanged).
|
|
1545
|
+
expect(result.resources.web?.attributes).toEqual({
|
|
1546
|
+
namespace: "prod",
|
|
1547
|
+
labels: { app: "web" },
|
|
1548
|
+
resourceVersion: "7",
|
|
1549
|
+
});
|
|
1550
|
+
});
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
// The behold#221 shape (#1629): the estate's control-plane project declares
|
|
1554
|
+
// the Kustomization carrying `spec.targetNamespace`, and the app project
|
|
1555
|
+
// declares bare objects the controller stamps at apply time. Reading the app
|
|
1556
|
+
// project alone, `metadata.namespace` is absent everywhere and the read
|
|
1557
|
+
// defaults to `default` — a running estate reported entirely absent. The
|
|
1558
|
+
// caller that knows the binding can now say where to look.
|
|
1559
|
+
describe("a per-read namespace override for entities that declare none (#1629)", () => {
|
|
1560
|
+
const inAppB = {
|
|
1561
|
+
apiVersion: "apps/v1",
|
|
1562
|
+
kind: "Deployment",
|
|
1563
|
+
metadata: { name: "web", namespace: "app-b", uid: "uid-b", resourceVersion: "11" },
|
|
1564
|
+
status: { readyReplicas: 1, replicas: 1 },
|
|
1565
|
+
};
|
|
1566
|
+
|
|
1567
|
+
test("replaces the `default` fallback for a namespaced entity that declares no namespace", async () => {
|
|
1568
|
+
const cluster = fakeCluster({ objects: { [objectKey("apps/v1", "Deployment", "web", "app-b")]: inAppB } });
|
|
1569
|
+
const result = await describeResources(
|
|
1570
|
+
{
|
|
1571
|
+
environment: "prod",
|
|
1572
|
+
buildOutput: "",
|
|
1573
|
+
entityNames: ["web"],
|
|
1574
|
+
entities: makeEntities([
|
|
1575
|
+
{ name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web" } } },
|
|
1576
|
+
]),
|
|
1577
|
+
namespace: "app-b",
|
|
1578
|
+
},
|
|
1579
|
+
cluster.connector,
|
|
1580
|
+
);
|
|
1581
|
+
|
|
1582
|
+
expect(result.resources.web).toMatchObject({ type: "K8s::Apps::Deployment", physicalId: "uid-b" });
|
|
1583
|
+
// The `queried` diagnosis line (#1620) reflects the overridden address —
|
|
1584
|
+
// whatever the read did, the report says where it looked.
|
|
1585
|
+
expect(result.queried?.web).toBe("/apis/apps/v1/namespaces/app-b/deployments/web");
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
test("an explicitly declared namespace still wins — the override is a default, not a rewrite", async () => {
|
|
1589
|
+
const cluster = fakeCluster({
|
|
1590
|
+
objects: {
|
|
1591
|
+
[objectKey("apps/v1", "Deployment", "web", "prod")]: web,
|
|
1592
|
+
[objectKey("apps/v1", "Deployment", "web", "app-b")]: inAppB,
|
|
1593
|
+
},
|
|
1594
|
+
});
|
|
1595
|
+
const result = await describeResources(
|
|
1596
|
+
{
|
|
1597
|
+
environment: "prod",
|
|
1598
|
+
buildOutput: "",
|
|
1599
|
+
entityNames: ["web"],
|
|
1600
|
+
entities: makeEntities([
|
|
1601
|
+
{ name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
|
|
1602
|
+
]),
|
|
1603
|
+
namespace: "app-b",
|
|
1604
|
+
},
|
|
1605
|
+
cluster.connector,
|
|
1606
|
+
);
|
|
1607
|
+
|
|
1608
|
+
// Both objects exist; the declared namespace decides which one was read.
|
|
1609
|
+
expect(result.resources.web).toMatchObject({ physicalId: "uid-1" });
|
|
1610
|
+
expect(result.queried?.web).toBe("/apis/apps/v1/namespaces/prod/deployments/web");
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
test("without the override the read still defaults to `default` — nothing changed for a caller that passes none", async () => {
|
|
1614
|
+
const cluster = fakeCluster({ objects: { [objectKey("apps/v1", "Deployment", "web", "app-b")]: inAppB } });
|
|
1615
|
+
const result = await describeResources(
|
|
1616
|
+
{
|
|
1617
|
+
environment: "prod",
|
|
1618
|
+
buildOutput: "",
|
|
1619
|
+
entityNames: ["web"],
|
|
1620
|
+
entities: makeEntities([
|
|
1621
|
+
{ name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web" } } },
|
|
1622
|
+
]),
|
|
1623
|
+
},
|
|
1624
|
+
cluster.connector,
|
|
1625
|
+
);
|
|
1626
|
+
|
|
1627
|
+
expect(result.resources).toEqual({});
|
|
1628
|
+
expect(result.queried?.web).toBe("/apis/apps/v1/namespaces/default/deployments/web");
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
test("a cluster-scoped kind is untouched — it has no namespace to default", async () => {
|
|
1632
|
+
const ns = { apiVersion: "v1", kind: "Namespace", metadata: { name: "app-b", uid: "ns-uid" } };
|
|
1633
|
+
const cluster = fakeCluster({ objects: { [objectKey("v1", "Namespace", "app-b")]: ns } });
|
|
1634
|
+
const result = await describeResources(
|
|
1635
|
+
{
|
|
1636
|
+
environment: "prod",
|
|
1637
|
+
buildOutput: "",
|
|
1638
|
+
entityNames: ["appB"],
|
|
1639
|
+
entities: makeEntities([
|
|
1640
|
+
{ name: "appB", entityType: "K8s::Core::Namespace", props: { metadata: { name: "app-b" } } },
|
|
1641
|
+
]),
|
|
1642
|
+
namespace: "app-b",
|
|
1643
|
+
},
|
|
1644
|
+
cluster.connector,
|
|
1645
|
+
);
|
|
1646
|
+
|
|
1647
|
+
expect(result.resources.appB).toMatchObject({ physicalId: "ns-uid" });
|
|
1648
|
+
expect(result.queried?.appB).toBe("/api/v1/namespaces/app-b");
|
|
1649
|
+
});
|
|
1650
|
+
|
|
1651
|
+
test("the override reaches every declared entity, not just the first", async () => {
|
|
1652
|
+
const svc = { apiVersion: "v1", kind: "Service", metadata: { name: "web-svc", namespace: "app-b", uid: "svc-b" } };
|
|
1653
|
+
const cluster = fakeCluster({
|
|
1654
|
+
objects: {
|
|
1655
|
+
[objectKey("apps/v1", "Deployment", "web", "app-b")]: inAppB,
|
|
1656
|
+
[objectKey("v1", "Service", "web-svc", "app-b")]: svc,
|
|
1657
|
+
},
|
|
1658
|
+
});
|
|
1659
|
+
const result = await describeResources(
|
|
1660
|
+
{
|
|
1661
|
+
environment: "prod",
|
|
1662
|
+
buildOutput: "",
|
|
1663
|
+
entityNames: ["web", "webSvc"],
|
|
1664
|
+
entities: makeEntities([
|
|
1665
|
+
{ name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web" } } },
|
|
1666
|
+
{ name: "webSvc", entityType: "K8s::Core::Service", props: { metadata: { name: "web-svc" } } },
|
|
1667
|
+
]),
|
|
1668
|
+
namespace: "app-b",
|
|
1669
|
+
},
|
|
1670
|
+
cluster.connector,
|
|
1671
|
+
);
|
|
1672
|
+
|
|
1673
|
+
expect(Object.keys(result.resources).sort()).toEqual(["web", "webSvc"]);
|
|
1674
|
+
});
|
|
1675
|
+
});
|
|
@@ -233,6 +233,45 @@ export function unhappyConditions(obj: K8sObject): string[] | undefined {
|
|
|
233
233
|
return out.length > 0 ? out : undefined;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
/**
|
|
237
|
+
* The revisions a GitOps controller writes onto its own object (#1632).
|
|
238
|
+
*
|
|
239
|
+
* Conditions lag. A Flux Kustomization mid-rollout still reports the previous
|
|
240
|
+
* reconcile's `Ready=True` for as long as the new one takes, so a consumer
|
|
241
|
+
* reading only conditions calls a half-applied estate converged. The
|
|
242
|
+
* controller's own definition of converged is a revision comparison:
|
|
243
|
+
* `lastAppliedRevision` against `lastAttemptedRevision` on the same object
|
|
244
|
+
* (attempted-but-not-applied is in flight), and a Kustomization's
|
|
245
|
+
* `lastAppliedRevision` against its source's `artifact.revision` (the
|
|
246
|
+
* cross-object half, which a consumer joins once both ends are on the wire).
|
|
247
|
+
* None of the three reached an observed entity's attributes before this.
|
|
248
|
+
*
|
|
249
|
+
* Read by path rather than by kind, which is the module's standing rule
|
|
250
|
+
* everywhere else: `status.lastAppliedRevision` / `status.lastAttemptedRevision`
|
|
251
|
+
* are what kustomize-controller and helm-controller write, and
|
|
252
|
+
* `status.artifact.revision` is what every source kind (GitRepository,
|
|
253
|
+
* OCIRepository, Bucket, HelmChart) writes. An object that has none of them —
|
|
254
|
+
* every non-GitOps kind — contributes nothing, so no kind table has to name
|
|
255
|
+
* which kinds those are, and a CRD speaking the same convention is carried for
|
|
256
|
+
* free. `artifactRevision` flattens the one nested path so every revision on
|
|
257
|
+
* the wire is a plain string next to the others.
|
|
258
|
+
*/
|
|
259
|
+
export function revisionAttributes(obj: K8sObject): {
|
|
260
|
+
lastAppliedRevision?: string;
|
|
261
|
+
lastAttemptedRevision?: string;
|
|
262
|
+
artifactRevision?: string;
|
|
263
|
+
} {
|
|
264
|
+
const status = obj.status as
|
|
265
|
+
| { lastAppliedRevision?: unknown; lastAttemptedRevision?: unknown; artifact?: { revision?: unknown } }
|
|
266
|
+
| undefined;
|
|
267
|
+
const str = (v: unknown): string | undefined => (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
|
+
|
|
236
275
|
interface Declared {
|
|
237
276
|
entityName: string;
|
|
238
277
|
entityType: string;
|
|
@@ -247,6 +286,25 @@ export interface DescribeResourcesOptions {
|
|
|
247
286
|
owned?: boolean;
|
|
248
287
|
/** Directory whose `chant.config.ts` carries the cluster binding. Defaults to cwd. */
|
|
249
288
|
cwd?: string;
|
|
289
|
+
/**
|
|
290
|
+
* Namespace to read a namespaced entity from when it declares none (#1629).
|
|
291
|
+
*
|
|
292
|
+
* A GitOps estate splits the binding from the objects: the control-plane
|
|
293
|
+
* project declares a Kustomization with `spec.targetNamespace: app-b`, the
|
|
294
|
+
* app project declares bare Deployments and Services with no
|
|
295
|
+
* `metadata.namespace` at all — the controller stamps it at apply time. The
|
|
296
|
+
* live read of the app project had no way to be told that, so it fell
|
|
297
|
+
* through to the client's `default` and reported an estate that is running
|
|
298
|
+
* as entirely absent (#1620's `queried` line is what made that visible).
|
|
299
|
+
*
|
|
300
|
+
* A DEFAULT, never a rewrite: an entity with an explicit
|
|
301
|
+
* `metadata.namespace` reads from the namespace it declares, override or
|
|
302
|
+
* not — that declaration is the estate's own statement of where the object
|
|
303
|
+
* lives, and overruling it would make the option a way to read the wrong
|
|
304
|
+
* object rather than the right one. Cluster-scoped kinds are untouched:
|
|
305
|
+
* they have no namespace to default.
|
|
306
|
+
*/
|
|
307
|
+
namespace?: string;
|
|
250
308
|
}
|
|
251
309
|
|
|
252
310
|
export async function describeResources(
|
|
@@ -331,11 +389,17 @@ export async function describeResources(
|
|
|
331
389
|
return;
|
|
332
390
|
}
|
|
333
391
|
|
|
392
|
+
// The declared namespace wins; the caller's override only fills the hole a
|
|
393
|
+
// declaration left, and only for a kind that HAS a namespace (#1629). With
|
|
394
|
+
// neither, the client defaults to `default` exactly as it did.
|
|
395
|
+
const namespace =
|
|
396
|
+
metadata?.namespace ?? (operation.scope === "Namespaced" ? options.namespace : undefined);
|
|
397
|
+
|
|
334
398
|
const ref = {
|
|
335
399
|
apiVersion: operation.apiVersion,
|
|
336
400
|
kind: operation.kind,
|
|
337
401
|
name,
|
|
338
|
-
...(
|
|
402
|
+
...(namespace ? { namespace } : {}),
|
|
339
403
|
};
|
|
340
404
|
|
|
341
405
|
// Resolve the address BEFORE the read, so every verdict below — present,
|
|
@@ -379,6 +443,9 @@ export async function describeResources(
|
|
|
379
443
|
resourceVersion: obj.metadata?.resourceVersion,
|
|
380
444
|
// What the object's own controller says is wrong with it (#1401).
|
|
381
445
|
conditions: unhappyConditions(obj),
|
|
446
|
+
// What a GitOps controller says it has applied and is applying
|
|
447
|
+
// (#1632) — the convergence signal conditions cannot carry.
|
|
448
|
+
...revisionAttributes(obj),
|
|
382
449
|
}),
|
|
383
450
|
};
|
|
384
451
|
} catch (err) {
|