@intentius/chant-lexicon-azure 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,12 +8,27 @@
8
8
  * (using props.name from #39's entity-prop pass-through). The environment
9
9
  * argument is treated as the Azure resource group name.
10
10
  *
11
- * Resource-not-found is silent — `state diff --live` then reports as missing.
12
- * Nested resource types (e.g. `Microsoft.Storage/storageAccounts/blobServices`)
13
- * are warn-skipped since `az resource show` doesn't accept them directly; they
14
- * need a different query path.
11
+ * Resource-not-found is an absence — `state diff --live` then reports it as
12
+ * missing. Nested resource types (e.g.
13
+ * `Microsoft.Storage/storageAccounts/blobServices`) are NOT-OBSERVED
14
+ * (`unsupported-kind`, #1089): `az resource show` doesn't accept a compound
15
+ * type, so chant never asks, and a blob service that already exists must not
16
+ * come back as a proposed `create`. Auth failures and unreachable subscriptions
17
+ * are holes for the same reason.
15
18
  */
16
- import type { ResourceMetadata } from "@intentius/chant/lexicon";
19
+ import type { ObservationResult, UnobservedReason } from "@intentius/chant/lexicon";
20
+ /**
21
+ * Classify an `az resource show` failure (#1089). Only the CLI's explicit
22
+ * not-found establishes absence; everything else — an expired login, a
23
+ * subscription that can't be resolved, a network failure — proves nothing.
24
+ */
25
+ export declare function classifyAzFailure(err: unknown): {
26
+ absent: true;
27
+ } | {
28
+ absent: false;
29
+ reason: UnobservedReason;
30
+ detail: string;
31
+ };
17
32
  export declare function describeResources(options: {
18
33
  environment: string;
19
34
  buildOutput: string;
@@ -22,5 +37,5 @@ export declare function describeResources(options: {
22
37
  entityType: string;
23
38
  props: Record<string, unknown>;
24
39
  }>;
25
- }): Promise<Record<string, ResourceMetadata>>;
40
+ }): Promise<ObservationResult>;
26
41
  //# 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;;;;;;;;;;;;;;GAcG;AAIH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAkCjE,wBAAsB,iBAAiB,CAAC,OAAO,EAAE;IAC/C,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;CAC/E,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAgD5C"}
1
+ {"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAIH,OAAO,KAAK,EAAE,iBAAiB,EAAsC,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAmCxH;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG;IAAE,MAAM,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,gBAAgB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAkC9H;AAED,wBAAsB,iBAAiB,CAAC,OAAO,EAAE;IAC/C,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;CAC/E,GAAG,OAAO,CAAC,iBAAiB,CAAC,CA2E7B"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "81cfbd61238ba8f1a39800628a3deb9d6762b463e84fd6b02b03a72839b492ba",
4
+ "manifest.json": "50025ac710a4bd52992e4a6b196a56d2ff14735a095dd7f72cf8bcb389b761ea",
5
5
  "meta.json": "051fb4ee43f7de48a36f1abc59c8ff5207c3b75aa28f13db6440b9f5b0337605",
6
6
  "types/index.d.ts": "617a6de433bd92a126170a7f5ae903d6d7aa83fbb0b57169d3450b5a13e72e0d",
7
7
  "rules/hardcoded-location.ts": "a9b1d1cec93f2ca9c5206641f53fc9621cefcfd2c00b3ab89c194b30a75fa949",
@@ -33,5 +33,5 @@
33
33
  "skills/chant-azure-patterns.md": "1a3bacfac612826b77332d2692a59195638db9f1b5e8ea2eda7579621d25f603",
34
34
  "skills/chant-azure-aks.md": "2d4e0098c1a22b54ffadd410564d9df0f3a04cb4eb7c6261f20ae33106e7aca8"
35
35
  },
36
- "composite": "9b71ffb460db8547a31947c1b50460dd28971b2758c282498e3c11ec8772baca"
36
+ "composite": "923cf13c8ea5dfb598f2aecae0e957d42e977bc511747871532644ab9230d8ed"
37
37
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azure",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Azure",
6
6
  "intrinsics": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-azure",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "Azure lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -74,7 +74,7 @@
74
74
  "typescript": "^5.9.3"
75
75
  },
76
76
  "peerDependencies": {
77
- "@intentius/chant": "^0.28.0",
77
+ "@intentius/chant": "^0.30.0",
78
78
  "typescript": "^5.9.3"
79
79
  }
80
80
  }
@@ -50,7 +50,7 @@ describe("azure describeResources", () => {
50
50
  expect(receivedCmd).toContain("--name mydata");
51
51
  expect(receivedCmd).toContain("--resource-type Microsoft.Storage/storageAccounts");
52
52
 
53
- expect(result["dataAccount"]).toMatchObject({
53
+ expect(result.resources["dataAccount"]).toMatchObject({
54
54
  type: "Microsoft.Storage/storageAccounts",
55
55
  physicalId: "/subscriptions/sub/resourceGroups/prod-rg/providers/Microsoft.Storage/storageAccounts/mydata",
56
56
  status: "Succeeded",
@@ -70,10 +70,10 @@ describe("azure describeResources", () => {
70
70
 
71
71
  const result = await describeResources({ environment: "prod-rg", buildOutput: "", entityNames: ["vnet"], entities });
72
72
 
73
- expect(result["vnet"].status).toBe("PRESENT");
73
+ expect(result.resources["vnet"].status).toBe("PRESENT");
74
74
  });
75
75
 
76
- test("az failure (resource not found) leaves entity out", async () => {
76
+ test("az failure (resource not found) leaves entity out — a confirmed absence", async () => {
77
77
  execMock.mockImplementation(() => { throw new Error("ResourceNotFound: ..."); });
78
78
 
79
79
  const entities = makeEntities([
@@ -82,10 +82,28 @@ describe("azure describeResources", () => {
82
82
 
83
83
  const result = await describeResources({ environment: "prod-rg", buildOutput: "", entityNames: ["missing"], entities });
84
84
 
85
- expect(result).toEqual({});
85
+ expect(result.resources).toEqual({});
86
+ expect(result.unobserved ?? {}).toEqual({});
86
87
  });
87
88
 
88
- test("nested-type entities are warn-skipped", async () => {
89
+ test("an expired az login is unobserved, not absent (#1089)", async () => {
90
+ execMock.mockImplementation(() => {
91
+ throw Object.assign(new Error("az failed"), {
92
+ stderr: "Please run 'az login' to setup account.",
93
+ });
94
+ });
95
+
96
+ const entities = makeEntities([
97
+ { name: "acct", entityType: "Microsoft.Storage/storageAccounts", props: { name: "acct" } },
98
+ ]);
99
+
100
+ const result = await describeResources({ environment: "prod-rg", buildOutput: "", entityNames: ["acct"], entities });
101
+
102
+ expect(result.resources).toEqual({});
103
+ expect(result.unobserved?.acct?.reason).toBe("no-credentials");
104
+ });
105
+
106
+ test("nested-type entities are unobserved, not absent (#1089)", async () => {
89
107
  const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
90
108
  const entities = makeEntities([
91
109
  { name: "nested", entityType: "Microsoft.Storage/storageAccounts/blobServices", props: { name: "x" } },
@@ -93,31 +111,37 @@ describe("azure describeResources", () => {
93
111
 
94
112
  const result = await describeResources({ environment: "prod-rg", buildOutput: "", entityNames: ["nested"], entities });
95
113
 
96
- expect(result).toEqual({});
114
+ expect(result.resources).toEqual({});
115
+ expect(result.unobserved?.nested).toMatchObject({
116
+ type: "Microsoft.Storage/storageAccounts/blobServices",
117
+ reason: "unsupported-kind",
118
+ });
97
119
  expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("nested-type"));
98
120
  expect(execMock).not.toHaveBeenCalled();
99
121
  warnSpy.mockRestore();
100
122
  });
101
123
 
102
- test("non-Azure entity types are skipped", async () => {
124
+ test("non-Azure entity types are unobserved — no ARM type to query", async () => {
103
125
  const entities = makeEntities([
104
126
  { name: "x", entityType: "AWS::S3::Bucket", props: { name: "x" } },
105
127
  ]);
106
128
 
107
129
  const result = await describeResources({ environment: "prod-rg", buildOutput: "", entityNames: ["x"], entities });
108
130
 
109
- expect(result).toEqual({});
131
+ expect(result.resources).toEqual({});
132
+ expect(result.unobserved?.x?.reason).toBe("unsupported-kind");
110
133
  expect(execMock).not.toHaveBeenCalled();
111
134
  });
112
135
 
113
- test("entity without name is silently skipped", async () => {
136
+ test("entity without name is unobserved — nothing was queried", async () => {
114
137
  const entities = makeEntities([
115
138
  { name: "broken", entityType: "Microsoft.Storage/storageAccounts", props: {} },
116
139
  ]);
117
140
 
118
141
  const result = await describeResources({ environment: "prod-rg", buildOutput: "", entityNames: ["broken"], entities });
119
142
 
120
- expect(result).toEqual({});
143
+ expect(result.resources).toEqual({});
144
+ expect(result.unobserved?.broken?.reason).toBe("read-failed");
121
145
  expect(execMock).not.toHaveBeenCalled();
122
146
  });
123
147
  });
@@ -8,15 +8,19 @@
8
8
  * (using props.name from #39's entity-prop pass-through). The environment
9
9
  * argument is treated as the Azure resource group name.
10
10
  *
11
- * Resource-not-found is silent — `state diff --live` then reports as missing.
12
- * Nested resource types (e.g. `Microsoft.Storage/storageAccounts/blobServices`)
13
- * are warn-skipped since `az resource show` doesn't accept them directly; they
14
- * need a different query path.
11
+ * Resource-not-found is an absence — `state diff --live` then reports it as
12
+ * missing. Nested resource types (e.g.
13
+ * `Microsoft.Storage/storageAccounts/blobServices`) are NOT-OBSERVED
14
+ * (`unsupported-kind`, #1089): `az resource show` doesn't accept a compound
15
+ * type, so chant never asks, and a blob service that already exists must not
16
+ * come back as a proposed `create`. Auth failures and unreachable subscriptions
17
+ * are holes for the same reason.
15
18
  */
16
19
 
17
20
  import { exec } from "node:child_process";
18
21
  import { promisify } from "node:util";
19
- import type { ResourceMetadata } from "@intentius/chant/lexicon";
22
+ import type { ObservationResult, ResourceMetadata, UnobservedEntity, UnobservedReason } from "@intentius/chant/lexicon";
23
+ import { observation } from "@intentius/chant/observation";
20
24
 
21
25
  const execAsync = promisify(exec);
22
26
 
@@ -50,25 +54,88 @@ function isTopLevelType(entityType: string): boolean {
50
54
  return slashCount === 1;
51
55
  }
52
56
 
57
+ /**
58
+ * Classify an `az resource show` failure (#1089). Only the CLI's explicit
59
+ * not-found establishes absence; everything else — an expired login, a
60
+ * subscription that can't be resolved, a network failure — proves nothing.
61
+ */
62
+ export function classifyAzFailure(err: unknown): { absent: true } | { absent: false; reason: UnobservedReason; detail: string } {
63
+ const raw =
64
+ typeof err === "object" && err !== null && typeof (err as { stderr?: unknown }).stderr === "string"
65
+ ? ((err as { stderr: string }).stderr.trim() || String((err as { message?: unknown }).message ?? err))
66
+ : String((err as { message?: unknown } | undefined)?.message ?? err);
67
+ const lower = raw.toLowerCase();
68
+ const detail = (raw.split("\n").find((l) => l.trim().length > 0) ?? raw).trim().slice(0, 200);
69
+
70
+ if (
71
+ lower.includes("resourcenotfound") ||
72
+ lower.includes("was not found") ||
73
+ lower.includes("could not be found") ||
74
+ lower.includes("does not exist")
75
+ ) {
76
+ return { absent: true };
77
+ }
78
+ if (
79
+ lower.includes("please run 'az login'") ||
80
+ lower.includes("az login") ||
81
+ lower.includes("authenticationfailed") ||
82
+ lower.includes("expired") ||
83
+ lower.includes("authorizationfailed") ||
84
+ lower.includes("forbidden")
85
+ ) {
86
+ return { absent: false, reason: "no-credentials", detail };
87
+ }
88
+ if (
89
+ lower.includes("subscriptionnotfound") ||
90
+ lower.includes("no subscription") ||
91
+ lower.includes("please run 'az account set'")
92
+ ) {
93
+ return { absent: false, reason: "no-binding", detail };
94
+ }
95
+ return { absent: false, reason: "read-failed", detail };
96
+ }
97
+
53
98
  export async function describeResources(options: {
54
99
  environment: string;
55
100
  buildOutput: string;
56
101
  entityNames: string[];
57
102
  entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
58
- }): Promise<Record<string, ResourceMetadata>> {
103
+ }): Promise<ObservationResult> {
59
104
  const result: Record<string, ResourceMetadata> = {};
105
+ const unobserved: Record<string, UnobservedEntity> = {};
60
106
  const skippedNested: string[] = [];
61
107
 
62
108
  for (const [entityName, { entityType, props }] of options.entities) {
63
- if (!entityType.startsWith("Microsoft.")) continue;
109
+ if (!entityType.startsWith("Microsoft.")) {
110
+ // Not an ARM resource type, so `az resource show` has nothing to ask for.
111
+ // Unobserved rather than skipped: a silent skip reads as absence (#1089).
112
+ unobserved[entityName] = {
113
+ type: entityType,
114
+ reason: "unsupported-kind",
115
+ detail: "not an ARM resource type (expected Microsoft.<provider>/<kind>)",
116
+ };
117
+ continue;
118
+ }
64
119
 
65
120
  if (!isTopLevelType(entityType)) {
66
121
  skippedNested.push(entityName);
122
+ unobserved[entityName] = {
123
+ type: entityType,
124
+ reason: "unsupported-kind",
125
+ detail: "az resource show does not accept a nested ARM type; chant never queried this resource",
126
+ };
67
127
  continue;
68
128
  }
69
129
 
70
130
  const name = props.name as string | undefined;
71
- if (!name) continue;
131
+ if (!name) {
132
+ unobserved[entityName] = {
133
+ type: entityType,
134
+ reason: "read-failed",
135
+ detail: "declared entity has no name to query by",
136
+ };
137
+ continue;
138
+ }
72
139
 
73
140
  const cmd = [
74
141
  "az", "resource", "show",
@@ -90,17 +157,22 @@ export async function describeResources(options: {
90
157
  tags: obj.tags,
91
158
  }),
92
159
  };
93
- } catch {
94
- // az returned non-zero (not found, auth, etc.) leave entity out
160
+ } catch (err) {
161
+ // Not-found leaves the entity out (absence). Auth/binding/other failures
162
+ // are recorded as holes so they can't become creates (#1089).
163
+ const outcome = classifyAzFailure(err);
164
+ if (!outcome.absent) {
165
+ unobserved[entityName] = { type: entityType, reason: outcome.reason, detail: outcome.detail };
166
+ }
95
167
  }
96
168
  }
97
169
 
98
170
  if (skippedNested.length > 0) {
99
171
  // eslint-disable-next-line no-console
100
172
  console.warn(
101
- `[azure] skipped ${skippedNested.length} nested-type entity(ies) — az resource show doesn't accept compound types: ${skippedNested.slice(0, 5).join(", ")}${skippedNested.length > 5 ? ", ..." : ""}`,
173
+ `[azure] ${skippedNested.length} nested-type entity(ies) reported as unobserved (not absent) — az resource show doesn't accept compound types: ${skippedNested.slice(0, 5).join(", ")}${skippedNested.length > 5 ? ", ..." : ""}`,
102
174
  );
103
175
  }
104
176
 
105
- return result;
177
+ return observation(result, unobserved);
106
178
  }
@@ -31,6 +31,8 @@ vi.mock("node:child_process", async () => {
31
31
  const { azurePlugin } = await import("./plugin");
32
32
  const { liveImportFromPlugins } = await import("@intentius/chant/cli/commands/import");
33
33
  const { buildChangeSet } = await import("@intentius/chant/lifecycle/change-set");
34
+ const { normalizeObservation } = await import("@intentius/chant/observation");
35
+ const { describeObservationConformance } = await import("@intentius/chant-test-utils");
34
36
 
35
37
  const liveTemplate = {
36
38
  $schema:
@@ -85,14 +87,16 @@ describe("azure lifecycle integration (#163)", () => {
85
87
  : new Error("unexpected"),
86
88
  );
87
89
 
88
- const observedNow = await azurePlugin.describeResources!({
89
- environment: "prod",
90
- buildOutput: "",
91
- entityNames: ["myStore"],
92
- entities: new Map([
93
- ["myStore", { entityType: "Microsoft.Storage/storageAccounts", props: { name: "mystore" } }],
94
- ]),
95
- });
90
+ const { resources: observedNow } = normalizeObservation(
91
+ await azurePlugin.describeResources!({
92
+ environment: "prod",
93
+ buildOutput: "",
94
+ entityNames: ["myStore"],
95
+ entities: new Map([
96
+ ["myStore", { entityType: "Microsoft.Storage/storageAccounts", props: { name: "mystore" } }],
97
+ ]),
98
+ }),
99
+ );
96
100
  expect(observedNow.myStore?.type).toBe("Microsoft.Storage/storageAccounts");
97
101
 
98
102
  const cs = buildChangeSet("prod", {
@@ -112,3 +116,46 @@ describe("azure lifecycle integration (#163)", () => {
112
116
  expect(cs2.entries.find((e) => e.name === "myStore")!.action).toBe("noop");
113
117
  });
114
118
  });
119
+
120
+ // The shared conformance suite (#1089).
121
+ describeObservationConformance({
122
+ lexicon: "azure",
123
+ scenarios: [
124
+ {
125
+ name: "a nested ARM type az resource show cannot query",
126
+ declared: ["blobSvc", "gone"],
127
+ expectUnobserved: ["blobSvc"],
128
+ expectAbsent: ["gone"],
129
+ run: () => {
130
+ execMock.mockImplementation(() => new Error("ResourceNotFound: gone"));
131
+ return azurePlugin.describeResources!({
132
+ environment: "prod",
133
+ buildOutput: "",
134
+ entityNames: ["blobSvc", "gone"],
135
+ entities: new Map([
136
+ ["blobSvc", { entityType: "Microsoft.Storage/storageAccounts/blobServices", props: { name: "blob" } }],
137
+ ["gone", { entityType: "Microsoft.Storage/storageAccounts", props: { name: "gone" } }],
138
+ ]),
139
+ });
140
+ },
141
+ },
142
+ {
143
+ name: "an expired login",
144
+ declared: ["myStore"],
145
+ expectUnobserved: ["myStore"],
146
+ run: () => {
147
+ execMock.mockImplementation(() =>
148
+ Object.assign(new Error("az failed"), { stderr: "Please run 'az login' to setup account." }),
149
+ );
150
+ return azurePlugin.describeResources!({
151
+ environment: "prod",
152
+ buildOutput: "",
153
+ entityNames: ["myStore"],
154
+ entities: new Map([
155
+ ["myStore", { entityType: "Microsoft.Storage/storageAccounts", props: { name: "mystore" } }],
156
+ ]),
157
+ });
158
+ },
159
+ },
160
+ ],
161
+ });