make-azure 0.2.0__tar.gz

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.
@@ -0,0 +1,19 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ .env
10
+
11
+ # wrangler caches its Pages upload state here (site/ is deployed by direct upload).
12
+ .wrangler/
13
+
14
+ # The VS Code extension in editors/vscode: `dist/` is above, and these two are what
15
+ # building a .vsix leaves behind. `npm install` is never run there -- the extension has
16
+ # no runtime dependency and both CLIs come from `npx --yes` -- so node_modules/ is
17
+ # listed to catch the day somebody tries.
18
+ node_modules/
19
+ *.vsix
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.5
2
+ Name: make-azure
3
+ Version: 0.2.0
4
+ Summary: Azure tasks for mkrun -- sign in with azure-identity, and create a free-tier virtual machine with nothing billed left behind.
5
+ Project-URL: Homepage, https://make.optersoft.com
6
+ Author-email: "Optersoft, S.L." <david@optersoft.com>
7
+ License-Expression: MIT OR Apache-2.0
8
+ Keywords: az,azure,free tier,mk,mkrun,virtual machine
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: azure-identity>=1.16
11
+ Requires-Dist: mkrun>=0.4.1
12
+ Description-Content-Type: text/markdown
13
+
14
+ # make-azure
15
+
16
+ A virtual machine on **Azure's free tier**, for [`mkrun`](https://pypi.org/project/mkrun/):
17
+ signed in with Microsoft's own `azure-identity`, created with every field that
18
+ decides the bill set explicitly, and removed with nothing billed left behind.
19
+ No `az` CLI.
20
+
21
+ ```python
22
+ # Makefile.py in a consuming repo
23
+ # /// script
24
+ # requires-python = ">=3.11"
25
+ # dependencies = ["mkrun>=0.4.1", "make-azure>=0.2"]
26
+ # ///
27
+ from make_azure import azure # importing is what registers the group
28
+ from make_azure.vm import Azure
29
+
30
+ Azure.configure(tenant="contoso.onmicrosoft.com", location="swedencentral") # optional
31
+ ```
32
+
33
+ ```console
34
+ $ mk azure.login --tenant <id or domain> # once; a browser opens
35
+ $ mk azure.login --tenant <t> --device-code # no browser here: enter a code elsewhere
36
+ $ mk azure.account # which subscription, and is it a free one
37
+ $ mk azure.vm box # Ubuntu 24.04 on a B1s, SSH open
38
+ $ mk azure.vm arm --size Standard_B2pts_v2
39
+ $ mk azure.vms # what the subscription has
40
+ $ mk azure.delete box # the VM and everything it made
41
+ $ mk -n azure.vm box # the plan; nothing is sent, no sign-in needed
42
+ ```
43
+
44
+ ## Signing in
45
+
46
+ `azure.login` runs the sign-in inside its own process, so the browser's
47
+ redirect goes back to the process that is waiting for it. It keeps two things:
48
+
49
+ - an **authentication record** (account, tenant, username; no secret) in
50
+ `~/.make/azure/<tenant>.json`, which says which account later calls use;
51
+ - the **tokens**, in MSAL's encrypted cache: the macOS Keychain, or libsecret
52
+ on Linux. Unencrypted storage is refused, never fallen back to.
53
+
54
+ Every later task gets its token silently from those two. It never opens a
55
+ browser in the middle of `azure.vm`: an expired sign-in is an error that says
56
+ `mk azure.login`.
57
+
58
+ In CI, an identity in the environment wins: `AZURE_CLIENT_ID` + `AZURE_TENANT_ID`
59
+ with `AZURE_FEDERATED_TOKEN_FILE` (workload identity) or `AZURE_CLIENT_SECRET`.
60
+ The secret's name marks it as a credential, so mkrun withholds it from child
61
+ processes and redacts it.
62
+
63
+ ⚠ **There is no `az` fallback**, on purpose. One address can be both a personal
64
+ Microsoft account and a work account, and silently reusing whichever `az`
65
+ session is around is how the wrong one gets used.
66
+
67
+ ⚠ **MFA is asked for at sign-in, on purpose.** Since Azure's mandatory MFA
68
+ (2025), Resource Manager answers any create, update or delete made without it
69
+ with `401 RequestDisallowedByAzure` and a claims challenge (`acrs: p1`). Reads
70
+ still pass, so everything up to the first write would work and then fail.
71
+ `azure.login` asks for those claims up front, and `arm` answers a challenge
72
+ silently if one still comes. The user does MFA once, at sign-in, as the portal
73
+ makes them.
74
+
75
+ ⚠ **School and work tenants often block `--device-code`.** Microsoft's managed
76
+ Conditional Access policy against device-code phishing answers
77
+ `AADSTS53003` *"does not meet the criteria"* after a successful sign-in, and
78
+ the device-code flow then polls until its code expires (about 15 minutes):
79
+ Ctrl-C. Use the browser flow, which the same policy allows. Measured on a
80
+ school tenant (Azure for Students), 2026-10-07: device code refused, browser
81
+ admitted, VM created, SSH in, deleted.
82
+
83
+ Two browser-flow traps, both with a fix on screen:
84
+
85
+ - **The redirect has to reach this process.** The sign-in URL is printed as
86
+ well as opened. Open it in the browser you actually sign in with; if you sign
87
+ in in some other tab, nothing comes back and `azure.login` times out after 5
88
+ minutes.
89
+ - **`state mismatch: … vs None`** means an *old* `localhost:8400` tab (a
90
+ previous *"Authentication complete"* page, reloaded or restored) answered
91
+ first. Close every such tab and run `azure.login` again.
92
+
93
+ `azure-identity` signs in as the Azure CLI's public client
94
+ (`04b07795-8ddb-461a-bbee-02f9e1bf7b46`), so a tenant that blocks `az` outright
95
+ blocks this too.
96
+
97
+ ## What "free" means here
98
+
99
+ An Azure free account gives, for its first 12 months, **750 hours a month** of
100
+ each of `Standard_B1s`, `Standard_B2ats_v2` (AMD) and `Standard_B2pts_v2` (Arm).
101
+ That's one VM running all month. It also gives **two 64 GiB P6 managed disks**.
102
+ Azure for Students carries the same free services. Everything else bills:
103
+
104
+ | | a portal or `az vm create` default | here |
105
+ |---|---|---|
106
+ | OS disk | 30 GiB → billed as **P4**, a meter the offer does not cover | **64 GiB Premium SSD = P6**, the free one |
107
+ | size | `Standard_DS1_v2` and friends | `Standard_B1s`; anything outside the three is refused |
108
+ | resource group | one you name, shared | `<name>-rg`, one per VM, so `delete` takes all of it |
109
+ | public IP | Standard static IPv4 | the same, **and it is billed** (about $3.65 a month) |
110
+
111
+ ⚠ **The public IPv4 is the one cost.** Basic public IPs, which the free account
112
+ used to cover, were retired on 2025-09-30, and a Standard IPv4 bills by the hour
113
+ whether or not the VM runs. `--no-public-ip` leaves it out; the VM is then
114
+ reachable only from inside its network.
115
+
116
+ ⚠ **A pay-as-you-go subscription takes the same request and pays for it.**
117
+ `azure.vm` reads the subscription's offer (`quotaId`) and refuses anything but
118
+ a free account or Azure for Students. A free account upgraded to pay-as-you-go
119
+ keeps its free hours until month 12 but reports the paid offer; `--paid-ok` is
120
+ for that case.
121
+
122
+ It also refuses, before creating anything: a sign-in that reaches no
123
+ subscription, or several when none is named; a region that will not sell the
124
+ size to this subscription (free accounts are often restricted in busy regions,
125
+ so try another `--location`); and a name already taken. A second VM of the
126
+ same size is allowed, with a warning: two running all month exceed the 750 hours.
127
+
128
+ ## How it talks to Azure
129
+
130
+ Plain REST to Resource Manager through `make.http`, with a bearer token from
131
+ `azure-identity`. No `azure-mgmt-*` SDKs. The VM is **one template
132
+ deployment** (`template.py`): network, NSG, IP, NIC and VM in a single request.
133
+ Azure works out the order, and a failure reports Azure's own reason
134
+ (`SkuNotAvailable: …`). `azure-identity` is imported only when a token is
135
+ needed, so `mk --list` never pays its import time.
136
+
137
+ ## Settings
138
+
139
+ `Azure.configure(...)` in the task file, an `[azure]` table in the config file,
140
+ or `MAKE_AZURE_<FIELD>` in the environment:
141
+
142
+ | field | default | |
143
+ |---|---|---|
144
+ | `tenant` | the only one `azure.login` saved | tenant id or domain |
145
+ | `subscription` | the only one the sign-in reaches | id or display name |
146
+ | `location` | `westeurope` | any region with B-series capacity |
147
+ | `size` | `Standard_B1s` | one of the three free sizes |
148
+ | `admin` | `azureuser` | the login user |
149
+ | `ssh_key` | `~/.ssh/id_ed25519.pub`, then `id_rsa.pub` | the public key the VM accepts |
150
+
151
+ The group is `azure`, and it merges with a repo's own `azure.*` tasks; this
152
+ package claims `login`, `account`, `vm`, `vms` and `delete`, nothing else.
@@ -0,0 +1,139 @@
1
+ # make-azure
2
+
3
+ A virtual machine on **Azure's free tier**, for [`mkrun`](https://pypi.org/project/mkrun/):
4
+ signed in with Microsoft's own `azure-identity`, created with every field that
5
+ decides the bill set explicitly, and removed with nothing billed left behind.
6
+ No `az` CLI.
7
+
8
+ ```python
9
+ # Makefile.py in a consuming repo
10
+ # /// script
11
+ # requires-python = ">=3.11"
12
+ # dependencies = ["mkrun>=0.4.1", "make-azure>=0.2"]
13
+ # ///
14
+ from make_azure import azure # importing is what registers the group
15
+ from make_azure.vm import Azure
16
+
17
+ Azure.configure(tenant="contoso.onmicrosoft.com", location="swedencentral") # optional
18
+ ```
19
+
20
+ ```console
21
+ $ mk azure.login --tenant <id or domain> # once; a browser opens
22
+ $ mk azure.login --tenant <t> --device-code # no browser here: enter a code elsewhere
23
+ $ mk azure.account # which subscription, and is it a free one
24
+ $ mk azure.vm box # Ubuntu 24.04 on a B1s, SSH open
25
+ $ mk azure.vm arm --size Standard_B2pts_v2
26
+ $ mk azure.vms # what the subscription has
27
+ $ mk azure.delete box # the VM and everything it made
28
+ $ mk -n azure.vm box # the plan; nothing is sent, no sign-in needed
29
+ ```
30
+
31
+ ## Signing in
32
+
33
+ `azure.login` runs the sign-in inside its own process, so the browser's
34
+ redirect goes back to the process that is waiting for it. It keeps two things:
35
+
36
+ - an **authentication record** (account, tenant, username; no secret) in
37
+ `~/.make/azure/<tenant>.json`, which says which account later calls use;
38
+ - the **tokens**, in MSAL's encrypted cache: the macOS Keychain, or libsecret
39
+ on Linux. Unencrypted storage is refused, never fallen back to.
40
+
41
+ Every later task gets its token silently from those two. It never opens a
42
+ browser in the middle of `azure.vm`: an expired sign-in is an error that says
43
+ `mk azure.login`.
44
+
45
+ In CI, an identity in the environment wins: `AZURE_CLIENT_ID` + `AZURE_TENANT_ID`
46
+ with `AZURE_FEDERATED_TOKEN_FILE` (workload identity) or `AZURE_CLIENT_SECRET`.
47
+ The secret's name marks it as a credential, so mkrun withholds it from child
48
+ processes and redacts it.
49
+
50
+ ⚠ **There is no `az` fallback**, on purpose. One address can be both a personal
51
+ Microsoft account and a work account, and silently reusing whichever `az`
52
+ session is around is how the wrong one gets used.
53
+
54
+ ⚠ **MFA is asked for at sign-in, on purpose.** Since Azure's mandatory MFA
55
+ (2025), Resource Manager answers any create, update or delete made without it
56
+ with `401 RequestDisallowedByAzure` and a claims challenge (`acrs: p1`). Reads
57
+ still pass, so everything up to the first write would work and then fail.
58
+ `azure.login` asks for those claims up front, and `arm` answers a challenge
59
+ silently if one still comes. The user does MFA once, at sign-in, as the portal
60
+ makes them.
61
+
62
+ ⚠ **School and work tenants often block `--device-code`.** Microsoft's managed
63
+ Conditional Access policy against device-code phishing answers
64
+ `AADSTS53003` *"does not meet the criteria"* after a successful sign-in, and
65
+ the device-code flow then polls until its code expires (about 15 minutes):
66
+ Ctrl-C. Use the browser flow, which the same policy allows. Measured on a
67
+ school tenant (Azure for Students), 2026-10-07: device code refused, browser
68
+ admitted, VM created, SSH in, deleted.
69
+
70
+ Two browser-flow traps, both with a fix on screen:
71
+
72
+ - **The redirect has to reach this process.** The sign-in URL is printed as
73
+ well as opened. Open it in the browser you actually sign in with; if you sign
74
+ in in some other tab, nothing comes back and `azure.login` times out after 5
75
+ minutes.
76
+ - **`state mismatch: … vs None`** means an *old* `localhost:8400` tab (a
77
+ previous *"Authentication complete"* page, reloaded or restored) answered
78
+ first. Close every such tab and run `azure.login` again.
79
+
80
+ `azure-identity` signs in as the Azure CLI's public client
81
+ (`04b07795-8ddb-461a-bbee-02f9e1bf7b46`), so a tenant that blocks `az` outright
82
+ blocks this too.
83
+
84
+ ## What "free" means here
85
+
86
+ An Azure free account gives, for its first 12 months, **750 hours a month** of
87
+ each of `Standard_B1s`, `Standard_B2ats_v2` (AMD) and `Standard_B2pts_v2` (Arm).
88
+ That's one VM running all month. It also gives **two 64 GiB P6 managed disks**.
89
+ Azure for Students carries the same free services. Everything else bills:
90
+
91
+ | | a portal or `az vm create` default | here |
92
+ |---|---|---|
93
+ | OS disk | 30 GiB → billed as **P4**, a meter the offer does not cover | **64 GiB Premium SSD = P6**, the free one |
94
+ | size | `Standard_DS1_v2` and friends | `Standard_B1s`; anything outside the three is refused |
95
+ | resource group | one you name, shared | `<name>-rg`, one per VM, so `delete` takes all of it |
96
+ | public IP | Standard static IPv4 | the same, **and it is billed** (about $3.65 a month) |
97
+
98
+ ⚠ **The public IPv4 is the one cost.** Basic public IPs, which the free account
99
+ used to cover, were retired on 2025-09-30, and a Standard IPv4 bills by the hour
100
+ whether or not the VM runs. `--no-public-ip` leaves it out; the VM is then
101
+ reachable only from inside its network.
102
+
103
+ ⚠ **A pay-as-you-go subscription takes the same request and pays for it.**
104
+ `azure.vm` reads the subscription's offer (`quotaId`) and refuses anything but
105
+ a free account or Azure for Students. A free account upgraded to pay-as-you-go
106
+ keeps its free hours until month 12 but reports the paid offer; `--paid-ok` is
107
+ for that case.
108
+
109
+ It also refuses, before creating anything: a sign-in that reaches no
110
+ subscription, or several when none is named; a region that will not sell the
111
+ size to this subscription (free accounts are often restricted in busy regions,
112
+ so try another `--location`); and a name already taken. A second VM of the
113
+ same size is allowed, with a warning: two running all month exceed the 750 hours.
114
+
115
+ ## How it talks to Azure
116
+
117
+ Plain REST to Resource Manager through `make.http`, with a bearer token from
118
+ `azure-identity`. No `azure-mgmt-*` SDKs. The VM is **one template
119
+ deployment** (`template.py`): network, NSG, IP, NIC and VM in a single request.
120
+ Azure works out the order, and a failure reports Azure's own reason
121
+ (`SkuNotAvailable: …`). `azure-identity` is imported only when a token is
122
+ needed, so `mk --list` never pays its import time.
123
+
124
+ ## Settings
125
+
126
+ `Azure.configure(...)` in the task file, an `[azure]` table in the config file,
127
+ or `MAKE_AZURE_<FIELD>` in the environment:
128
+
129
+ | field | default | |
130
+ |---|---|---|
131
+ | `tenant` | the only one `azure.login` saved | tenant id or domain |
132
+ | `subscription` | the only one the sign-in reaches | id or display name |
133
+ | `location` | `westeurope` | any region with B-series capacity |
134
+ | `size` | `Standard_B1s` | one of the three free sizes |
135
+ | `admin` | `azureuser` | the login user |
136
+ | `ssh_key` | `~/.ssh/id_ed25519.pub`, then `id_rsa.pub` | the public key the VM accepts |
137
+
138
+ The group is `azure`, and it merges with a repo's own `azure.*` tasks; this
139
+ package claims `login`, `account`, `vm`, `vms` and `delete`, nothing else.
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "make-azure"
3
+ version = "0.2.0"
4
+ description = "Azure tasks for mkrun -- sign in with azure-identity, and create a free-tier virtual machine with nothing billed left behind."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ # Same dual licence as mkrun, for the same reason: this member is generic and
8
+ # publishable. The licence texts live at the repository root; a built wheel
9
+ # carries the SPDX expression, which is what PEP 639 asks for.
10
+ license = "MIT OR Apache-2.0"
11
+ authors = [{ name = "Optersoft, S.L.", email = "david@optersoft.com" }]
12
+ keywords = ["azure", "virtual machine", "free tier", "az", "mk", "mkrun"]
13
+ # azure-identity owns the sign-in (MSAL, the encrypted token cache) and nothing
14
+ # else: the API calls are plain REST through `make.http`. Not the azure-mgmt-*
15
+ # SDKs -- three more packages and a slow import to wrap about six URLs. It is
16
+ # imported lazily, so `mk --list` never pays for it.
17
+ dependencies = ["mkrun>=0.4.1", "azure-identity>=1.16"]
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/make_azure"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://make.optersoft.com"
28
+
29
+ # The runner is the other half of this repository, so resolve it from the
30
+ # working tree instead of PyPI, exactly as the other members do.
31
+ [tool.uv.sources]
32
+ mkrun = { workspace = true }
33
+
34
+ [dependency-groups]
35
+ dev = ["pytest>=8", "ruff>=0.6"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
39
+ addopts = "-q"
40
+
41
+ # No [tool.ruff] here on purpose: every member shares the root config, so they
42
+ # cannot drift apart by an isort option.
@@ -0,0 +1,30 @@
1
+ """Azure tasks for mkrun -- the `azure` group.
2
+
3
+ Today it is one thing: a virtual machine on Azure's free tier. `azure.login`
4
+ signs in (azure-identity, no `az` CLI), `azure.vm` creates the VM, `azure.vms`
5
+ lists them, `azure.delete` removes one with everything it made, `azure.account`
6
+ says whether the subscription carries free services.
7
+
8
+ Importing is what registers it:
9
+
10
+ # Makefile.py
11
+ from make_azure import azure
12
+
13
+ The group merges with a repo's own `azure.*` tasks -- groups are namespaces,
14
+ and this package claims only the five names above.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ __version__ = "0.2.0"
20
+
21
+ __all__ = ["arm", "auth", "azure", "template", "vm"]
22
+
23
+
24
+ def __getattr__(name: str):
25
+ """Import on first access, keeping module scope import-free -- startup is a feature."""
26
+ if name in __all__:
27
+ import importlib
28
+
29
+ return importlib.import_module(f".{name}", __name__)
30
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,207 @@
1
+ """Azure Resource Manager over plain HTTPS, through `make.http`.
2
+
3
+ Every call is `https://management.azure.com/<path>?api-version=<v>` with a
4
+ bearer token from `auth`. Three things ARM does that a naive client misses:
5
+
6
+ - **Errors come in an envelope**: `{"error": {"code", "message", "details"}}`.
7
+ The code is the thing to read (`SkuNotAvailable`, `InvalidTemplateDeployment`,
8
+ `AuthorizationFailed`), so it leads the message.
9
+ - **Long operations answer 201/202 and keep going.** A deployment is polled until
10
+ its `provisioningState` is terminal; a delete is polled at its `Location`
11
+ header until that stops answering 202.
12
+ - **Lists page**: a `nextLink` is followed until there is none.
13
+
14
+ Under `--dry-run` nothing is sent and every call returns `None`, which callers
15
+ read as "unknown" -- a dry run prints the whole plan, as `make_cloudflare.pages`
16
+ does.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import time
23
+ from typing import Any
24
+ from urllib.parse import urlencode
25
+
26
+ from make import http, note
27
+ from make.errors import MakeError
28
+
29
+ from . import auth
30
+
31
+ __all__ = ["BASE", "call", "collect", "wait_deployment", "wait_location"]
32
+
33
+ BASE = "https://management.azure.com"
34
+
35
+ #: How long to wait for a VM deployment or a group delete before giving up.
36
+ TIMEOUT = 20 * 60
37
+
38
+
39
+ class _Token:
40
+ """One token per task run: fetched on first use, reused after."""
41
+
42
+ value: str | None = None
43
+ tenant: str | None = None
44
+
45
+
46
+ def _bearer(tenant: str, claims: str | None = None) -> str:
47
+ if claims or _Token.value is None or _Token.tenant != tenant:
48
+ _Token.value, _Token.tenant = auth.token(tenant, claims=claims), tenant
49
+ return _Token.value
50
+
51
+
52
+ def _challenge(answer: http.Response) -> str | None:
53
+ """The claims a 401 asks for, decoded -- ARM's way of saying "do MFA first"."""
54
+ import base64
55
+ import re
56
+
57
+ header = answer.headers.get("www-authenticate", "")
58
+ if answer.status != 401 or "insufficient_claims" not in header:
59
+ return None
60
+ found = re.search(r'claims="([^"]+)"', header)
61
+ if not found:
62
+ return None
63
+ encoded = found.group(1)
64
+ try:
65
+ return base64.b64decode(encoded + "=" * (-len(encoded) % 4)).decode()
66
+ except ValueError:
67
+ return None
68
+
69
+
70
+ def reset() -> None:
71
+ _Token.value = _Token.tenant = None
72
+
73
+
74
+ def url(path: str, api_version: str, **query: str) -> str:
75
+ return f"{BASE}{path}?" + urlencode({"api-version": api_version, **query})
76
+
77
+
78
+ def request(method: str, target: str, *, tenant: str = "", body: Any = None) -> http.Response | None:
79
+ """One request to a full URL; `None` under `--dry-run`. Raises on an ARM error."""
80
+ from make.context import current
81
+
82
+ # A dry run sends nothing, so it needs no sign-in either: the plan prints on a
83
+ # machine that has never run `azure.login`.
84
+ bearer = "<token>" if current().dry_run else _bearer(tenant)
85
+ headers = {"Authorization": f"Bearer {bearer}"}
86
+ data = None
87
+ if body is not None:
88
+ headers["Content-Type"] = "application/json"
89
+ data = json.dumps(body).encode()
90
+ answer = http.request(target, method=method, headers=headers, data=data, timeout=120.0)
91
+ if answer.skipped:
92
+ return None
93
+ claims = _challenge(answer)
94
+ if claims:
95
+ # Reads pass without MFA; a write gets this. Answer it once, silently, and
96
+ # retry; a second refusal is a real one and is raised below.
97
+ headers["Authorization"] = f"Bearer {_bearer(tenant, claims)}"
98
+ answer = http.request(target, method=method, headers=headers, data=data, timeout=120.0)
99
+ if answer.status == 0:
100
+ raise MakeError(f"{method} {target.split('?')[0]}: no answer from Azure")
101
+ if answer.status >= 400 and answer.status != 404:
102
+ error = _error(method, target, answer)
103
+ if _challenge(answer):
104
+ error.hint = "Azure wants MFA for this; `mk azure.login` signs in with it"
105
+ raise error
106
+ return answer
107
+
108
+
109
+ def call(
110
+ method: str,
111
+ path: str,
112
+ api_version: str,
113
+ *,
114
+ tenant: str = "",
115
+ body: Any = None,
116
+ missing_ok: bool = False,
117
+ **query: str,
118
+ ) -> Any:
119
+ """One ARM call, returning the parsed body. A 404 is `None` when `missing_ok`."""
120
+ answer = request(method, url(path, api_version, **query), tenant=tenant, body=body)
121
+ if answer is None:
122
+ return None
123
+ if answer.status == 404:
124
+ if missing_ok:
125
+ return None
126
+ raise _error(method, answer.url, answer)
127
+ return _json(answer)
128
+
129
+
130
+ def collect(path: str, api_version: str, *, tenant: str = "", **query: str) -> list[dict] | None:
131
+ """Every item of a list, across pages; `None` under `--dry-run`."""
132
+ found: list[dict] = []
133
+ target: str | None = url(path, api_version, **query)
134
+ while target:
135
+ answer = request("GET", target, tenant=tenant)
136
+ if answer is None:
137
+ return None
138
+ page = _json(answer)
139
+ found.extend(page.get("value") or [])
140
+ target = page.get("nextLink")
141
+ return found
142
+
143
+
144
+ def wait_deployment(
145
+ path: str, api_version: str, *, tenant: str = "", timeout: float = TIMEOUT
146
+ ) -> dict | None:
147
+ """Poll a deployment until it succeeds; raise with Azure's own reason if it does not."""
148
+ started = time.monotonic()
149
+ last = ""
150
+ while True:
151
+ found = call("GET", path, api_version, tenant=tenant)
152
+ if found is None:
153
+ return None
154
+ state = (found.get("properties") or {}).get("provisioningState", "?")
155
+ if state == "Succeeded":
156
+ return found
157
+ if state in ("Failed", "Canceled"):
158
+ error = (found.get("properties") or {}).get("error") or {}
159
+ raise MakeError(f"deployment {state.lower()}: {_describe(error)}")
160
+ elapsed = time.monotonic() - started
161
+ if elapsed > timeout:
162
+ raise MakeError(
163
+ f"deployment still {state} after {elapsed / 60:.0f} minutes", hint="see the portal"
164
+ )
165
+ if state != last:
166
+ note(f"{state.lower()}...")
167
+ last = state
168
+ time.sleep(5)
169
+
170
+
171
+ def wait_location(answer: http.Response | None, *, tenant: str = "", timeout: float = TIMEOUT) -> None:
172
+ """Follow a 202's `Location` until the operation stops answering 202."""
173
+ if answer is None or answer.status != 202:
174
+ return
175
+ location = answer.headers.get("location") or answer.headers.get("azure-asyncoperation")
176
+ if not location:
177
+ return
178
+ started = time.monotonic()
179
+ while True:
180
+ time.sleep(min(int(answer.headers.get("retry-after", "10") or 10), 30))
181
+ answer = request("GET", location, tenant=tenant)
182
+ if answer is None or answer.status != 202:
183
+ return
184
+ if time.monotonic() - started > timeout:
185
+ raise MakeError(f"still running after {timeout / 60:.0f} minutes", hint="see the portal")
186
+
187
+
188
+ def _json(answer: http.Response) -> Any:
189
+ try:
190
+ return json.loads(answer.body) if answer.body.strip() else {}
191
+ except ValueError:
192
+ return {}
193
+
194
+
195
+ def _describe(error: dict) -> str:
196
+ """`Code: message`, descending into `details` -- where the useful reason usually is."""
197
+ parts = [f"{error.get('code', '?')}: {error.get('message', '')}".strip()]
198
+ for detail in error.get("details") or []:
199
+ parts.append(_describe(detail))
200
+ return "; ".join(p for p in parts if p and p != "?:")
201
+
202
+
203
+ def _error(method: str, target: str, answer: http.Response) -> MakeError:
204
+ payload = _json(answer)
205
+ error = payload.get("error") if isinstance(payload, dict) else None
206
+ detail = _describe(error) if isinstance(error, dict) else answer.body[:300]
207
+ return MakeError(f"{method} {target.split('?')[0].removeprefix(BASE)} -> {answer.status}: {detail}")