@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;CACd;AAED,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,wBAAwB,EACjC,OAAO,GAAE,YAAkC,GAC1C,OAAO,CAAC,iBAAiB,CAAC,CAoJ5B"}
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"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "994f303cfb6edefd9d0eb5d18406f784172b3fb0bd5b524e7e3255a96f4bf338",
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": "815ab02fe7f7325c6fbec07be9f9f8423df8d9771ddd23babef1af5ae60c0acd"
57
+ "composite": "3e4f2e1e01baea8c1204bde8b257bbab0c1508eb1a1718f883a93b664ffc202f"
58
58
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "k8s",
3
- "version": "0.44.4",
3
+ "version": "0.44.6",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "K8s",
6
6
  "intrinsics": [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-k8s",
3
- "version": "0.44.4",
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.4"
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.4",
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
- ...(metadata?.namespace ? { namespace: metadata.namespace } : {}),
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) {