@intentius/chant-lexicon-azure 0.124.31 → 0.124.33

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.
Files changed (144) hide show
  1. package/dist/actions/appservice.js +7 -0
  2. package/dist/actions/compute.js +9 -0
  3. package/dist/actions/container.js +14 -0
  4. package/dist/actions/identity.js +11 -0
  5. package/dist/actions/index.js +11 -0
  6. package/dist/actions/keyvault.js +12 -0
  7. package/dist/actions/network.js +9 -0
  8. package/dist/actions/sql.js +9 -0
  9. package/dist/actions/storage.js +15 -0
  10. package/dist/api/read-client.js +146 -0
  11. package/dist/codegen/docs-cli.js +2 -0
  12. package/dist/codegen/docs.js +83 -0
  13. package/dist/codegen/generate-cli.js +11 -0
  14. package/dist/codegen/generate-lexicon.js +77 -0
  15. package/dist/codegen/generate-typescript.js +163 -0
  16. package/dist/codegen/generate.js +106 -0
  17. package/dist/codegen/naming.js +139 -0
  18. package/dist/codegen/package.js +64 -0
  19. package/dist/composites/aks-cluster.js +68 -0
  20. package/dist/composites/app-service.js +62 -0
  21. package/dist/composites/application-gateway.js +107 -0
  22. package/dist/composites/catalog.js +856 -0
  23. package/dist/composites/container-instance.js +36 -0
  24. package/dist/composites/container-registry.js +62 -0
  25. package/dist/composites/cosmos-database.js +56 -0
  26. package/dist/composites/from-arm.js +47 -0
  27. package/dist/composites/function-app.js +57 -0
  28. package/dist/composites/index.js +16 -0
  29. package/dist/composites/keyvault.js +69 -0
  30. package/dist/composites/landing-zone.js +119 -0
  31. package/dist/composites/private-endpoint.js +61 -0
  32. package/dist/composites/redis-cache.js +21 -0
  33. package/dist/composites/service-bus-pipeline.js +54 -0
  34. package/dist/composites/sql-database.js +62 -0
  35. package/dist/composites/storage-account.js +59 -0
  36. package/dist/composites/vm-linux.js +139 -0
  37. package/dist/composites/vnet-default.js +102 -0
  38. package/dist/coverage.js +28 -0
  39. package/dist/deep-observe.js +356 -0
  40. package/dist/default-tags.js +50 -0
  41. package/dist/deploy-scopes.d.ts.map +1 -1
  42. package/dist/deploy-scopes.js +157 -0
  43. package/dist/describe-resources.js +187 -0
  44. package/dist/detect.js +17 -0
  45. package/dist/export-resources.js +108 -0
  46. package/dist/generated/index.d.ts.map +1 -1
  47. package/dist/generated/index.js +16598 -0
  48. package/dist/generated/runtime.js +4 -0
  49. package/dist/governance.js +144 -0
  50. package/dist/health.d.ts +42 -0
  51. package/dist/health.d.ts.map +1 -0
  52. package/dist/health.js +137 -0
  53. package/dist/import/generator.js +140 -0
  54. package/dist/import/live-export.js +39 -0
  55. package/dist/import/parser.js +192 -0
  56. package/dist/index.js +44 -0
  57. package/dist/integrity.json +5 -5
  58. package/dist/intrinsics.js +179 -0
  59. package/dist/lint/audit-catalog.js +36 -0
  60. package/dist/lint/audit-lineage.js +71 -0
  61. package/dist/lint/post-synth/arm-refs.d.ts +1 -0
  62. package/dist/lint/post-synth/arm-refs.d.ts.map +1 -1
  63. package/dist/lint/post-synth/arm-refs.js +96 -0
  64. package/dist/lint/post-synth/azr010.js +45 -0
  65. package/dist/lint/post-synth/azr011.js +43 -0
  66. package/dist/lint/post-synth/azr012.js +41 -0
  67. package/dist/lint/post-synth/azr013.js +50 -0
  68. package/dist/lint/post-synth/azr014.js +49 -0
  69. package/dist/lint/post-synth/azr015.js +66 -0
  70. package/dist/lint/post-synth/azr016.js +37 -0
  71. package/dist/lint/post-synth/azr017.js +37 -0
  72. package/dist/lint/post-synth/azr018.js +42 -0
  73. package/dist/lint/post-synth/azr019.js +41 -0
  74. package/dist/lint/post-synth/azr020.js +37 -0
  75. package/dist/lint/post-synth/azr021.js +36 -0
  76. package/dist/lint/post-synth/azr022.js +39 -0
  77. package/dist/lint/post-synth/azr023.js +39 -0
  78. package/dist/lint/post-synth/azr024.js +39 -0
  79. package/dist/lint/post-synth/azr025.js +37 -0
  80. package/dist/lint/post-synth/azr026.js +38 -0
  81. package/dist/lint/post-synth/azr027.js +37 -0
  82. package/dist/lint/post-synth/azr028.js +37 -0
  83. package/dist/lint/post-synth/azr029.js +38 -0
  84. package/dist/lint/post-synth/azr030.js +44 -0
  85. package/dist/lint/post-synth/azr031.js +36 -0
  86. package/dist/lint/post-synth/azr032.js +53 -0
  87. package/dist/lint/post-synth/azr033.js +35 -0
  88. package/dist/lint/post-synth/index.js +50 -0
  89. package/dist/lint/rules/hardcoded-location.js +82 -0
  90. package/dist/lint/rules/index.js +6 -0
  91. package/dist/lint/rules/nsg-wildcard.js +44 -0
  92. package/dist/lint/rules/storage-https.js +62 -0
  93. package/dist/lsp/completions.d.ts.map +1 -1
  94. package/dist/lsp/completions.js +18 -0
  95. package/dist/lsp/hover.d.ts.map +1 -1
  96. package/dist/lsp/hover.js +44 -0
  97. package/dist/manifest.json +1 -1
  98. package/dist/meta.json +6 -1
  99. package/dist/okf/types/containerApps.md +5 -0
  100. package/dist/op/activities/az-apply.js +563 -0
  101. package/dist/op/activities/azure.js +48 -0
  102. package/dist/op/activities/floci-az.js +37 -0
  103. package/dist/op/activities/index.js +10 -0
  104. package/dist/op/builders.js +70 -0
  105. package/dist/ownership.js +13 -0
  106. package/dist/package-cli.js +21 -0
  107. package/dist/parameter.js +13 -0
  108. package/dist/plugin.d.ts.map +1 -1
  109. package/dist/plugin.js +478 -0
  110. package/dist/pseudo.js +40 -0
  111. package/dist/rules/arm-refs.ts +5 -3
  112. package/dist/serializer.d.ts +7 -0
  113. package/dist/serializer.d.ts.map +1 -1
  114. package/dist/serializer.js +543 -0
  115. package/dist/spec/api-versions.js +130 -0
  116. package/dist/spec/fetch.js +196 -0
  117. package/dist/spec/parse.d.ts.map +1 -1
  118. package/dist/spec/parse.js +318 -0
  119. package/dist/taggable.js +34 -0
  120. package/dist/types/index.d.ts +5 -0
  121. package/dist/validate-cli.js +16 -0
  122. package/dist/validate.js +53 -0
  123. package/package.json +8 -8
  124. package/src/attributes.test.ts +17 -0
  125. package/src/codegen/generate-typescript.ts +1 -1
  126. package/src/codegen/generate.ts +1 -1
  127. package/src/codegen/package.ts +2 -2
  128. package/src/deploy-scopes.ts +2 -1
  129. package/src/generated/index.d.ts +5 -0
  130. package/src/generated/index.ts +1 -1
  131. package/src/generated/lexicon-azure.json +6 -1
  132. package/src/health.test.ts +66 -0
  133. package/src/health.ts +162 -0
  134. package/src/lint/post-synth/arm-refs.ts +5 -3
  135. package/src/lint/post-synth/post-synth.test.ts +1 -1
  136. package/src/lsp/completions.ts +2 -1
  137. package/src/lsp/hover.ts +2 -1
  138. package/src/plugin.ts +11 -2
  139. package/src/serializer-names.test.ts +83 -0
  140. package/src/serializer-outputs.test.ts +65 -0
  141. package/src/serializer.ts +112 -30
  142. package/src/spec/parse.test.ts +15 -0
  143. package/src/spec/parse.ts +10 -0
  144. package/src/taggable.ts +2 -2
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Shared taggable resource lookup — loads from the generated lexicon JSON.
3
+ *
4
+ * Lazy-loaded and cached for the lifetime of the process.
5
+ */
6
+ import { readFileSync } from "fs";
7
+ import { join } from "path";
8
+ import { moduleSourceDir } from "@intentius/chant/module-source";
9
+ let _cached;
10
+ const __dirname_ = moduleSourceDir(import.meta.url);
11
+ /**
12
+ * Load taggable resource types from the lexicon JSON.
13
+ * Result is cached after first call.
14
+ */
15
+ export function loadTaggableResources() {
16
+ if (_cached)
17
+ return _cached;
18
+ const set = new Set();
19
+ try {
20
+ const lexiconPath = join(__dirname_, "generated", "lexicon-azure.json");
21
+ const content = readFileSync(lexiconPath, "utf-8");
22
+ const data = JSON.parse(content);
23
+ for (const [_name, entry] of Object.entries(data)) {
24
+ if (entry.kind === "resource" && entry.resourceType && entry.tagging?.taggable) {
25
+ set.add(entry.resourceType);
26
+ }
27
+ }
28
+ }
29
+ catch {
30
+ // Lexicon not available — skip
31
+ }
32
+ _cached = set;
33
+ return set;
34
+ }
@@ -4232,7 +4232,12 @@ export declare class containerApps {
4232
4232
  /** Resource tags. */
4233
4233
  tags?: Record<string, unknown>;
4234
4234
  }, attributes?: ArmResourceAttributes);
4235
+ readonly configuration_ingress_fqdn: string;
4235
4236
  readonly id: string;
4237
+ readonly latestRevisionFqdn: string;
4238
+ readonly latestRevisionName: string;
4239
+ readonly outboundIpAddresses: string[];
4240
+ readonly provisioningState: string;
4236
4241
  }
4237
4242
 
4238
4243
  export declare class containerApps_authConfigs {
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Thin entry point for `npm run validate` in lexicon-azure.
4
+ */
5
+ import { validate } from "./validate.js";
6
+ const result = await validate();
7
+ for (const check of result.checks) {
8
+ const status = check.ok ? "OK" : "FAIL";
9
+ const msg = check.error ? ` — ${check.error}` : "";
10
+ console.error(` [${status}] ${check.name}${msg}`);
11
+ }
12
+ if (!result.success) {
13
+ console.error("Validation failed");
14
+ process.exit(1);
15
+ }
16
+ console.error("All validation checks passed.");
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Validate generated lexicon-azure artifacts.
3
+ */
4
+ import { dirname } from "path";
5
+ import { fileURLToPath } from "url";
6
+ import { validateLexiconArtifacts } from "@intentius/chant/codegen/validate";
7
+ import { CURATED_PROPERTY_TYPE_DEFS } from "./spec/parse.js";
8
+ const REQUIRED_NAMES = [
9
+ // Core compute resources
10
+ "VirtualMachine", "VirtualMachineScaleSet", "AvailabilitySet",
11
+ // Networking
12
+ "VirtualNetwork", "Subnet", "NetworkInterface",
13
+ "NetworkSecurityGroup", "PublicIPAddress", "LoadBalancer",
14
+ // Storage
15
+ "StorageAccount", "BlobContainer",
16
+ // Web & App
17
+ "WebApp", "AppServicePlan",
18
+ // Containers
19
+ "ManagedCluster", "ContainerRegistry",
20
+ // Databases
21
+ "SqlServer", "SqlDatabase",
22
+ // Security & Identity
23
+ "KeyVault", "ManagedIdentity",
24
+ // Governance — org hierarchy + policy primitives (#1545). once claimed
25
+ // these existed when they did not; this gate keeps them from vanishing again.
26
+ "ManagementGroup", "SubscriptionAlias", "PolicyDefinition", "PolicyAssignment",
27
+ // DNS & Traffic
28
+ "DnsZone", "TrafficManagerProfile",
29
+ // Curated property types kept typed by the codegen (#438) — single source of truth.
30
+ ...CURATED_PROPERTY_TYPE_DEFS,
31
+ ];
32
+ export async function validate(opts) {
33
+ const basePath = opts?.basePath ?? dirname(dirname(fileURLToPath(import.meta.url)));
34
+ return validateLexiconArtifacts({
35
+ lexiconJsonFilename: "lexicon-azure.json",
36
+ requiredNames: REQUIRED_NAMES,
37
+ // Curated property types are emitted under resource-prefixed/variant names
38
+ // after bounding (#438), so match required names by substring.
39
+ requiredNamesMatchSubstring: true,
40
+ basePath,
41
+ // chant #1475 — azure generates from a commit sha of
42
+ // azure-resource-manager-schemas (#1144), so a fresh generate is
43
+ // deterministic. Gate the surface on every validate: the #1144 pin left
44
+ // the snapshot 483 entries behind until #1572 re-baselined it, because
45
+ // nothing compared the two on a PR.
46
+ checkSurfaceSnapshot: "always",
47
+ coverageThresholds: {
48
+ minPropertyPct: 1,
49
+ minLifecyclePct: 1,
50
+ minAttrPct: 1,
51
+ },
52
+ });
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-azure",
3
- "version": "0.124.31",
3
+ "version": "0.124.33",
4
4
  "description": "Azure lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -32,27 +32,27 @@
32
32
  ".": {
33
33
  "development": "./src/index.ts",
34
34
  "types": "./dist/index.d.ts",
35
- "default": "./src/index.ts"
35
+ "default": "./dist/index.js"
36
36
  },
37
37
  "./*": {
38
38
  "development": "./src/*.ts",
39
39
  "types": "./dist/*.d.ts",
40
- "default": "./src/*.ts"
40
+ "default": "./dist/*.js"
41
41
  },
42
42
  "./detect": {
43
43
  "development": "./src/detect.ts",
44
44
  "types": "./dist/detect.d.ts",
45
- "default": "./src/detect.ts"
45
+ "default": "./dist/detect.js"
46
46
  },
47
47
  "./op/activities": {
48
48
  "development": "./src/op/activities/index.ts",
49
49
  "types": "./dist/op/activities/index.d.ts",
50
- "default": "./src/op/activities/index.ts"
50
+ "default": "./dist/op/activities/index.js"
51
51
  },
52
52
  "./lint/post-synth": {
53
53
  "development": "./src/lint/post-synth/index.ts",
54
54
  "types": "./dist/lint/post-synth/index.d.ts",
55
- "default": "./src/lint/post-synth/index.ts"
55
+ "default": "./dist/lint/post-synth/index.js"
56
56
  },
57
57
  "./manifest": "./dist/manifest.json",
58
58
  "./meta": "./dist/meta.json",
@@ -64,7 +64,7 @@
64
64
  "validate": "tsx src/validate-cli.ts",
65
65
  "docs": "tsx src/codegen/docs-cli.ts",
66
66
  "prepack": "npm run generate && npm run bundle && npm run validate && npm run build",
67
- "build": "../../scripts/tsc7.sh -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && find dist -type f \\( -name \"*.js\" -o -name \"*.js.map\" \\) -delete"
67
+ "build": "../../scripts/tsc7.sh -p tsconfig.build.json --sourceMap false && tsc-alias -p tsconfig.build.json"
68
68
  },
69
69
  "dependencies": {
70
70
  "fflate": "^0.8.2"
@@ -74,6 +74,6 @@
74
74
  "typescript": "^5.9.3"
75
75
  },
76
76
  "peerDependencies": {
77
- "@intentius/chant": "^0.124.31"
77
+ "@intentius/chant": "^0.124.33"
78
78
  }
79
79
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * A Container App's hostname is referenceable (#4179), so DNS in another
3
+ * lexicon can point at it: `configuration.ingress.fqdn` is read through
4
+ * `configuration_ingress_fqdn`.
5
+ */
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import { AttrRef } from "@intentius/chant";
9
+ import { containerApps } from "./index";
10
+
11
+ describe("Container App attributes", () => {
12
+ test("configuration.ingress.fqdn and latestRevisionFqdn are AttrRefs", () => {
13
+ const app = new containerApps({ name: "web", location: "eastus" } as never) as unknown as Record<string, unknown>;
14
+ expect((app.configuration_ingress_fqdn as AttrRef).attribute).toBe("configuration.ingress.fqdn");
15
+ expect(app.latestRevisionFqdn).toBeInstanceOf(AttrRef);
16
+ });
17
+ });
@@ -78,7 +78,7 @@ export function generateTypeScriptDeclarations(
78
78
  description: p.description,
79
79
  }));
80
80
  const dtsAttrs: DtsAttribute[] = r.resource.attributes.map((a) => ({
81
- name: a.name,
81
+ name: a.name.replace(/\./g, "_"),
82
82
  type: a.tsType,
83
83
  }));
84
84
 
@@ -95,7 +95,7 @@ function generateRuntimeIndex(
95
95
  // Build attrs map
96
96
  const attrs: Record<string, string> = {};
97
97
  for (const a of r.resource.attributes) {
98
- attrs[a.name] = a.name;
98
+ attrs[a.name.replace(/\./g, "_")] = a.name;
99
99
  }
100
100
 
101
101
  resourceEntries.push({ tsName, resourceType: armType, attrs });
@@ -6,7 +6,7 @@
6
6
  import { readFileSync } from "fs";
7
7
  import { azurePlugin } from "../plugin";
8
8
  import { dirname } from "path";
9
- import { fileURLToPath } from "url";
9
+ import { moduleSourceDir } from "@intentius/chant/module-source";
10
10
  import type { IntrinsicDef } from "@intentius/chant/lexicon";
11
11
  import {
12
12
  packagePipeline,
@@ -18,7 +18,7 @@ import { generate } from "./generate";
18
18
 
19
19
  export type { PackageOptions, PackageResult };
20
20
 
21
- const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
21
+ const pkgDir = dirname(moduleSourceDir(import.meta.url));
22
22
 
23
23
  /**
24
24
  * Map intrinsic name to its ARM output key.
@@ -14,6 +14,7 @@
14
14
  import { readFileSync } from "fs";
15
15
  import { join, dirname } from "path";
16
16
  import { fileURLToPath } from "url";
17
+ import { moduleSourceDir } from "@intentius/chant/module-source";
17
18
  import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
18
19
 
19
20
  export type DeployScope = "resourceGroup" | "subscription" | "managementGroup" | "tenant";
@@ -94,7 +95,7 @@ let _cached: Map<string, DeployScope[]> | undefined;
94
95
  */
95
96
  function moduleDir(): string | undefined {
96
97
  try {
97
- return dirname(fileURLToPath(import.meta.url));
98
+ return moduleSourceDir(import.meta.url);
98
99
  } catch {
99
100
  return undefined;
100
101
  }
@@ -4232,7 +4232,12 @@ export declare class containerApps {
4232
4232
  /** Resource tags. */
4233
4233
  tags?: Record<string, unknown>;
4234
4234
  }, attributes?: ArmResourceAttributes);
4235
+ readonly configuration_ingress_fqdn: string;
4235
4236
  readonly id: string;
4237
+ readonly latestRevisionFqdn: string;
4238
+ readonly latestRevisionName: string;
4239
+ readonly outboundIpAddresses: string[];
4240
+ readonly provisioningState: string;
4236
4241
  }
4237
4242
 
4238
4243
  export declare class containerApps_authConfigs {
@@ -314,7 +314,7 @@ export const connections_flows = /* @__PURE__ */ createResource("Microsoft.Azure
314
314
  export const connectors = /* @__PURE__ */ createResource("Microsoft.Workloads/connectors", "azure", {"id":"id"});
315
315
  export const connectors_acssBackups = /* @__PURE__ */ createResource("Microsoft.Workloads/connectors_acssBackups", "azure", {"id":"id"});
316
316
  export const Consumptionbudgets = /* @__PURE__ */ createResource("Microsoft.Consumption/budgets", "azure", {"id":"id"});
317
- export const containerApps = /* @__PURE__ */ createResource("Microsoft.App/containerApps", "azure", {"id":"id"});
317
+ export const containerApps = /* @__PURE__ */ createResource("Microsoft.App/containerApps", "azure", {"id":"id","configuration_ingress_fqdn":"configuration.ingress.fqdn","latestRevisionFqdn":"latestRevisionFqdn","latestRevisionName":"latestRevisionName","outboundIpAddresses":"outboundIpAddresses","provisioningState":"provisioningState"});
318
318
  export const containerApps_authConfigs = /* @__PURE__ */ createResource("Microsoft.App/containerApps_authConfigs", "azure", {"id":"id"});
319
319
  export const containerApps_resiliencyPolicies = /* @__PURE__ */ createResource("Microsoft.App/containerApps_resiliencyPolicies", "azure", {"id":"id"});
320
320
  export const containerApps_sourcecontrols = /* @__PURE__ */ createResource("Microsoft.App/containerApps_sourcecontrols", "azure", {"id":"id"});
@@ -33235,7 +33235,12 @@
33235
33235
  "lexicon": "azure",
33236
33236
  "apiVersion": "2025-02-02-preview",
33237
33237
  "attrs": {
33238
- "id": "id"
33238
+ "id": "id",
33239
+ "configuration_ingress_fqdn": "configuration.ingress.fqdn",
33240
+ "latestRevisionFqdn": "latestRevisionFqdn",
33241
+ "latestRevisionName": "latestRevisionName",
33242
+ "outboundIpAddresses": "outboundIpAddresses",
33243
+ "provisioningState": "provisioningState"
33239
33244
  },
33240
33245
  "propertyConstraints": {
33241
33246
  "kind": {
@@ -0,0 +1,66 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
2
+ import { appRunningStatusEntry, containerAppEntry, observeHealthAzure } from "./health";
3
+ import type { AzHttp } from "./op/activities/az-apply";
4
+
5
+ const AT = "2026-10-20T21:00:00.000Z";
6
+ const now = () => new Date(AT);
7
+ const entities = new Map([["web", { entityType: "Microsoft.App/containerApps", props: { name: "web-app" } }]]);
8
+
9
+ describe("azure health (#4177)", () => {
10
+ let saved: string | undefined;
11
+ beforeEach(() => {
12
+ saved = process.env.AZURE_ENDPOINT_URL;
13
+ process.env.AZURE_ENDPOINT_URL = "http://localhost:4577";
14
+ });
15
+ afterEach(() => {
16
+ if (saved === undefined) delete process.env.AZURE_ENDPOINT_URL;
17
+ else process.env.AZURE_ENDPOINT_URL = saved;
18
+ });
19
+
20
+ test("judges the active revisions", () => {
21
+ const rev = (runningState: string, extra: Record<string, unknown> = {}) => ({
22
+ name: "web-app--r1",
23
+ properties: { active: true, runningState, replicas: 2, trafficWeight: 100, ...extra },
24
+ });
25
+ expect(containerAppEntry([rev("Running")], AT)).toMatchObject({ state: "healthy", detail: "web-app--r1 Running, 2 replica(s), 100% traffic" });
26
+ expect(containerAppEntry([rev("Running", { healthState: "Unhealthy" })], AT).state).toBe("unhealthy");
27
+ expect(containerAppEntry([rev("Failed")], AT).state).toBe("unhealthy");
28
+ expect(containerAppEntry([rev("Processing")], AT).state).toBe("unknown");
29
+ expect(containerAppEntry([{ name: "old", properties: { active: false, runningState: "Running" } }], AT)).toMatchObject({
30
+ state: "unhealthy",
31
+ detail: "no active revision (1 revision(s))",
32
+ });
33
+ });
34
+
35
+ test("the app's runningStatus answers when revisions cannot be listed", () => {
36
+ expect(appRunningStatusEntry("Running", AT).state).toBe("healthy");
37
+ expect(appRunningStatusEntry("Stopped", AT).state).toBe("unhealthy");
38
+ });
39
+
40
+ test("reads the app, then its revisions, at the endpoint override", async () => {
41
+ const urls: string[] = [];
42
+ const http: AzHttp = async (_method, url) => {
43
+ urls.push(url);
44
+ if (url.includes("/revisions")) {
45
+ return { status: 200, text: JSON.stringify({ value: [{ name: "web-app--r1", properties: { active: true, runningState: "Running", replicas: 1 } }] }) };
46
+ }
47
+ return { status: 200, text: JSON.stringify({ id: "/x", properties: {} }) };
48
+ };
49
+ const r = await observeHealthAzure({ environment: "rg", buildOutput: "", entities, http, now });
50
+ expect(r.entities.web).toMatchObject({ state: "healthy", observedAt: AT });
51
+ expect(urls[1]).toBe(
52
+ "http://localhost:4577/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/rg/providers/Microsoft.App/containerApps/web-app/revisions?api-version=2024-03-01",
53
+ );
54
+ });
55
+
56
+ test("a 404 app is absent; an unreadable revisions list with no runningStatus is a hole", async () => {
57
+ const notFound: AzHttp = async () => ({ status: 404, text: JSON.stringify({ error: { code: "ResourceNotFound", message: "gone" } }) });
58
+ expect((await observeHealthAzure({ environment: "rg", buildOutput: "", entities, http: notFound })).absent).toEqual(["web"]);
59
+
60
+ const noRevisions: AzHttp = async (_m, url) =>
61
+ url.includes("/revisions") ? { status: 404, text: "" } : { status: 200, text: JSON.stringify({ id: "/x", properties: {} }) };
62
+ const r = await observeHealthAzure({ environment: "rg", buildOutput: "", entities, http: noRevisions });
63
+ expect(r.entities).toEqual({});
64
+ expect(r.unobserved.web?.reason).toBe("read-failed");
65
+ });
66
+ });
package/src/health.ts ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Runtime health for Azure (#4177): Container Apps.
3
+ *
4
+ * For each declared `Microsoft.App/containerApps`, GET the app (a 404 is
5
+ * absent), then its revisions, and judge the active ones by their running and
6
+ * health state. Same transport and endpoint rule as `describe-resources.ts`, so
7
+ * `AZURE_ENDPOINT_URL` (floci-az) answers.
8
+ *
9
+ * When the revisions list cannot be read but the app carries a
10
+ * `runningStatus` (newer API versions do), that answers instead, and the detail
11
+ * says so.
12
+ */
13
+
14
+ import type { HealthEntry, HealthReadOptions, HealthReadResult, UnobservedEntity } from "@intentius/chant/lexicon";
15
+ import { armResourceReadUrl, AzureReadError, getResource, isNotFound, type AzureReadClientOptions } from "./api/read-client";
16
+ import { classifyArmFailure } from "./describe-resources";
17
+ import type { AzHttp } from "./op/activities/az-apply";
18
+
19
+ export const AZURE_HEALTH_TYPES = ["Microsoft.App/containerApps"] as const;
20
+
21
+ /** The Container Apps API version whose revisions carry `runningState` and `healthState`. */
22
+ const CONTAINER_APPS_API_VERSION = "2024-03-01";
23
+
24
+ export interface AzureHealthOptions extends HealthReadOptions {
25
+ http?: AzHttp;
26
+ now?: () => Date;
27
+ }
28
+
29
+ /** The fields of one revision that decide health. */
30
+ export interface RevisionState {
31
+ name?: string;
32
+ properties?: {
33
+ active?: boolean;
34
+ runningState?: string;
35
+ healthState?: string;
36
+ replicas?: number;
37
+ trafficWeight?: number;
38
+ lastActiveTime?: string;
39
+ createdTime?: string;
40
+ };
41
+ }
42
+
43
+ const RUNNING = new Set(["running", "runningatmaxscale"]);
44
+ const DOWN = new Set(["failed", "degraded", "stopped"]);
45
+
46
+ /**
47
+ * The active revisions' verdict: unhealthy if none is active or any active one
48
+ * failed, degraded, stopped or reports `Unhealthy`; healthy if every active one
49
+ * is running and none is unhealthy; otherwise unknown (still provisioning).
50
+ */
51
+ export function containerAppEntry(revisions: RevisionState[], observedAt: string): HealthEntry {
52
+ const type = "Microsoft.App/containerApps";
53
+ const active = revisions.filter((r) => r.properties?.active === true);
54
+ if (active.length === 0) {
55
+ return { type, state: "unhealthy", detail: `no active revision (${revisions.length} revision(s))`, observedAt };
56
+ }
57
+ const describe = (r: RevisionState): string => {
58
+ const p = r.properties ?? {};
59
+ const bits = [p.runningState ?? "no running state"];
60
+ if (p.healthState && p.healthState !== "None") bits.push(p.healthState);
61
+ if (typeof p.replicas === "number") bits.push(`${p.replicas} replica(s)`);
62
+ if (typeof p.trafficWeight === "number") bits.push(`${p.trafficWeight}% traffic`);
63
+ return `${r.name ?? "revision"} ${bits.join(", ")}`;
64
+ };
65
+ const detail = active.map(describe).join("; ");
66
+ const running = (r: RevisionState) => RUNNING.has((r.properties?.runningState ?? "").toLowerCase());
67
+ const down = (r: RevisionState) =>
68
+ DOWN.has((r.properties?.runningState ?? "").toLowerCase()) || r.properties?.healthState?.toLowerCase() === "unhealthy";
69
+ const checkedAt = active
70
+ .map((r) => r.properties?.lastActiveTime ?? r.properties?.createdTime)
71
+ .filter((t): t is string => typeof t === "string")
72
+ .sort()
73
+ .pop();
74
+ const state = active.some(down) ? "unhealthy" : active.every(running) ? "healthy" : "unknown";
75
+ return { type, state, detail, observedAt, ...(checkedAt ? { checkedAt } : {}) };
76
+ }
77
+
78
+ /** An app's own `runningStatus`, for when its revisions cannot be listed. */
79
+ export function appRunningStatusEntry(runningStatus: string, observedAt: string): HealthEntry {
80
+ const lower = runningStatus.toLowerCase();
81
+ const state = RUNNING.has(lower) ? "healthy" : DOWN.has(lower) ? "unhealthy" : "unknown";
82
+ return {
83
+ type: "Microsoft.App/containerApps",
84
+ state,
85
+ detail: `app runningStatus ${runningStatus} (revisions not readable)`,
86
+ observedAt,
87
+ };
88
+ }
89
+
90
+ function hole(err: unknown): UnobservedEntity {
91
+ const { reason, detail } = classifyArmFailure(err);
92
+ return { type: "Microsoft.App/containerApps", reason, detail };
93
+ }
94
+
95
+ async function listRevisions(client: AzureReadClientOptions, name: string): Promise<RevisionState[]> {
96
+ const http = client.http!;
97
+ const url = armResourceReadUrl(client, "Microsoft.App/containerApps", `${name}/revisions`, CONTAINER_APPS_API_VERSION);
98
+ const res = await http("GET", url, undefined, client.signal);
99
+ if (res.status >= 300) throw new AzureReadError(`revisions read failed with HTTP ${res.status}`, res.status);
100
+ let body: unknown;
101
+ try {
102
+ body = JSON.parse(res.text);
103
+ } catch {
104
+ throw new AzureReadError("unparseable revisions list", res.status);
105
+ }
106
+ const value = body && typeof body === "object" ? (body as { value?: unknown }).value : undefined;
107
+ if (!Array.isArray(value)) throw new AzureReadError("unparseable revisions list", res.status);
108
+ return value as RevisionState[];
109
+ }
110
+
111
+ const defaultHttp: AzHttp = async (method, url, body, signal) => {
112
+ const res = await fetch(url, {
113
+ method,
114
+ headers: body === undefined ? undefined : { "content-type": "application/json" },
115
+ body: body === undefined ? undefined : JSON.stringify(body),
116
+ signal,
117
+ });
118
+ return { status: res.status, text: await res.text() };
119
+ };
120
+
121
+ export async function observeHealthAzure(options: AzureHealthOptions): Promise<HealthReadResult> {
122
+ const observedAt = (options.now?.() ?? new Date()).toISOString();
123
+ const client: AzureReadClientOptions = {
124
+ resourceGroup: options.environment,
125
+ ...(process.env.AZURE_ENDPOINT_URL ? { endpoint: process.env.AZURE_ENDPOINT_URL } : {}),
126
+ ...(process.env.AZURE_SUBSCRIPTION_ID ? { subscriptionId: process.env.AZURE_SUBSCRIPTION_ID } : {}),
127
+ http: options.http ?? defaultHttp,
128
+ };
129
+ const entities: Record<string, HealthEntry> = {};
130
+ const unobserved: Record<string, UnobservedEntity> = {};
131
+ const absent: string[] = [];
132
+
133
+ await Promise.all(
134
+ [...options.entities].map(async ([entityName, { entityType, props }]) => {
135
+ const name = props.name as string | undefined;
136
+ if (!name) {
137
+ unobserved[entityName] = { type: entityType, reason: "read-failed", detail: "declared entity has no name to query by" };
138
+ return;
139
+ }
140
+ let app: Awaited<ReturnType<typeof getResource>>;
141
+ try {
142
+ app = await getResource(client, entityType, name, CONTAINER_APPS_API_VERSION);
143
+ } catch (err) {
144
+ if (isNotFound(err)) absent.push(entityName);
145
+ else unobserved[entityName] = hole(err);
146
+ return;
147
+ }
148
+ try {
149
+ entities[entityName] = containerAppEntry(await listRevisions(client, name), observedAt);
150
+ } catch (err) {
151
+ const runningStatus = app.properties?.runningStatus;
152
+ if (typeof runningStatus === "string" && runningStatus.length > 0) {
153
+ entities[entityName] = appRunningStatusEntry(runningStatus, observedAt);
154
+ } else {
155
+ unobserved[entityName] = hole(err);
156
+ }
157
+ }
158
+ }),
159
+ );
160
+
161
+ return { entities, unobserved, absent };
162
+ }
@@ -76,17 +76,19 @@ export function isBracketExpression(value: unknown): boolean {
76
76
  *
77
77
  * Handles:
78
78
  * - [resourceId('Type', 'name')] -> extracts 'name'
79
+ * - [resourceId('Type', 'a', 'b')] -> extracts 'a/b'
79
80
  * - [reference('name')] -> extracts 'name'
80
81
  * - [reference('name').prop] -> extracts 'name'
81
82
  */
82
83
  export function extractRefsFromExpression(expr: string): string[] {
83
84
  const refs: string[] = [];
84
85
 
85
- // Match resourceId('...', 'name') — extract the last quoted argument (the resource name)
86
- const resourceIdPattern = /resourceId\s*\([^)]*,\s*'([^']+)'\s*\)/g;
86
+ // Match resourceId('Type', 'name', ...): the name is every quoted argument
87
+ // after the type, joined with "/" for a child resource ('vnet', 'subnet').
88
+ const resourceIdPattern = /resourceId\s*\(\s*'[^']+'((?:\s*,\s*'[^']+')+)\s*\)/g;
87
89
  let match: RegExpExecArray | null;
88
90
  while ((match = resourceIdPattern.exec(expr)) !== null) {
89
- refs.push(match[1]);
91
+ refs.push([...match[1].matchAll(/'([^']+)'/g)].map((m) => m[1]).join("/"));
90
92
  }
91
93
 
92
94
  // Match reference('name') — extract the name
@@ -33,7 +33,7 @@ describe("findArmResourceRefs", () => {
33
33
  const refs = findArmResourceRefs({
34
34
  subnet: "[resourceId('Microsoft.Network/virtualNetworks/subnets', 'myVnet', 'mySubnet')]",
35
35
  });
36
- expect(refs.has("mySubnet")).toBe(true);
36
+ expect(refs.has("myVnet/mySubnet")).toBe(true);
37
37
  });
38
38
 
39
39
  test("extracts reference targets", () => {
@@ -1,7 +1,8 @@
1
1
  import { createRequire } from "module";
2
+ import { moduleSourceUrl } from "@intentius/chant/module-source";
2
3
  import type { CompletionContext, CompletionItem } from "@intentius/chant/lsp/types";
3
4
  import { LexiconIndex, lexiconCompletions, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
4
- const require = createRequire(import.meta.url);
5
+ const require = createRequire(moduleSourceUrl(import.meta.url));
5
6
 
6
7
  let cachedIndex: LexiconIndex | null = null;
7
8
 
package/src/lsp/hover.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { createRequire } from "module";
2
+ import { moduleSourceUrl } from "@intentius/chant/module-source";
2
3
  import type { HoverContext, HoverInfo } from "@intentius/chant/lsp/types";
3
4
  import { LexiconIndex, lexiconHover, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
4
- const require = createRequire(import.meta.url);
5
+ const require = createRequire(moduleSourceUrl(import.meta.url));
5
6
 
6
7
  let cachedIndex: LexiconIndex | null = null;
7
8
 
package/src/plugin.ts CHANGED
@@ -13,6 +13,7 @@ import { createSkillsLoader } from "@intentius/chant/lexicon-plugin-helpers";
13
13
  import { readFileSync, readdirSync, existsSync } from "fs";
14
14
  import { join, dirname } from "path";
15
15
  import { fileURLToPath } from "url";
16
+ import { moduleSourceDir } from "@intentius/chant/module-source";
16
17
  import { azureSerializer } from "./serializer";
17
18
  import { hardcodedLocationRule } from "./lint/rules/hardcoded-location";
18
19
  import { storageHttpsRule } from "./lint/rules/storage-https";
@@ -373,7 +374,7 @@ export const tags = defaultTags([
373
374
  required: ["query"],
374
375
  },
375
376
  handler: async (args: Record<string, unknown>) => {
376
- const dir = dirname(fileURLToPath(import.meta.url));
377
+ const dir = moduleSourceDir(import.meta.url);
377
378
  try {
378
379
  const lexicon = JSON.parse(
379
380
  readFileSync(join(dir, "generated", "lexicon-azure.json"), "utf-8"),
@@ -398,7 +399,7 @@ export const tags = defaultTags([
398
399
  description: "All available Azure resource types",
399
400
  mimeType: "application/json",
400
401
  handler: async () => {
401
- const dir = dirname(fileURLToPath(import.meta.url));
402
+ const dir = moduleSourceDir(import.meta.url);
402
403
  try {
403
404
  return readFileSync(join(dir, "generated", "lexicon-azure.json"), "utf-8");
404
405
  } catch {
@@ -485,6 +486,14 @@ export const tags = defaultTags([
485
486
  /** The noise rules the deep pass applies to both the live and declared trees. */
486
487
  deepNormalizationHooks: azureDeepNormalizationHooks,
487
488
 
489
+ /** Runtime health (#4177): Container Apps. Implementation in ./health.ts. */
490
+ healthTypes: ["Microsoft.App/containerApps"],
491
+
492
+ async observeHealth(options) {
493
+ const { observeHealthAzure } = await import("./health");
494
+ return observeHealthAzure(options);
495
+ },
496
+
488
497
  async exportResources(options) {
489
498
  const { exportResources } = await import("./export-resources");
490
499
  return exportResources(options);