@intentius/chant-lexicon-azure 0.124.46 → 0.124.48

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 (38) hide show
  1. package/README.md +8 -0
  2. package/dist/components/acr-backend.d.ts +11 -0
  3. package/dist/components/acr-backend.d.ts.map +1 -0
  4. package/dist/components/acr-backend.js +17 -0
  5. package/dist/components/blob-backend.d.ts +11 -0
  6. package/dist/components/blob-backend.d.ts.map +1 -0
  7. package/dist/components/blob-backend.js +18 -0
  8. package/dist/components/builders.d.ts +6 -0
  9. package/dist/components/builders.d.ts.map +1 -0
  10. package/dist/components/builders.js +11 -0
  11. package/dist/components/capability-plugin.d.ts +1 -0
  12. package/dist/components/capability-plugin.d.ts.map +1 -1
  13. package/dist/components/capability-plugin.js +16 -1
  14. package/dist/components/index.d.ts +4 -0
  15. package/dist/components/index.d.ts.map +1 -1
  16. package/dist/components/index.js +4 -0
  17. package/dist/components/wait-azure.d.ts +59 -0
  18. package/dist/components/wait-azure.d.ts.map +1 -0
  19. package/dist/components/wait-azure.js +106 -0
  20. package/dist/describe-stack-status.d.ts +15 -0
  21. package/dist/describe-stack-status.d.ts.map +1 -0
  22. package/dist/describe-stack-status.js +33 -0
  23. package/dist/integrity.json +2 -2
  24. package/dist/manifest.json +1 -1
  25. package/dist/plugin.d.ts.map +1 -1
  26. package/dist/plugin.js +5 -0
  27. package/package.json +2 -2
  28. package/src/components/acr-backend.ts +25 -0
  29. package/src/components/blob-backend.ts +29 -0
  30. package/src/components/builders.ts +14 -0
  31. package/src/components/capability-plugin.ts +16 -1
  32. package/src/components/index.ts +4 -0
  33. package/src/components/publish-backend.test.ts +50 -0
  34. package/src/components/storage-backend.test.ts +64 -0
  35. package/src/components/wait-azure.test.ts +96 -0
  36. package/src/components/wait-azure.ts +163 -0
  37. package/src/describe-stack-status.ts +43 -0
  38. package/src/plugin.ts +6 -0
package/README.md CHANGED
@@ -10,6 +10,14 @@ npm install --save-dev @intentius/chant @intentius/chant-lexicon-azure
10
10
 
11
11
  **[Documentation →](https://intentius.io/chant/lexicons/azure/)**
12
12
 
13
+ ## Lifecycle
14
+
15
+ chant marks what it owns with ARM tags (`src/ownership.ts`): `chant-managed-by`, `chant-stack` and `chant-env`. Azure tag keys can't hold `/`, so these differ from the label keys k8s and gcp use.
16
+
17
+ - `chant lifecycle diff <env> --live` and `chant lifecycle plan <env>` read the resource group live and compare it with the built template, keyed by those tags.
18
+ - `azApply`, the Op activity, and `arm-deploy`, the component step, apply the template resource by resource with ARM PUTs into one resource group, rather than through `az deployment`. `AZURE_ENDPOINT_URL` points them at an emulator such as floci-az.
19
+ - With `prune`, the applier deletes chant-owned resources in the group that the template no longer declares, and nothing it doesn't own.
20
+
13
21
  ## Related Packages
14
22
 
15
23
  | Package | Role |
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The azure lexicon's registry login for core's `publish-image` (#3142): ACR,
3
+ * through `az acr login`. Core owns load, tag, push and the digest read-back;
4
+ * this only authenticates the local docker client.
5
+ */
6
+ import type { RegistryLoginBackend } from "@intentius/chant/components/verbs/publish-image";
7
+ import { type ProcessRunner } from "@intentius/chant/components/verbs/process-runner";
8
+ export declare function createAcrRegistryBackend(runner?: ProcessRunner): RegistryLoginBackend;
9
+ /** The ACR backend the azure plugin contributes, over the real process runner. */
10
+ export declare const acrRegistryBackend: RegistryLoginBackend;
11
+ //# sourceMappingURL=acr-backend.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"acr-backend.d.ts","sourceRoot":"","sources":["../../src/components/acr-backend.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,iDAAiD,CAAC;AAC5F,OAAO,EAA2B,KAAK,aAAa,EAAE,MAAM,kDAAkD,CAAC;AAK/G,wBAAgB,wBAAwB,CAAC,MAAM,CAAC,EAAE,aAAa,GAAG,oBAAoB,CAUrF;AAED,kFAAkF;AAClF,eAAO,MAAM,kBAAkB,EAAE,oBAAiD,CAAC"}
@@ -0,0 +1,17 @@
1
+ import { defaultProcessRunner, q } from "@intentius/chant/components/verbs/process-runner";
2
+ /** `<name>.azurecr.io` (also the sovereign-cloud suffixes). */
3
+ const ACR_HOST = /^([a-z0-9]+)\.azurecr\.(io|cn|us)$/i;
4
+ export function createAcrRegistryBackend(runner) {
5
+ return {
6
+ name: "acr",
7
+ matches: (registry) => ACR_HOST.test(registry),
8
+ async login(registry) {
9
+ const m = ACR_HOST.exec(registry);
10
+ if (!m)
11
+ throw new Error(`acr login: "${registry}" is not an Azure Container Registry host (<name>.azurecr.io)`);
12
+ await (runner ?? defaultProcessRunner()).run(`az acr login --name ${q(m[1])}`);
13
+ },
14
+ };
15
+ }
16
+ /** The ACR backend the azure plugin contributes, over the real process runner. */
17
+ export const acrRegistryBackend = createAcrRegistryBackend();
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The azure lexicon's storage backend for core's `publish-artifact` (#4408):
3
+ * Azure Blob Storage through `az storage blob upload`. Core owns the content
4
+ * digest and the object URI; this only uploads.
5
+ */
6
+ import type { ArtifactStorageBackend } from "@intentius/chant/components/verbs/publish-artifact";
7
+ import { type ProcessRunner } from "@intentius/chant/components/verbs/process-runner";
8
+ export declare function createAzureBlobStorageBackend(runner?: ProcessRunner): ArtifactStorageBackend;
9
+ /** The Blob backend the azure plugin contributes, over the real process runner. */
10
+ export declare const azureBlobStorageBackend: ArtifactStorageBackend;
11
+ //# sourceMappingURL=blob-backend.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blob-backend.d.ts","sourceRoot":"","sources":["../../src/components/blob-backend.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,oDAAoD,CAAC;AACjG,OAAO,EAA2B,KAAK,aAAa,EAAE,MAAM,kDAAkD,CAAC;AAK/G,wBAAgB,6BAA6B,CAAC,MAAM,CAAC,EAAE,aAAa,GAAG,sBAAsB,CAc5F;AAED,mFAAmF;AACnF,eAAO,MAAM,uBAAuB,EAAE,sBAAwD,CAAC"}
@@ -0,0 +1,18 @@
1
+ import { defaultProcessRunner, q } from "@intentius/chant/components/verbs/process-runner";
2
+ /** `https://<account>.blob.core.windows.net/<container>/<blob path>` (also the sovereign-cloud suffixes). */
3
+ const BLOB_URI = /^https:\/\/([a-z0-9]+)\.blob\.core\.(windows\.net|chinacloudapi\.cn|usgovcloudapi\.net)\/([^/]+)\/(.+)$/i;
4
+ export function createAzureBlobStorageBackend(runner) {
5
+ return {
6
+ name: "azure-blob",
7
+ matches: (uri) => /^https:\/\/[a-z0-9]+\.blob\.core\./i.test(uri),
8
+ async upload({ from, to }) {
9
+ const m = BLOB_URI.exec(to);
10
+ if (!m) {
11
+ throw new Error(`azure-blob upload: "${to}" is not a blob URI (https://<account>.blob.core.windows.net/<container>/<path>)`);
12
+ }
13
+ await (runner ?? defaultProcessRunner()).run(`az storage blob upload --account-name ${q(m[1])} --container-name ${q(m[3])} --name ${q(m[4])} --file ${q(from)} --overwrite --auth-mode login`);
14
+ },
15
+ };
16
+ }
17
+ /** The Blob backend the azure plugin contributes, over the real process runner. */
18
+ export const azureBlobStorageBackend = createAzureBlobStorageBackend();
@@ -0,0 +1,6 @@
1
+ import type { ArmDeployInput } from "./arm-deploy.js";
2
+ import type { WaitContainerAppInput } from "./wait-azure.js";
3
+ export declare const armDeploy: (input: ArmDeployInput) => import("@intentius/chant/components").Step;
4
+ export declare const waitForDeployment: (input: import("./wait-azure.js").TargetInput) => import("@intentius/chant/components").Step;
5
+ export declare const waitContainerApp: (input: WaitContainerAppInput) => import("@intentius/chant/components").Step;
6
+ //# sourceMappingURL=builders.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"builders.d.ts","sourceRoot":"","sources":["../../src/components/builders.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACnD,OAAO,KAAK,EAA0B,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAElF,eAAO,MAAM,SAAS,uEAAqC,CAAC;AAC5D,eAAO,MAAM,iBAAiB,2FAAsD,CAAC;AACrF,eAAO,MAAM,gBAAgB,8EAAoD,CAAC"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Typed step-builders for the azure lexicon's verbs (#3142), the same sugar the
3
+ * aws lexicon offers. `armDeploy({ template })` is exactly
4
+ * `{ kind: "arm-deploy", template }`, checked against the capability's input.
5
+ * `publishImage` is core's verb; azure contributes only the ACR login, chosen
6
+ * by the registry host or `$env.publish.backend`.
7
+ */
8
+ import { step } from "@intentius/chant/components";
9
+ export const armDeploy = step("arm-deploy");
10
+ export const waitForDeployment = step("wait-for-deployment");
11
+ export const waitContainerApp = step("wait-container-app");
@@ -1,6 +1,7 @@
1
1
  import { type CapabilityPlugin } from "@intentius/chant/components/capability-plugin";
2
2
  export declare const AZURE_VERB_FAMILIES: {
3
3
  readonly apply: readonly ["arm-deploy"];
4
+ readonly waitVerify: readonly ["wait-for-deployment", "wait-container-app"];
4
5
  };
5
6
  export declare const azureCapabilityPlugin: CapabilityPlugin;
6
7
  //# sourceMappingURL=capability-plugin.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"capability-plugin.d.ts","sourceRoot":"","sources":["../../src/components/capability-plugin.ts"],"names":[],"mappings":"AAOA,OAAO,EAAqB,KAAK,gBAAgB,EAAE,MAAM,+CAA+C,CAAC;AAGzG,eAAO,MAAM,mBAAmB;aAC9B,KAAK,YAAG,YAAY;CACZ,CAAC;AAEX,eAAO,MAAM,qBAAqB,EAAE,gBAWnC,CAAC"}
1
+ {"version":3,"file":"capability-plugin.d.ts","sourceRoot":"","sources":["../../src/components/capability-plugin.ts"],"names":[],"mappings":"AAOA,OAAO,EAAqB,KAAK,gBAAgB,EAAE,MAAM,+CAA+C,CAAC;AAMzG,eAAO,MAAM,mBAAmB;aAC9B,KAAK,YAAG,YAAY;aACpB,UAAU,YAAG,qBAAqB,EAAE,oBAAoB;CAEhD,CAAC;AAEX,eAAO,MAAM,qBAAqB,EAAE,gBAqBnC,CAAC"}
@@ -1,7 +1,12 @@
1
1
  import { ownPackageVersion } from "@intentius/chant/components/capability-plugin";
2
2
  import { armDeployCapability } from "./arm-deploy.js";
3
+ import { waitForDeploymentCapability, waitContainerAppCapability } from "./wait-azure.js";
4
+ import { acrRegistryBackend } from "./acr-backend.js";
5
+ import { azureBlobStorageBackend } from "./blob-backend.js";
3
6
  export const AZURE_VERB_FAMILIES = {
4
7
  apply: ["arm-deploy"],
8
+ waitVerify: ["wait-for-deployment", "wait-container-app"],
9
+ // `publish-image` is core's verb; azure contributes the "acr" registry login backend.
5
10
  };
6
11
  export const azureCapabilityPlugin = {
7
12
  name: "azure",
@@ -9,7 +14,17 @@ export const azureCapabilityPlugin = {
9
14
  return ownPackageVersion(import.meta.url);
10
15
  },
11
16
  capabilities() {
12
- return [armDeployCapability];
17
+ return [
18
+ armDeployCapability,
19
+ waitForDeploymentCapability,
20
+ waitContainerAppCapability,
21
+ ];
22
+ },
23
+ registryBackends() {
24
+ return [acrRegistryBackend];
25
+ },
26
+ storageBackends() {
27
+ return [azureBlobStorageBackend];
13
28
  },
14
29
  families() {
15
30
  return AZURE_VERB_FAMILIES;
@@ -1,3 +1,7 @@
1
1
  export { azureCapabilityPlugin, AZURE_VERB_FAMILIES } from "./capability-plugin.js";
2
2
  export { armDeployCapability, createArmDeployCapability, ArmDeployPlanMovedError, type ArmDeployInput, type ArmDeployOutput, type ArmDeployPlanArtifact, } from "./arm-deploy.js";
3
+ export * from "./wait-azure.js";
4
+ export * from "./builders.js";
5
+ export { createAcrRegistryBackend, acrRegistryBackend } from "./acr-backend.js";
6
+ export { createAzureBlobStorageBackend, azureBlobStorageBackend } from "./blob-backend.js";
3
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AACjF,OAAO,EACL,mBAAmB,EACnB,yBAAyB,EACzB,uBAAuB,EACvB,KAAK,cAAc,EACnB,KAAK,eAAe,EACpB,KAAK,qBAAqB,GAC3B,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AACjF,OAAO,EACL,mBAAmB,EACnB,yBAAyB,EACzB,uBAAuB,EACvB,KAAK,cAAc,EACnB,KAAK,eAAe,EACpB,KAAK,qBAAqB,GAC3B,MAAM,cAAc,CAAC;AACtB,cAAc,cAAc,CAAC;AAC7B,cAAc,YAAY,CAAC;AAC3B,OAAO,EAAE,wBAAwB,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAC7E,OAAO,EAAE,6BAA6B,EAAE,uBAAuB,EAAE,MAAM,gBAAgB,CAAC"}
@@ -1,2 +1,6 @@
1
1
  export { azureCapabilityPlugin, AZURE_VERB_FAMILIES } from "./capability-plugin.js";
2
2
  export { armDeployCapability, createArmDeployCapability, ArmDeployPlanMovedError, } from "./arm-deploy.js";
3
+ export * from "./wait-azure.js";
4
+ export * from "./builders.js";
5
+ export { createAcrRegistryBackend, acrRegistryBackend } from "./acr-backend.js";
6
+ export { createAzureBlobStorageBackend, azureBlobStorageBackend } from "./blob-backend.js";
@@ -0,0 +1,59 @@
1
+ /**
2
+ * wait / verify family, azure members (#3142): `wait-for-deployment` (every
3
+ * resource of a group reaches an ARM terminal provisioning state) and
4
+ * `wait-container-app` (a Container App serves its latest revision). Both
5
+ * poll ARM through the read client, so a test or floci-az swaps `http`. A wait
6
+ * is a read-only observation: neither declares a rollback.
7
+ */
8
+ import type { Capability } from "@intentius/chant/components/capability";
9
+ import type { AzHttp } from "../op/activities/az-apply.js";
10
+ export interface AzureWaitOptions {
11
+ http?: AzHttp;
12
+ sleep?: (ms: number) => Promise<void>;
13
+ now?: () => number;
14
+ }
15
+ export interface TargetInput {
16
+ /** The resource group. Default: the component's name. */
17
+ resourceGroup?: string;
18
+ /** ARM endpoint. Default: `AZURE_ENDPOINT_URL`, else real Azure. */
19
+ endpoint?: string;
20
+ /** Subscription id. Default: `AZURE_SUBSCRIPTION_ID`, else floci-az's local subscription. */
21
+ subscriptionId?: string;
22
+ /** Poll interval in ms. Default: 10000. */
23
+ intervalMs?: number;
24
+ /** Overall timeout in ms. Default: 10 minutes. */
25
+ timeoutMs?: number;
26
+ }
27
+ export type WaitForDeploymentInput = TargetInput;
28
+ export interface WaitForDeploymentOutput {
29
+ /** Resources of the group, each with the state it ended in. */
30
+ resources: Array<{
31
+ type: string;
32
+ name: string;
33
+ state: string;
34
+ }>;
35
+ }
36
+ /**
37
+ * Poll the group until no resource is still provisioning. Throws when one ends
38
+ * `Failed` or `Canceled`, or when the timeout passes first. A resource whose
39
+ * body carries no `provisioningState` (some types do not) counts as settled.
40
+ */
41
+ export declare function createWaitForDeploymentCapability(options?: AzureWaitOptions): Capability<WaitForDeploymentInput, WaitForDeploymentOutput>;
42
+ export declare const waitForDeploymentCapability: Capability<WaitForDeploymentInput, WaitForDeploymentOutput>;
43
+ export interface WaitContainerAppInput extends TargetInput {
44
+ /** The Container App's name. */
45
+ app: string;
46
+ }
47
+ export interface WaitContainerAppOutput {
48
+ /** The revision serving traffic once the app is steady. */
49
+ revision: string;
50
+ /** The app's FQDN, when it has ingress. */
51
+ fqdn?: string;
52
+ }
53
+ /**
54
+ * Poll a Container App until provisioning succeeded and its latest revision is
55
+ * the latest ready one. A rollout that fails provisioning throws.
56
+ */
57
+ export declare function createWaitContainerAppCapability(options?: AzureWaitOptions): Capability<WaitContainerAppInput, WaitContainerAppOutput>;
58
+ export declare const waitContainerAppCapability: Capability<WaitContainerAppInput, WaitContainerAppOutput>;
59
+ //# sourceMappingURL=wait-azure.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wait-azure.d.ts","sourceRoot":"","sources":["../../src/components/wait-azure.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wCAAwC,CAAC;AAEzE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,2BAA2B,CAAC;AAOxD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAID,MAAM,WAAW,WAAW;IAC1B,yDAAyD;IACzD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,oEAAoE;IACpE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6FAA6F;IAC7F,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,2CAA2C;IAC3C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kDAAkD;IAClD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAoBD,MAAM,MAAM,sBAAsB,GAAG,WAAW,CAAC;AAEjD,MAAM,WAAW,uBAAuB;IACtC,+DAA+D;IAC/D,SAAS,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACjE;AAED;;;;GAIG;AACH,wBAAgB,iCAAiC,CAC/C,OAAO,GAAE,gBAAqB,GAC7B,UAAU,CAAC,sBAAsB,EAAE,uBAAuB,CAAC,CA+B7D;AAED,eAAO,MAAM,2BAA2B,EAAE,UAAU,CAAC,sBAAsB,EAAE,uBAAuB,CAC/D,CAAC;AAItC,MAAM,WAAW,qBAAsB,SAAQ,WAAW;IACxD,gCAAgC;IAChC,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,sBAAsB;IACrC,2DAA2D;IAC3D,QAAQ,EAAE,MAAM,CAAC;IACjB,2CAA2C;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;GAGG;AACH,wBAAgB,gCAAgC,CAC9C,OAAO,GAAE,gBAAqB,GAC7B,UAAU,CAAC,qBAAqB,EAAE,sBAAsB,CAAC,CAkC3D;AAED,eAAO,MAAM,0BAA0B,EAAE,UAAU,CAAC,qBAAqB,EAAE,sBAAsB,CAC7D,CAAC"}
@@ -0,0 +1,106 @@
1
+ import { getResource, getResourceById, isNotFound, listResources } from "../api/read-client.js";
2
+ const DEFAULT_INTERVAL_MS = 10000;
3
+ const DEFAULT_TIMEOUT_MS = 10 * 60000;
4
+ /** ARM provisioning states that will not change without a new request. */
5
+ const TERMINAL = new Set(["Succeeded", "Failed", "Canceled"]);
6
+ const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
7
+ function client(input, component, options) {
8
+ const endpoint = input.endpoint ?? process.env.AZURE_ENDPOINT_URL;
9
+ const subscriptionId = input.subscriptionId ?? process.env.AZURE_SUBSCRIPTION_ID;
10
+ return {
11
+ resourceGroup: input.resourceGroup ?? component,
12
+ ...(endpoint ? { endpoint } : {}),
13
+ ...(subscriptionId ? { subscriptionId } : {}),
14
+ ...(options.http ? { http: options.http } : {}),
15
+ };
16
+ }
17
+ function provisioningState(body) {
18
+ const s = body.properties?.provisioningState;
19
+ return typeof s === "string" ? s : undefined;
20
+ }
21
+ /**
22
+ * Poll the group until no resource is still provisioning. Throws when one ends
23
+ * `Failed` or `Canceled`, or when the timeout passes first. A resource whose
24
+ * body carries no `provisioningState` (some types do not) counts as settled.
25
+ */
26
+ export function createWaitForDeploymentCapability(options = {}) {
27
+ const sleep = options.sleep ?? realSleep;
28
+ const now = options.now ?? Date.now;
29
+ return {
30
+ kind: "wait-for-deployment",
31
+ async run(ctx, input) {
32
+ const read = client(input, ctx.component, options);
33
+ const interval = input.intervalMs ?? DEFAULT_INTERVAL_MS;
34
+ const deadline = now() + (input.timeoutMs ?? DEFAULT_TIMEOUT_MS);
35
+ while (true) {
36
+ const listed = await listResources(read);
37
+ const resources = [];
38
+ for (const item of listed) {
39
+ const body = item.id ? await getResourceById(read, item.id) : item;
40
+ resources.push({
41
+ type: String(item.type ?? ""),
42
+ name: String(item.name ?? ""),
43
+ state: provisioningState(body) ?? "Succeeded",
44
+ });
45
+ }
46
+ const bad = resources.find((r) => r.state === "Failed" || r.state === "Canceled");
47
+ if (bad)
48
+ throw new Error(`wait-for-deployment "${read.resourceGroup}": ${bad.type}/${bad.name} ended ${bad.state}`);
49
+ if (resources.every((r) => TERMINAL.has(r.state)))
50
+ return { resources };
51
+ if (now() > deadline) {
52
+ const pending = resources.filter((r) => !TERMINAL.has(r.state)).map((r) => `${r.name} (${r.state})`).join(", ");
53
+ throw new Error(`wait-for-deployment "${read.resourceGroup}" timed out; still provisioning: ${pending}`);
54
+ }
55
+ await sleep(interval);
56
+ }
57
+ },
58
+ };
59
+ }
60
+ export const waitForDeploymentCapability = createWaitForDeploymentCapability();
61
+ /**
62
+ * Poll a Container App until provisioning succeeded and its latest revision is
63
+ * the latest ready one. A rollout that fails provisioning throws.
64
+ */
65
+ export function createWaitContainerAppCapability(options = {}) {
66
+ const sleep = options.sleep ?? realSleep;
67
+ const now = options.now ?? Date.now;
68
+ return {
69
+ kind: "wait-container-app",
70
+ async run(ctx, input) {
71
+ const read = client(input, ctx.component, options);
72
+ const interval = input.intervalMs ?? DEFAULT_INTERVAL_MS;
73
+ const deadline = now() + (input.timeoutMs ?? DEFAULT_TIMEOUT_MS);
74
+ while (true) {
75
+ let state = "NotFound";
76
+ let ready = false;
77
+ let revision = "";
78
+ let fqdn;
79
+ try {
80
+ const body = await getResource(read, "Microsoft.App/containerApps", input.app);
81
+ const props = body.properties ?? {};
82
+ state = provisioningState(body) ?? "Unknown";
83
+ const latest = typeof props.latestRevisionName === "string" ? props.latestRevisionName : undefined;
84
+ const latestReady = typeof props.latestReadyRevisionName === "string" ? props.latestReadyRevisionName : undefined;
85
+ revision = latestReady ?? latest ?? "";
86
+ ready = state === "Succeeded" && (latest === undefined || latest === latestReady);
87
+ const ingress = props.configuration?.ingress;
88
+ if (typeof ingress?.fqdn === "string")
89
+ fqdn = ingress.fqdn;
90
+ }
91
+ catch (err) {
92
+ if (!isNotFound(err))
93
+ throw err;
94
+ }
95
+ if (state === "Failed" || state === "Canceled")
96
+ throw new Error(`wait-container-app "${input.app}" ended ${state}`);
97
+ if (ready)
98
+ return { revision, ...(fqdn ? { fqdn } : {}) };
99
+ if (now() > deadline)
100
+ throw new Error(`wait-container-app "${input.app}" timed out in state ${state}`);
101
+ await sleep(interval);
102
+ }
103
+ },
104
+ };
105
+ }
106
+ export const waitContainerAppCapability = createWaitContainerAppCapability();
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Deploy-unit status for Azure (#3142, the #1495 seam). An `arm-deploy` unit is
3
+ * its resource group. It is present when the group holds chant-owned
4
+ * resources (the ownership tags `arm-deploy` stamps), and healthy when every
5
+ * one of them reports a `Succeeded` provisioning state. A read that cannot
6
+ * happen (no credentials, ARM unreachable) is indeterminate (`null`), never a
7
+ * confident absence; a group ARM says does not exist is `present: false`.
8
+ */
9
+ import type { StackStatusObservation } from "@intentius/chant/lexicon";
10
+ import { type AzureReadClientOptions } from "./api/read-client.js";
11
+ export declare function describeStackStatus(options: {
12
+ environment: string;
13
+ stack: string;
14
+ }, read?: Partial<AzureReadClientOptions>): Promise<StackStatusObservation | null>;
15
+ //# sourceMappingURL=describe-stack-status.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"describe-stack-status.d.ts","sourceRoot":"","sources":["../src/describe-stack-status.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AACvE,OAAO,EAA8C,KAAK,sBAAsB,EAAE,MAAM,mBAAmB,CAAC;AAG5G,wBAAsB,mBAAmB,CACvC,OAAO,EAAE;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,EAC/C,IAAI,GAAE,OAAO,CAAC,sBAAsB,CAAM,GACzC,OAAO,CAAC,sBAAsB,GAAG,IAAI,CAAC,CA2BxC"}
@@ -0,0 +1,33 @@
1
+ import { getResourceById, isNotFound, listResources } from "./api/read-client.js";
2
+ import { isChantOwned } from "./op/activities/az-apply.js";
3
+ export async function describeStackStatus(options, read = {}) {
4
+ const client = {
5
+ ...(process.env.AZURE_ENDPOINT_URL ? { endpoint: process.env.AZURE_ENDPOINT_URL } : {}),
6
+ ...(process.env.AZURE_SUBSCRIPTION_ID ? { subscriptionId: process.env.AZURE_SUBSCRIPTION_ID } : {}),
7
+ ...read,
8
+ resourceGroup: options.stack,
9
+ };
10
+ try {
11
+ const owned = (await listResources(client)).filter((r) => isChantOwned(r.tags));
12
+ if (owned.length === 0)
13
+ return { stack: options.stack, present: false };
14
+ const states = [];
15
+ for (const item of owned) {
16
+ const body = item.id ? await getResourceById(client, item.id) : item;
17
+ const s = body.properties?.provisioningState;
18
+ states.push(typeof s === "string" ? s : "Succeeded");
19
+ }
20
+ const ok = states.filter((s) => s === "Succeeded").length;
21
+ return {
22
+ stack: options.stack,
23
+ present: true,
24
+ status: `${ok}/${states.length} resources succeeded`,
25
+ healthy: ok === states.length,
26
+ };
27
+ }
28
+ catch (err) {
29
+ if (isNotFound(err))
30
+ return { stack: options.stack, present: false };
31
+ return null;
32
+ }
33
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "d99147e1901c90e5697d439240fafed5f98c89812732ed93e073acb6ff0c0d44",
4
+ "manifest.json": "369520efc14abc28045333753c2ae150e635a0bece0da521edf7b11cb58ca934",
5
5
  "meta.json": "d2521221ed616d882ded4296f0071685bd74e4b645d55dabc9685733c37dc419",
6
6
  "types/index.d.ts": "29631032bda9533849178968290e65d72250eab0b8bab424f2d9ef9b4e8546a4",
7
7
  "rules/hardcoded-location.ts": "0b895449c8c94066e81721c46be24249231029fe13b471477456df5c4e45b8bf",
@@ -37,5 +37,5 @@
37
37
  "skills/chant-azure-patterns.md": "2d8cb8bbeda73b695856cec4ddaf5b3a0efce76e4ee6e3cf14b90ca69dee5757",
38
38
  "skills/chant-azure-aks.md": "2d4e0098c1a22b54ffadd410564d9df0f3a04cb4eb7c6261f20ae33106e7aca8"
39
39
  },
40
- "composite": "bea3406d9505353c387f90e59b8afdd477ca096e5d2ec3c67c57b7855335f4ed"
40
+ "composite": "e1f5a1afb2dfc2d52424da55d29e2c1868423c488d7987dfb81c8e448d8ef730"
41
41
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azure",
3
- "version": "0.124.46",
3
+ "version": "0.124.48",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Azure",
6
6
  "intrinsics": [
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAgB,MAAM,0BAA0B,CAAC;AA2B5E;;;;;GAKG;AACH,eAAO,MAAM,WAAW,EAAE,aA8dzB,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAgB,MAAM,0BAA0B,CAAC;AA2B5E;;;;;GAKG;AACH,eAAO,MAAM,WAAW,EAAE,aAoezB,CAAC"}
package/dist/plugin.js CHANGED
@@ -442,6 +442,11 @@ export const tags = defaultTags([
442
442
  const { packageLexicon } = await import("./codegen/package.js");
443
443
  await packageLexicon(opts);
444
444
  },
445
+ /** Deploy-unit status for `arm-deploy`'s resource group (#3142). */
446
+ async describeStackStatus(options) {
447
+ const { describeStackStatus } = await import("./describe-stack-status.js");
448
+ return describeStackStatus(options);
449
+ },
445
450
  async describeResources(options) {
446
451
  const { describeResources } = await import("./describe-resources.js");
447
452
  return describeResources(options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-azure",
3
- "version": "0.124.46",
3
+ "version": "0.124.48",
4
4
  "description": "Azure lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -79,6 +79,6 @@
79
79
  "typescript": "^5.9.3"
80
80
  },
81
81
  "peerDependencies": {
82
- "@intentius/chant": "^0.124.46"
82
+ "@intentius/chant": "^0.124.48"
83
83
  }
84
84
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The azure lexicon's registry login for core's `publish-image` (#3142): ACR,
3
+ * through `az acr login`. Core owns load, tag, push and the digest read-back;
4
+ * this only authenticates the local docker client.
5
+ */
6
+ import type { RegistryLoginBackend } from "@intentius/chant/components/verbs/publish-image";
7
+ import { defaultProcessRunner, q, type ProcessRunner } from "@intentius/chant/components/verbs/process-runner";
8
+
9
+ /** `<name>.azurecr.io` (also the sovereign-cloud suffixes). */
10
+ const ACR_HOST = /^([a-z0-9]+)\.azurecr\.(io|cn|us)$/i;
11
+
12
+ export function createAcrRegistryBackend(runner?: ProcessRunner): RegistryLoginBackend {
13
+ return {
14
+ name: "acr",
15
+ matches: (registry) => ACR_HOST.test(registry),
16
+ async login(registry) {
17
+ const m = ACR_HOST.exec(registry);
18
+ if (!m) throw new Error(`acr login: "${registry}" is not an Azure Container Registry host (<name>.azurecr.io)`);
19
+ await (runner ?? defaultProcessRunner()).run(`az acr login --name ${q(m[1]!)}`);
20
+ },
21
+ };
22
+ }
23
+
24
+ /** The ACR backend the azure plugin contributes, over the real process runner. */
25
+ export const acrRegistryBackend: RegistryLoginBackend = createAcrRegistryBackend();
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The azure lexicon's storage backend for core's `publish-artifact` (#4408):
3
+ * Azure Blob Storage through `az storage blob upload`. Core owns the content
4
+ * digest and the object URI; this only uploads.
5
+ */
6
+ import type { ArtifactStorageBackend } from "@intentius/chant/components/verbs/publish-artifact";
7
+ import { defaultProcessRunner, q, type ProcessRunner } from "@intentius/chant/components/verbs/process-runner";
8
+
9
+ /** `https://<account>.blob.core.windows.net/<container>/<blob path>` (also the sovereign-cloud suffixes). */
10
+ const BLOB_URI = /^https:\/\/([a-z0-9]+)\.blob\.core\.(windows\.net|chinacloudapi\.cn|usgovcloudapi\.net)\/([^/]+)\/(.+)$/i;
11
+
12
+ export function createAzureBlobStorageBackend(runner?: ProcessRunner): ArtifactStorageBackend {
13
+ return {
14
+ name: "azure-blob",
15
+ matches: (uri) => /^https:\/\/[a-z0-9]+\.blob\.core\./i.test(uri),
16
+ async upload({ from, to }) {
17
+ const m = BLOB_URI.exec(to);
18
+ if (!m) {
19
+ throw new Error(`azure-blob upload: "${to}" is not a blob URI (https://<account>.blob.core.windows.net/<container>/<path>)`);
20
+ }
21
+ await (runner ?? defaultProcessRunner()).run(
22
+ `az storage blob upload --account-name ${q(m[1]!)} --container-name ${q(m[3]!)} --name ${q(m[4]!)} --file ${q(from)} --overwrite --auth-mode login`,
23
+ );
24
+ },
25
+ };
26
+ }
27
+
28
+ /** The Blob backend the azure plugin contributes, over the real process runner. */
29
+ export const azureBlobStorageBackend: ArtifactStorageBackend = createAzureBlobStorageBackend();
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Typed step-builders for the azure lexicon's verbs (#3142), the same sugar the
3
+ * aws lexicon offers. `armDeploy({ template })` is exactly
4
+ * `{ kind: "arm-deploy", template }`, checked against the capability's input.
5
+ * `publishImage` is core's verb; azure contributes only the ACR login, chosen
6
+ * by the registry host or `$env.publish.backend`.
7
+ */
8
+ import { step } from "@intentius/chant/components";
9
+ import type { ArmDeployInput } from "./arm-deploy";
10
+ import type { WaitForDeploymentInput, WaitContainerAppInput } from "./wait-azure";
11
+
12
+ export const armDeploy = step<ArmDeployInput>("arm-deploy");
13
+ export const waitForDeployment = step<WaitForDeploymentInput>("wait-for-deployment");
14
+ export const waitContainerApp = step<WaitContainerAppInput>("wait-container-app");
@@ -7,9 +7,14 @@
7
7
  import type { Capability } from "@intentius/chant/components/capability";
8
8
  import { ownPackageVersion, type CapabilityPlugin } from "@intentius/chant/components/capability-plugin";
9
9
  import { armDeployCapability } from "./arm-deploy";
10
+ import { waitForDeploymentCapability, waitContainerAppCapability } from "./wait-azure";
11
+ import { acrRegistryBackend } from "./acr-backend";
12
+ import { azureBlobStorageBackend } from "./blob-backend";
10
13
 
11
14
  export const AZURE_VERB_FAMILIES = {
12
15
  apply: ["arm-deploy"],
16
+ waitVerify: ["wait-for-deployment", "wait-container-app"],
17
+ // `publish-image` is core's verb; azure contributes the "acr" registry login backend.
13
18
  } as const;
14
19
 
15
20
  export const azureCapabilityPlugin: CapabilityPlugin = {
@@ -18,7 +23,17 @@ export const azureCapabilityPlugin: CapabilityPlugin = {
18
23
  return ownPackageVersion(import.meta.url);
19
24
  },
20
25
  capabilities(): Array<Capability<never, unknown>> {
21
- return [armDeployCapability as Capability<never, unknown>];
26
+ return [
27
+ armDeployCapability,
28
+ waitForDeploymentCapability,
29
+ waitContainerAppCapability,
30
+ ] as unknown as Array<Capability<never, unknown>>;
31
+ },
32
+ registryBackends() {
33
+ return [acrRegistryBackend];
34
+ },
35
+ storageBackends() {
36
+ return [azureBlobStorageBackend];
22
37
  },
23
38
  families(): Record<string, readonly string[]> {
24
39
  return AZURE_VERB_FAMILIES;
@@ -7,3 +7,7 @@ export {
7
7
  type ArmDeployOutput,
8
8
  type ArmDeployPlanArtifact,
9
9
  } from "./arm-deploy";
10
+ export * from "./wait-azure";
11
+ export * from "./builders";
12
+ export { createAcrRegistryBackend, acrRegistryBackend } from "./acr-backend";
13
+ export { createAzureBlobStorageBackend, azureBlobStorageBackend } from "./blob-backend";
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { CapabilityRegistry } from "@intentius/chant/components/capability";
3
+ import { registerCapabilityPlugins } from "@intentius/chant/components/capability-plugin-loader";
4
+ import { starterCapabilityPlugin } from "@intentius/chant/components/starter-plugin";
5
+ import { createMockProcessRunner } from "@intentius/chant/components/verbs/__tests__/mock-process-runner";
6
+ import { awsCapabilityPlugin } from "@intentius/chant-lexicon-aws/components";
7
+ import { gcpCapabilityPlugin } from "@intentius/chant-lexicon-gcp/components";
8
+ import { azureCapabilityPlugin } from "./capability-plugin";
9
+ import { createAcrRegistryBackend } from "./acr-backend";
10
+
11
+ describe("ACR registry backend (#3142)", () => {
12
+ it("matches azurecr hosts only", () => {
13
+ const b = createAcrRegistryBackend(createMockProcessRunner().runner);
14
+ expect(b.matches("myreg.azurecr.io")).toBe(true);
15
+ expect(b.matches("123.dkr.ecr.us-east-1.amazonaws.com")).toBe(false);
16
+ });
17
+
18
+ it("logs in with az acr login by registry name", async () => {
19
+ const mock = createMockProcessRunner();
20
+ await createAcrRegistryBackend(mock.runner).login("myreg.azurecr.io");
21
+ expect(mock.calls.map((c) => c.command)).toEqual(["az acr login --name 'myreg'"]);
22
+ });
23
+
24
+ it("refuses a host that is not an ACR", async () => {
25
+ await expect(createAcrRegistryBackend(createMockProcessRunner().runner).login("example.com")).rejects.toThrow(/not an Azure Container Registry/);
26
+ });
27
+ });
28
+
29
+ describe("a project declaring aws plus azure plus gcp (#3142, #3143)", () => {
30
+ it("loads without a duplicate-verb error and keeps one publish-image", () => {
31
+ const registry = registerCapabilityPlugins(new CapabilityRegistry(), [
32
+ starterCapabilityPlugin,
33
+ awsCapabilityPlugin,
34
+ azureCapabilityPlugin,
35
+ gcpCapabilityPlugin,
36
+ ]);
37
+ expect(registry.has("publish-image")).toBe(true);
38
+ expect(registry.has("arm-deploy")).toBe(true);
39
+ expect(registry.has("gcp-apply")).toBe(true);
40
+ expect(registry.has("cfn-deploy")).toBe(true);
41
+ const backends = [awsCapabilityPlugin, azureCapabilityPlugin, gcpCapabilityPlugin].flatMap((p) => p.registryBackends?.() ?? []);
42
+ expect(backends.map((b) => b.name).sort()).toEqual(["acr", "artifact-registry", "ecr"]);
43
+ });
44
+
45
+ it("no lexicon registers publish-image itself", () => {
46
+ for (const p of [awsCapabilityPlugin, azureCapabilityPlugin, gcpCapabilityPlugin]) {
47
+ expect(p.capabilities().map((c) => c.kind)).not.toContain("publish-image");
48
+ }
49
+ });
50
+ });
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { CapabilityRegistry } from "@intentius/chant/components/capability";
6
+ import { registerCapabilityPlugins } from "@intentius/chant/components/capability-plugin-loader";
7
+ import { starterCapabilityPlugin } from "@intentius/chant/components/starter-plugin";
8
+ import { createPublishArtifactCapability } from "@intentius/chant/components/verbs/publish-artifact";
9
+ import { createMockProcessRunner } from "@intentius/chant/components/verbs/__tests__/mock-process-runner";
10
+ import { awsCapabilityPlugin } from "@intentius/chant-lexicon-aws/components";
11
+ import { gcpCapabilityPlugin, createGcsStorageBackend } from "@intentius/chant-lexicon-gcp/components";
12
+ import { azureCapabilityPlugin } from "./capability-plugin";
13
+ import { createAzureBlobStorageBackend } from "./blob-backend";
14
+
15
+ describe("Azure Blob storage backend (#4408)", () => {
16
+ it("matches blob hosts only", () => {
17
+ const b = createAzureBlobStorageBackend(createMockProcessRunner().runner);
18
+ expect(b.matches("https://acct.blob.core.windows.net/c/a.jar")).toBe(true);
19
+ expect(b.matches("s3://bucket/a.jar")).toBe(false);
20
+ });
21
+
22
+ it("uploads with az storage blob upload", async () => {
23
+ const mock = createMockProcessRunner();
24
+ await createAzureBlobStorageBackend(mock.runner).upload({ from: "/tmp/a.jar", to: "https://acct.blob.core.windows.net/cont/jars/a.jar" });
25
+ expect(mock.calls.map((c) => c.command)).toEqual([
26
+ "az storage blob upload --account-name 'acct' --container-name 'cont' --name 'jars/a.jar' --file '/tmp/a.jar' --overwrite --auth-mode login",
27
+ ]);
28
+ });
29
+
30
+ it("refuses a URI that is not a blob", async () => {
31
+ await expect(createAzureBlobStorageBackend(createMockProcessRunner().runner).upload({ from: "/a", to: "https://example.com/x" })).rejects.toThrow(/not a blob URI/);
32
+ });
33
+ });
34
+
35
+ describe("a project declaring aws plus azure plus gcp (#4408)", () => {
36
+ it("registers one publish-artifact and exposes three storage backends", () => {
37
+ const registry = registerCapabilityPlugins(new CapabilityRegistry(), [
38
+ starterCapabilityPlugin,
39
+ awsCapabilityPlugin,
40
+ azureCapabilityPlugin,
41
+ gcpCapabilityPlugin,
42
+ ]);
43
+ expect(registry.has("publish-artifact")).toBe(true);
44
+ const plugins = [awsCapabilityPlugin, azureCapabilityPlugin, gcpCapabilityPlugin];
45
+ expect(plugins.flatMap((p) => p.storageBackends?.() ?? []).map((b) => b.name).sort()).toEqual(["azure-blob", "gcs", "s3"]);
46
+ for (const p of plugins) expect(p.capabilities().map((c) => c.kind)).not.toContain("publish-artifact");
47
+ });
48
+
49
+ it("routes by destination URI to the right mocked backend", async () => {
50
+ const dir = mkdtempSync(join(tmpdir(), "chant-pa-"));
51
+ const file = join(dir, "a.jar");
52
+ writeFileSync(file, "J");
53
+ const az = createMockProcessRunner();
54
+ const gc = createMockProcessRunner();
55
+ const cap = createPublishArtifactCapability({
56
+ backends: [createAzureBlobStorageBackend(az.runner), createGcsStorageBackend(gc.runner)],
57
+ });
58
+ const ctx = { env: "dev", component: "c" } as never;
59
+ await cap.run(ctx, { from: file, to: "gs://bk/a.jar" });
60
+ await cap.run(ctx, { from: file, to: "https://acct.blob.core.windows.net/c/a.jar" });
61
+ expect(gc.calls.map((c) => c.command)).toEqual([`gcloud storage cp '${file}' 'gs://bk/a.jar'`]);
62
+ expect(az.calls).toHaveLength(1);
63
+ });
64
+ });
@@ -0,0 +1,96 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { AzHttp } from "../op/activities/az-apply";
3
+ import { createWaitContainerAppCapability, createWaitForDeploymentCapability } from "./wait-azure";
4
+ import { describeStackStatus } from "../describe-stack-status";
5
+
6
+ const ctx = { env: "dev", component: "web" };
7
+ const base = "/subscriptions/s/resourceGroups/web";
8
+
9
+ /** A group whose resources change state after each poll: `states[i]` per resource, per round. */
10
+ function fakeGroup(rounds: string[][], tags: Record<string, string> = { "chant-managed-by": "chant" }) {
11
+ let round = 0;
12
+ const http: AzHttp = async (_m, url) => {
13
+ const path = new URL(url).pathname;
14
+ if (path.endsWith("/resources")) {
15
+ const value = rounds[0]!.map((_, i) => ({ id: `${base}/providers/Microsoft.Storage/storageAccounts/a${i}`, name: `a${i}`, type: "Microsoft.Storage/storageAccounts", tags }));
16
+ return { status: 200, text: JSON.stringify({ value }) };
17
+ }
18
+ const i = Number(/a(\d+)$/.exec(path)![1]);
19
+ const state = rounds[Math.min(round, rounds.length - 1)]![i]!;
20
+ if (i === rounds[0]!.length - 1) round++;
21
+ return { status: 200, text: JSON.stringify({ name: `a${i}`, properties: { provisioningState: state } }) };
22
+ };
23
+ return http;
24
+ }
25
+
26
+ const fast = { sleep: async () => {}, now: () => 0 };
27
+
28
+ describe("wait-for-deployment (#3142)", () => {
29
+ it("polls until every resource is terminal", async () => {
30
+ const cap = createWaitForDeploymentCapability({ ...fast, http: fakeGroup([["Creating", "Succeeded"], ["Succeeded", "Succeeded"]]) });
31
+ const out = await cap.run(ctx, { endpoint: "http://x" });
32
+ expect(out.resources.map((r) => r.state)).toEqual(["Succeeded", "Succeeded"]);
33
+ });
34
+
35
+ it("throws when a resource fails", async () => {
36
+ const cap = createWaitForDeploymentCapability({ ...fast, http: fakeGroup([["Failed"]]) });
37
+ await expect(cap.run(ctx, { endpoint: "http://x" })).rejects.toThrow(/ended Failed/);
38
+ });
39
+
40
+ it("times out while still provisioning", async () => {
41
+ let t = 0;
42
+ const cap = createWaitForDeploymentCapability({ sleep: async () => {}, now: () => (t += 1000), http: fakeGroup([["Creating"]]) });
43
+ await expect(cap.run(ctx, { endpoint: "http://x", timeoutMs: 1500 })).rejects.toThrow(/timed out.*Creating/);
44
+ });
45
+
46
+ it("declares no rollback", () => {
47
+ expect(createWaitForDeploymentCapability().rollback).toBeUndefined();
48
+ });
49
+ });
50
+
51
+ describe("wait-container-app (#3142)", () => {
52
+ const app = (props: Record<string, unknown>): AzHttp => async () => ({ status: 200, text: JSON.stringify({ properties: props }) });
53
+
54
+ it("returns once the latest revision is the latest ready one", async () => {
55
+ let n = 0;
56
+ const http: AzHttp = async () => {
57
+ const ready = ++n > 1;
58
+ return {
59
+ status: 200,
60
+ text: JSON.stringify({ properties: { provisioningState: "Succeeded", latestRevisionName: "app--2", latestReadyRevisionName: ready ? "app--2" : "app--1", configuration: { ingress: { fqdn: "app.example" } } } }),
61
+ };
62
+ };
63
+ const out = await createWaitContainerAppCapability({ ...fast, http }).run(ctx, { app: "app", endpoint: "http://x" });
64
+ expect(out).toEqual({ revision: "app--2", fqdn: "app.example" });
65
+ expect(n).toBe(2);
66
+ });
67
+
68
+ it("throws on a failed provisioning state", async () => {
69
+ const cap = createWaitContainerAppCapability({ ...fast, http: app({ provisioningState: "Failed" }) });
70
+ await expect(cap.run(ctx, { app: "app", endpoint: "http://x" })).rejects.toThrow(/ended Failed/);
71
+ });
72
+ });
73
+
74
+ describe("azure describeStackStatus (#3142)", () => {
75
+ it("is present and healthy when owned resources all succeeded", async () => {
76
+ const obs = await describeStackStatus({ environment: "dev", stack: "web" }, { endpoint: "http://x", http: fakeGroup([["Succeeded", "Succeeded"]]) });
77
+ expect(obs).toEqual({ stack: "web", present: true, status: "2/2 resources succeeded", healthy: true });
78
+ });
79
+
80
+ it("is unhealthy while one is still creating", async () => {
81
+ const obs = await describeStackStatus({ environment: "dev", stack: "web" }, { endpoint: "http://x", http: fakeGroup([["Succeeded", "Creating"]]) });
82
+ expect(obs).toMatchObject({ present: true, healthy: false });
83
+ });
84
+
85
+ it("is absent when nothing in the group is chant-owned", async () => {
86
+ const obs = await describeStackStatus({ environment: "dev", stack: "web" }, { endpoint: "http://x", http: fakeGroup([["Succeeded"]], {}) });
87
+ expect(obs).toEqual({ stack: "web", present: false });
88
+ });
89
+
90
+ it("is absent on a 404 group and indeterminate on a server error", async () => {
91
+ const notFound: AzHttp = async () => ({ status: 404, text: "{}" });
92
+ expect(await describeStackStatus({ environment: "dev", stack: "web" }, { endpoint: "http://x", http: notFound })).toEqual({ stack: "web", present: false });
93
+ const boom: AzHttp = async () => ({ status: 500, text: "{}" });
94
+ expect(await describeStackStatus({ environment: "dev", stack: "web" }, { endpoint: "http://x", http: boom })).toBeNull();
95
+ });
96
+ });
@@ -0,0 +1,163 @@
1
+ /**
2
+ * wait / verify family, azure members (#3142): `wait-for-deployment` (every
3
+ * resource of a group reaches an ARM terminal provisioning state) and
4
+ * `wait-container-app` (a Container App serves its latest revision). Both
5
+ * poll ARM through the read client, so a test or floci-az swaps `http`. A wait
6
+ * is a read-only observation: neither declares a rollback.
7
+ */
8
+ import type { Capability } from "@intentius/chant/components/capability";
9
+ import { getResource, getResourceById, isNotFound, listResources, type AzureReadClientOptions } from "../api/read-client";
10
+ import type { AzHttp } from "../op/activities/az-apply";
11
+
12
+ const DEFAULT_INTERVAL_MS = 10_000;
13
+ const DEFAULT_TIMEOUT_MS = 10 * 60_000;
14
+ /** ARM provisioning states that will not change without a new request. */
15
+ const TERMINAL = new Set(["Succeeded", "Failed", "Canceled"]);
16
+
17
+ export interface AzureWaitOptions {
18
+ http?: AzHttp;
19
+ sleep?: (ms: number) => Promise<void>;
20
+ now?: () => number;
21
+ }
22
+
23
+ const realSleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
24
+
25
+ export interface TargetInput {
26
+ /** The resource group. Default: the component's name. */
27
+ resourceGroup?: string;
28
+ /** ARM endpoint. Default: `AZURE_ENDPOINT_URL`, else real Azure. */
29
+ endpoint?: string;
30
+ /** Subscription id. Default: `AZURE_SUBSCRIPTION_ID`, else floci-az's local subscription. */
31
+ subscriptionId?: string;
32
+ /** Poll interval in ms. Default: 10000. */
33
+ intervalMs?: number;
34
+ /** Overall timeout in ms. Default: 10 minutes. */
35
+ timeoutMs?: number;
36
+ }
37
+
38
+ function client(input: TargetInput, component: string, options: AzureWaitOptions): AzureReadClientOptions {
39
+ const endpoint = input.endpoint ?? process.env.AZURE_ENDPOINT_URL;
40
+ const subscriptionId = input.subscriptionId ?? process.env.AZURE_SUBSCRIPTION_ID;
41
+ return {
42
+ resourceGroup: input.resourceGroup ?? component,
43
+ ...(endpoint ? { endpoint } : {}),
44
+ ...(subscriptionId ? { subscriptionId } : {}),
45
+ ...(options.http ? { http: options.http } : {}),
46
+ };
47
+ }
48
+
49
+ function provisioningState(body: { properties?: Record<string, unknown> }): string | undefined {
50
+ const s = body.properties?.provisioningState;
51
+ return typeof s === "string" ? s : undefined;
52
+ }
53
+
54
+ // ── wait-for-deployment ──────────────────────────────────────────────────────
55
+
56
+ export type WaitForDeploymentInput = TargetInput;
57
+
58
+ export interface WaitForDeploymentOutput {
59
+ /** Resources of the group, each with the state it ended in. */
60
+ resources: Array<{ type: string; name: string; state: string }>;
61
+ }
62
+
63
+ /**
64
+ * Poll the group until no resource is still provisioning. Throws when one ends
65
+ * `Failed` or `Canceled`, or when the timeout passes first. A resource whose
66
+ * body carries no `provisioningState` (some types do not) counts as settled.
67
+ */
68
+ export function createWaitForDeploymentCapability(
69
+ options: AzureWaitOptions = {},
70
+ ): Capability<WaitForDeploymentInput, WaitForDeploymentOutput> {
71
+ const sleep = options.sleep ?? realSleep;
72
+ const now = options.now ?? Date.now;
73
+ return {
74
+ kind: "wait-for-deployment",
75
+ async run(ctx, input) {
76
+ const read = client(input, ctx.component, options);
77
+ const interval = input.intervalMs ?? DEFAULT_INTERVAL_MS;
78
+ const deadline = now() + (input.timeoutMs ?? DEFAULT_TIMEOUT_MS);
79
+ while (true) {
80
+ const listed = await listResources(read);
81
+ const resources: WaitForDeploymentOutput["resources"] = [];
82
+ for (const item of listed) {
83
+ const body = item.id ? await getResourceById(read, item.id) : item;
84
+ resources.push({
85
+ type: String(item.type ?? ""),
86
+ name: String(item.name ?? ""),
87
+ state: provisioningState(body) ?? "Succeeded",
88
+ });
89
+ }
90
+ const bad = resources.find((r) => r.state === "Failed" || r.state === "Canceled");
91
+ if (bad) throw new Error(`wait-for-deployment "${read.resourceGroup}": ${bad.type}/${bad.name} ended ${bad.state}`);
92
+ if (resources.every((r) => TERMINAL.has(r.state))) return { resources };
93
+ if (now() > deadline) {
94
+ const pending = resources.filter((r) => !TERMINAL.has(r.state)).map((r) => `${r.name} (${r.state})`).join(", ");
95
+ throw new Error(`wait-for-deployment "${read.resourceGroup}" timed out; still provisioning: ${pending}`);
96
+ }
97
+ await sleep(interval);
98
+ }
99
+ },
100
+ };
101
+ }
102
+
103
+ export const waitForDeploymentCapability: Capability<WaitForDeploymentInput, WaitForDeploymentOutput> =
104
+ createWaitForDeploymentCapability();
105
+
106
+ // ── wait-container-app ───────────────────────────────────────────────────────
107
+
108
+ export interface WaitContainerAppInput extends TargetInput {
109
+ /** The Container App's name. */
110
+ app: string;
111
+ }
112
+
113
+ export interface WaitContainerAppOutput {
114
+ /** The revision serving traffic once the app is steady. */
115
+ revision: string;
116
+ /** The app's FQDN, when it has ingress. */
117
+ fqdn?: string;
118
+ }
119
+
120
+ /**
121
+ * Poll a Container App until provisioning succeeded and its latest revision is
122
+ * the latest ready one. A rollout that fails provisioning throws.
123
+ */
124
+ export function createWaitContainerAppCapability(
125
+ options: AzureWaitOptions = {},
126
+ ): Capability<WaitContainerAppInput, WaitContainerAppOutput> {
127
+ const sleep = options.sleep ?? realSleep;
128
+ const now = options.now ?? Date.now;
129
+ return {
130
+ kind: "wait-container-app",
131
+ async run(ctx, input) {
132
+ const read = client(input, ctx.component, options);
133
+ const interval = input.intervalMs ?? DEFAULT_INTERVAL_MS;
134
+ const deadline = now() + (input.timeoutMs ?? DEFAULT_TIMEOUT_MS);
135
+ while (true) {
136
+ let state = "NotFound";
137
+ let ready = false;
138
+ let revision = "";
139
+ let fqdn: string | undefined;
140
+ try {
141
+ const body = await getResource(read, "Microsoft.App/containerApps", input.app);
142
+ const props = body.properties ?? {};
143
+ state = provisioningState(body) ?? "Unknown";
144
+ const latest = typeof props.latestRevisionName === "string" ? props.latestRevisionName : undefined;
145
+ const latestReady = typeof props.latestReadyRevisionName === "string" ? props.latestReadyRevisionName : undefined;
146
+ revision = latestReady ?? latest ?? "";
147
+ ready = state === "Succeeded" && (latest === undefined || latest === latestReady);
148
+ const ingress = (props.configuration as { ingress?: { fqdn?: unknown } } | undefined)?.ingress;
149
+ if (typeof ingress?.fqdn === "string") fqdn = ingress.fqdn;
150
+ } catch (err) {
151
+ if (!isNotFound(err)) throw err;
152
+ }
153
+ if (state === "Failed" || state === "Canceled") throw new Error(`wait-container-app "${input.app}" ended ${state}`);
154
+ if (ready) return { revision, ...(fqdn ? { fqdn } : {}) };
155
+ if (now() > deadline) throw new Error(`wait-container-app "${input.app}" timed out in state ${state}`);
156
+ await sleep(interval);
157
+ }
158
+ },
159
+ };
160
+ }
161
+
162
+ export const waitContainerAppCapability: Capability<WaitContainerAppInput, WaitContainerAppOutput> =
163
+ createWaitContainerAppCapability();
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Deploy-unit status for Azure (#3142, the #1495 seam). An `arm-deploy` unit is
3
+ * its resource group. It is present when the group holds chant-owned
4
+ * resources (the ownership tags `arm-deploy` stamps), and healthy when every
5
+ * one of them reports a `Succeeded` provisioning state. A read that cannot
6
+ * happen (no credentials, ARM unreachable) is indeterminate (`null`), never a
7
+ * confident absence; a group ARM says does not exist is `present: false`.
8
+ */
9
+ import type { StackStatusObservation } from "@intentius/chant/lexicon";
10
+ import { getResourceById, isNotFound, listResources, type AzureReadClientOptions } from "./api/read-client";
11
+ import { isChantOwned } from "./op/activities/az-apply";
12
+
13
+ export async function describeStackStatus(
14
+ options: { environment: string; stack: string },
15
+ read: Partial<AzureReadClientOptions> = {},
16
+ ): Promise<StackStatusObservation | null> {
17
+ const client: AzureReadClientOptions = {
18
+ ...(process.env.AZURE_ENDPOINT_URL ? { endpoint: process.env.AZURE_ENDPOINT_URL } : {}),
19
+ ...(process.env.AZURE_SUBSCRIPTION_ID ? { subscriptionId: process.env.AZURE_SUBSCRIPTION_ID } : {}),
20
+ ...read,
21
+ resourceGroup: options.stack,
22
+ };
23
+ try {
24
+ const owned = (await listResources(client)).filter((r) => isChantOwned(r.tags));
25
+ if (owned.length === 0) return { stack: options.stack, present: false };
26
+ const states: string[] = [];
27
+ for (const item of owned) {
28
+ const body = item.id ? await getResourceById(client, item.id) : item;
29
+ const s = body.properties?.provisioningState;
30
+ states.push(typeof s === "string" ? s : "Succeeded");
31
+ }
32
+ const ok = states.filter((s) => s === "Succeeded").length;
33
+ return {
34
+ stack: options.stack,
35
+ present: true,
36
+ status: `${ok}/${states.length} resources succeeded`,
37
+ healthy: ok === states.length,
38
+ };
39
+ } catch (err) {
40
+ if (isNotFound(err)) return { stack: options.stack, present: false };
41
+ return null;
42
+ }
43
+ }
package/src/plugin.ts CHANGED
@@ -468,6 +468,12 @@ export const tags = defaultTags([
468
468
  await packageLexicon(opts);
469
469
  },
470
470
 
471
+ /** Deploy-unit status for `arm-deploy`'s resource group (#3142). */
472
+ async describeStackStatus(options) {
473
+ const { describeStackStatus } = await import("./describe-stack-status");
474
+ return describeStackStatus(options);
475
+ },
476
+
471
477
  async describeResources(options) {
472
478
  const { describeResources } = await import("./describe-resources");
473
479
  return describeResources(options);