make-azure 0.2.0__py3-none-any.whl

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.
make_azure/__init__.py ADDED
@@ -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}")
make_azure/arm.py ADDED
@@ -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}")
make_azure/auth.py ADDED
@@ -0,0 +1,229 @@
1
+ """Signing in to Azure, with `azure-identity` -- no `az` CLI.
2
+
3
+ mk azure.login --tenant <id> # a browser, once
4
+ mk azure.login --tenant <id> --device-code
5
+
6
+ `login` runs the sign-in **inside the task's own process**, so the browser's
7
+ redirect returns to the process waiting for it -- the step `az login` lost on
8
+ 2026-10-07. What it keeps:
9
+
10
+ - an `AuthenticationRecord` -- account id, tenant, username; no secret -- in
11
+ `~/.make/azure/<tenant>.json`, which says *which* account to use later;
12
+ - the tokens, in MSAL's **encrypted** persistent cache (the macOS Keychain,
13
+ libsecret on Linux). Unencrypted storage is refused, never fallen back to.
14
+
15
+ Every later call gets a token silently from those two. In CI, an identity in
16
+ the environment wins instead: `AZURE_CLIENT_ID` + `AZURE_TENANT_ID` with
17
+ `AZURE_FEDERATED_TOKEN_FILE` (workload identity) or `AZURE_CLIENT_SECRET`.
18
+
19
+ ⚠ **There is deliberately no `az` fallback.** Quietly reusing an `az` session
20
+ is how the wrong identity gets picked -- one address can be both a personal
21
+ Microsoft account and a work account, and nothing on screen tells them apart.
22
+
23
+ `azure.identity` costs hundreds of milliseconds to import, so it is imported
24
+ inside these functions and never at module scope: `mk --list` must not pay it.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from collections.abc import Iterator
30
+ from contextlib import contextmanager
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ from make import env, note, step
35
+ from make.context import mark_sensitive
36
+ from make.errors import ConfigError, MakeError
37
+
38
+ __all__ = ["SCOPE", "credential", "login", "record_path", "saved_tenants", "token"]
39
+
40
+ #: Azure Resource Manager -- every call this package makes.
41
+ SCOPE = "https://management.azure.com/.default"
42
+
43
+ #: The name of the encrypted token cache, shared by every repo on the machine.
44
+ CACHE = "mkrun-azure"
45
+
46
+ #: The claims challenge Resource Manager sends a write made without MFA
47
+ #: (`RequestDisallowedByAzure`, Azure's mandatory MFA, 2025-10). Asking for it at
48
+ #: sign-in means the user does MFA once, in `azure.login`, as the portal makes them.
49
+ MFA_CLAIMS = '{"access_token":{"acrs":{"essential":true,"values":["p1"]}}}'
50
+
51
+ #: Seconds `azure.login` waits for the browser's redirect before saying why it may not come.
52
+ LOGIN_TIMEOUT = 300
53
+
54
+
55
+ def records_dir() -> Path:
56
+ return env.config_dir() / "azure"
57
+
58
+
59
+ def record_path(tenant: str) -> Path:
60
+ return records_dir() / f"{tenant}.json"
61
+
62
+
63
+ def saved_tenants() -> list[str]:
64
+ """The tenants with a saved sign-in, oldest first."""
65
+ folder = records_dir()
66
+ if not folder.is_dir():
67
+ return []
68
+ files = sorted(folder.glob("*.json"), key=lambda p: p.stat().st_mtime)
69
+ return [f.stem for f in files]
70
+
71
+
72
+ def _cache_options() -> Any:
73
+ from azure.identity import TokenCachePersistenceOptions
74
+
75
+ return TokenCachePersistenceOptions(name=CACHE, allow_unencrypted_storage=False)
76
+
77
+
78
+ def _user_credential(
79
+ tenant: str, *, device_code: bool = False, record: Any = None, silent: bool = False
80
+ ) -> Any:
81
+ from azure.identity import DeviceCodeCredential, InteractiveBrowserCredential
82
+
83
+ options: dict[str, Any] = {"tenant_id": tenant, "cache_persistence_options": _cache_options()}
84
+ if record is not None:
85
+ options["authentication_record"] = record
86
+ if silent:
87
+ # Never pop a browser from the middle of `azure.vm`: an expired sign-in is
88
+ # an error naming `azure.login`, not a surprise window.
89
+ options["disable_automatic_authentication"] = True
90
+ if device_code:
91
+
92
+ def show(uri: str, code: str, _expires: Any) -> None:
93
+ step(f"open {uri} and enter the code {code}")
94
+
95
+ return DeviceCodeCredential(prompt_callback=show, **options)
96
+ if not silent:
97
+ options["timeout"] = LOGIN_TIMEOUT
98
+ return InteractiveBrowserCredential(**options)
99
+
100
+
101
+ @contextmanager
102
+ def _showing_the_url() -> Iterator[None]:
103
+ """Print the sign-in URL as well as opening it.
104
+
105
+ MSAL opens the default browser, which is not always the one the user signs in
106
+ with -- and when the sign-in lands in another tab, the redirect never reaches
107
+ this process and it waits for nothing. With the URL on screen the user opens
108
+ it where they mean to. It carries a PKCE challenge, not a secret.
109
+ """
110
+ import webbrowser
111
+
112
+ real = webbrowser.open
113
+
114
+ def opened(url: str, *args: Any, **kwargs: Any) -> bool:
115
+ step(f"sign in at: {url}")
116
+ note("open it in the browser you sign in with; this waits for its redirect")
117
+ return real(url, *args, **kwargs)
118
+
119
+ webbrowser.open = opened # type: ignore[assignment]
120
+ try:
121
+ yield
122
+ finally:
123
+ webbrowser.open = real # type: ignore[assignment]
124
+
125
+
126
+ def login(tenant: str, *, device_code: bool = False) -> str:
127
+ """Sign in to `tenant` and remember the account. Returns the username."""
128
+ from azure.core.exceptions import ClientAuthenticationError
129
+
130
+ credential = _user_credential(tenant, device_code=device_code)
131
+ try:
132
+ with _showing_the_url():
133
+ record = credential.authenticate(scopes=[SCOPE], claims=MFA_CLAIMS)
134
+ except ClientAuthenticationError as exc:
135
+ text = str(exc)
136
+ if "53003" in text or "timed out" in text.lower() or "timeout" in text.lower():
137
+ hint = (
138
+ "if the browser showed AADSTS53003, the tenant's Conditional Access refused this "
139
+ "device or flow -- no client can pass it. "
140
+ + (
141
+ "Device code is often blocked: try without --device-code"
142
+ if device_code
143
+ else _first_line(exc)
144
+ )
145
+ )
146
+ elif "state mismatch" in text:
147
+ # MSAL's local server takes the first request it gets. An old
148
+ # `localhost:8400` tab, reloaded or restored, gets there first with no
149
+ # `state` -- and still shows "Authentication complete". Measured 2026-10-07.
150
+ hint = "an old localhost:8400 tab answered first: close every such tab and run azure.login again"
151
+ else:
152
+ hint = _first_line(exc)
153
+ raise MakeError(f"sign-in to {tenant} failed", hint=hint) from exc
154
+ except Exception as exc: # msal-extensions: no encrypted store on this machine
155
+ if "persist" in type(exc).__name__.lower() or "encrypt" in str(exc).lower():
156
+ raise MakeError(
157
+ "no encrypted token cache on this machine",
158
+ hint="on Linux install libsecret (and a keyring daemon); tokens are never stored in the clear",
159
+ ) from exc
160
+ raise
161
+ path = record_path(tenant)
162
+ path.parent.mkdir(parents=True, exist_ok=True)
163
+ path.write_text(record.serialize())
164
+ note(f"signed in as {record.username}; remembered in {path}")
165
+ return str(record.username)
166
+
167
+
168
+ def credential(tenant: str = "") -> Any:
169
+ """The identity to act as: the environment's, else a saved sign-in."""
170
+ client = env.get("AZURE_CLIENT_ID")
171
+ env_tenant = env.get("AZURE_TENANT_ID")
172
+ if client and env_tenant:
173
+ token_file = env.get("AZURE_FEDERATED_TOKEN_FILE")
174
+ if token_file:
175
+ from azure.identity import WorkloadIdentityCredential
176
+
177
+ return WorkloadIdentityCredential(
178
+ tenant_id=env_tenant, client_id=client, token_file_path=token_file
179
+ )
180
+ secret = env.secret("AZURE_CLIENT_SECRET", export_to_children=False)
181
+ if secret:
182
+ from azure.identity import ClientSecretCredential
183
+
184
+ return ClientSecretCredential(env_tenant, client, secret)
185
+
186
+ if not tenant:
187
+ saved = saved_tenants()
188
+ if len(saved) > 1:
189
+ raise ConfigError(
190
+ f"signed in to {len(saved)} tenants: {', '.join(saved)}",
191
+ hint="Azure.configure(tenant=...) says which one to use",
192
+ )
193
+ tenant = saved[0] if saved else ""
194
+ path = record_path(tenant) if tenant else None
195
+ if path is None or not path.is_file():
196
+ raise ConfigError(
197
+ "not signed in to Azure" + (f" (tenant {tenant})" if tenant else ""),
198
+ hint=f"mk azure.login --tenant {tenant or '<tenant id or domain>'}",
199
+ )
200
+ from azure.identity import AuthenticationRecord
201
+
202
+ record = AuthenticationRecord.deserialize(path.read_text())
203
+ return _user_credential(tenant, record=record, silent=True)
204
+
205
+
206
+ def token(tenant: str = "", *, claims: str | None = None) -> str:
207
+ """A bearer token for Resource Manager, masked in everything printed.
208
+
209
+ `claims` is a challenge ARM sent back (see `arm.request`): the token is then
210
+ fetched again, silently, carrying it -- which works when the saved session
211
+ already did MFA, and otherwise says `mk azure.login`.
212
+ """
213
+ from azure.core.exceptions import ClientAuthenticationError
214
+
215
+ try:
216
+ value = credential(tenant).get_token(SCOPE, claims=claims).token
217
+ except ClientAuthenticationError as exc:
218
+ # AuthenticationRequiredError is a subclass: the refresh token expired.
219
+ raise MakeError(
220
+ "the saved Azure sign-in no longer works",
221
+ hint=f"mk azure.login --tenant {tenant or '<tenant>'} ({_first_line(exc)})",
222
+ ) from exc
223
+ mark_sensitive(value)
224
+ return value
225
+
226
+
227
+ def _first_line(exc: BaseException) -> str:
228
+ text = str(exc).strip()
229
+ return text.splitlines()[0][:300] if text else type(exc).__name__
make_azure/azure.py ADDED
@@ -0,0 +1,86 @@
1
+ """The `azure` group: a free-tier virtual machine, made and removed.
2
+
3
+ mk azure.login --tenant T sign in once (a browser; --device-code without one)
4
+ mk azure.account which subscription, and whether it is free
5
+ mk azure.vm NAME create one (Ubuntu 24.04, B1s, 64 GiB P6, SSH open)
6
+ mk azure.vms what the subscription has
7
+ mk azure.delete NAME remove it, and everything it made
8
+
9
+ Settings come from `Azure.configure(...)` in the task file, the `[azure]`
10
+ config table, or `MAKE_AZURE_*` -- see `vm.Azure`. No `az` CLI is involved.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Annotated
16
+
17
+ from make import arg, group, note, step
18
+
19
+ from . import auth
20
+ from . import vm as azure_vm
21
+
22
+ __all__ = ["azure"]
23
+
24
+ azure = group("azure")
25
+
26
+
27
+ @azure.task(name="login")
28
+ def login(
29
+ *,
30
+ tenant: Annotated[str | None, arg(help="tenant id or domain; default Azure.tenant")] = None,
31
+ device_code: Annotated[
32
+ bool, arg(help="print a code to enter on another device; no browser here")
33
+ ] = False,
34
+ ) -> None:
35
+ """Sign in to Azure and remember the account; tokens go in the encrypted cache."""
36
+ chosen = tenant or azure_vm.Azure.get().tenant
37
+ if not chosen:
38
+ from make.errors import ConfigError
39
+
40
+ raise ConfigError("which tenant?", hint="mk azure.login --tenant <id or domain>")
41
+ auth.login(chosen, device_code=device_code)
42
+
43
+
44
+ @azure.task(name="account")
45
+ def account() -> None:
46
+ """The subscription this sign-in acts in, and whether its offer carries free services."""
47
+ entry = azure_vm.subscription()
48
+ offer = azure_vm.free_offer(entry)
49
+ step(f"{entry.get('displayName')} {entry.get('subscriptionId')}")
50
+ note(f"free services: {offer}" if offer else "no free services: every VM here is billed")
51
+
52
+
53
+ @azure.task(name="vm", dangerous=True)
54
+ def vm(
55
+ name: str,
56
+ *,
57
+ location: str | None = None,
58
+ size: Annotated[str | None, arg(help="Standard_B1s, Standard_B2ats_v2 or Standard_B2pts_v2")] = None,
59
+ no_public_ip: Annotated[
60
+ bool, arg(help="skip the IPv4, the one billed part; no SSH from outside")
61
+ ] = False,
62
+ paid_ok: Annotated[bool, arg(help="allow a subscription without free services")] = False,
63
+ ) -> None:
64
+ """Create a free-tier Ubuntu VM in a resource group of its own."""
65
+ azure_vm.create(name, location=location, size=size, public_ip=not no_public_ip, paid_ok=paid_ok)
66
+
67
+
68
+ @azure.task(name="vms")
69
+ def vms() -> None:
70
+ """The subscription's VMs: size, power state, address."""
71
+ sub = azure_vm.subscription()["subscriptionId"]
72
+ found = azure_vm.list_vms(sub)
73
+ if not found:
74
+ note("no virtual machines")
75
+ return
76
+ addresses = azure_vm.public_ips(sub)
77
+ for entry in found:
78
+ row = azure_vm.describe(entry, addresses)
79
+ cost = "free" if row["free"] else "billed"
80
+ step(f"{row['name']} {row['size']} ({cost}) {row['state']:<12} {row['ip'] or '--'}")
81
+
82
+
83
+ @azure.task(name="delete", dangerous=True)
84
+ def delete(name: str, *, no_wait: bool = False) -> None:
85
+ """Delete a VM made by `azure.vm`, with its disk, network and IP."""
86
+ azure_vm.delete(name, wait=not no_wait)
make_azure/template.py ADDED
@@ -0,0 +1,174 @@
1
+ """The ARM template for one free-tier VM: network, NSG, IP, NIC, VM -- one deployment.
2
+
3
+ A template rather than five ordered PUTs: Azure works out the order from
4
+ `dependsOn`, and a failure leaves one deployment saying why instead of a half-built
5
+ group. It is a dict, not a JSON file, so a test can assert the fields that decide
6
+ the bill -- size, disk size, disk SKU -- without a network.
7
+
8
+ Every `apiVersion` is pinned here and nowhere else; they age, and this is the one
9
+ place to move them.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ __all__ = ["DISK_GB", "DISK_SKU", "IMAGES", "vm_template"]
17
+
18
+ NETWORK_API = "2023-09-01"
19
+ COMPUTE_API = "2024-07-01"
20
+
21
+ #: The P6 tier: what the free offer covers, and the smallest disk it covers.
22
+ #: Ubuntu's default 30 GiB is billed as P4 -- a different meter, not free.
23
+ DISK_GB = 64
24
+ DISK_SKU = "Premium_LRS"
25
+
26
+ #: Ubuntu 24.04 LTS (Gen2). The x64 `server` SKU and the Arm `server-arm64` one.
27
+ IMAGES = {
28
+ "x64": {"publisher": "Canonical", "offer": "ubuntu-24_04-lts", "sku": "server", "version": "latest"},
29
+ "Arm64": {
30
+ "publisher": "Canonical",
31
+ "offer": "ubuntu-24_04-lts",
32
+ "sku": "server-arm64",
33
+ "version": "latest",
34
+ },
35
+ }
36
+
37
+
38
+ def _id(kind: str, name: str) -> str:
39
+ return f"[resourceId('{kind}', '{name}')]"
40
+
41
+
42
+ def vm_template(
43
+ name: str, *, size: str, arch: str, admin: str, public_key: str, location: str, public_ip: bool = True
44
+ ) -> dict[str, Any]:
45
+ """The whole template. Its one output, `publicIp`, is the address or `""`."""
46
+ vnet, subnet, nsg, ip, nic = (f"{name}-vnet", "default", f"{name}-nsg", f"{name}-ip", f"{name}-nic")
47
+ rules = [
48
+ {
49
+ "name": "SSH",
50
+ "properties": {
51
+ "priority": 1000,
52
+ "protocol": "Tcp",
53
+ "access": "Allow",
54
+ "direction": "Inbound",
55
+ "sourceAddressPrefix": "*",
56
+ "sourcePortRange": "*",
57
+ "destinationAddressPrefix": "*",
58
+ "destinationPortRange": "22",
59
+ },
60
+ }
61
+ ]
62
+ resources: list[dict[str, Any]] = [
63
+ {
64
+ "type": "Microsoft.Network/networkSecurityGroups",
65
+ "apiVersion": NETWORK_API,
66
+ "name": nsg,
67
+ "location": location,
68
+ "properties": {"securityRules": rules if public_ip else []},
69
+ },
70
+ {
71
+ "type": "Microsoft.Network/virtualNetworks",
72
+ "apiVersion": NETWORK_API,
73
+ "name": vnet,
74
+ "location": location,
75
+ "properties": {
76
+ "addressSpace": {"addressPrefixes": ["10.0.0.0/16"]},
77
+ "subnets": [{"name": subnet, "properties": {"addressPrefix": "10.0.0.0/24"}}],
78
+ },
79
+ },
80
+ ]
81
+ ip_config: dict[str, Any] = {
82
+ "privateIPAllocationMethod": "Dynamic",
83
+ "subnet": {"id": f"[resourceId('Microsoft.Network/virtualNetworks/subnets', '{vnet}', '{subnet}')]"},
84
+ }
85
+ nic_depends = [
86
+ _id("Microsoft.Network/virtualNetworks", vnet),
87
+ _id("Microsoft.Network/networkSecurityGroups", nsg),
88
+ ]
89
+ if public_ip:
90
+ # Standard is the only SKU left (Basic retired 2025-09-30), and Standard is
91
+ # Static. It bills by the hour: the one line on this VM's bill.
92
+ resources.append(
93
+ {
94
+ "type": "Microsoft.Network/publicIPAddresses",
95
+ "apiVersion": NETWORK_API,
96
+ "name": ip,
97
+ "location": location,
98
+ "sku": {"name": "Standard"},
99
+ "properties": {"publicIPAllocationMethod": "Static", "deleteOption": "Delete"},
100
+ }
101
+ )
102
+ ip_config["publicIPAddress"] = {"id": _id("Microsoft.Network/publicIPAddresses", ip)}
103
+ nic_depends.append(_id("Microsoft.Network/publicIPAddresses", ip))
104
+ resources.append(
105
+ {
106
+ "type": "Microsoft.Network/networkInterfaces",
107
+ "apiVersion": NETWORK_API,
108
+ "name": nic,
109
+ "location": location,
110
+ "dependsOn": nic_depends,
111
+ "properties": {
112
+ "ipConfigurations": [{"name": "ipconfig1", "properties": ip_config}],
113
+ "networkSecurityGroup": {"id": _id("Microsoft.Network/networkSecurityGroups", nsg)},
114
+ },
115
+ }
116
+ )
117
+ machine: dict[str, Any] = {
118
+ "type": "Microsoft.Compute/virtualMachines",
119
+ "apiVersion": COMPUTE_API,
120
+ "name": name,
121
+ "location": location,
122
+ "tags": {"managed-by": "mkrun"},
123
+ "dependsOn": [_id("Microsoft.Network/networkInterfaces", nic)],
124
+ "properties": {
125
+ "hardwareProfile": {"vmSize": size},
126
+ "storageProfile": {
127
+ "imageReference": IMAGES[arch],
128
+ "osDisk": {
129
+ "createOption": "FromImage",
130
+ "diskSizeGB": DISK_GB,
131
+ "managedDisk": {"storageAccountType": DISK_SKU},
132
+ "deleteOption": "Delete",
133
+ },
134
+ },
135
+ "osProfile": {
136
+ "computerName": name,
137
+ "adminUsername": admin,
138
+ "linuxConfiguration": {
139
+ "disablePasswordAuthentication": True,
140
+ "ssh": {
141
+ "publicKeys": [
142
+ {"path": f"/home/{admin}/.ssh/authorized_keys", "keyData": public_key.strip()}
143
+ ]
144
+ },
145
+ },
146
+ },
147
+ "networkProfile": {
148
+ "networkInterfaces": [
149
+ {
150
+ "id": _id("Microsoft.Network/networkInterfaces", nic),
151
+ "properties": {"deleteOption": "Delete"},
152
+ }
153
+ ]
154
+ },
155
+ },
156
+ }
157
+ if arch == "x64":
158
+ # Trusted Launch is free and Azure's default for Gen2 images; Arm VMs do not support it.
159
+ machine["properties"]["securityProfile"] = {
160
+ "securityType": "TrustedLaunch",
161
+ "uefiSettings": {"secureBootEnabled": True, "vTpmEnabled": True},
162
+ }
163
+ resources.append(machine)
164
+ output = (
165
+ f"[reference(resourceId('Microsoft.Network/publicIPAddresses', '{ip}'), '{NETWORK_API}').ipAddress]"
166
+ if public_ip
167
+ else ""
168
+ )
169
+ return {
170
+ "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
171
+ "contentVersion": "1.0.0.0",
172
+ "resources": resources,
173
+ "outputs": {"publicIp": {"type": "string", "value": output}},
174
+ }
make_azure/vm.py ADDED
@@ -0,0 +1,342 @@
1
+ """A virtual machine on Azure's free tier.
2
+
3
+ from make_azure import vm
4
+
5
+ vm.create("box") # Ubuntu 24.04 on a B1s, 64 GiB P6 disk, SSH open
6
+ vm.delete("box") # its resource group, and so everything it made
7
+
8
+ Azure's free account gives three VM sizes 750 hours a month each for its first
9
+ 12 months -- `Standard_B1s`, `Standard_B2ats_v2` (AMD) and `Standard_B2pts_v2`
10
+ (Arm) -- plus two 64 GiB P6 managed disks. 750 hours is one VM running all
11
+ month. Everything else costs money, which is why every field that decides the
12
+ bill is set explicitly in `template.py`:
13
+
14
+ - **The OS disk is 64 GiB Premium SSD, on purpose.** Azure bills a managed disk
15
+ by the tier its size falls in, and the free offer is the P6 tier. Ubuntu's
16
+ default 30 GiB lands on P4 -- a smaller disk, a different meter, and a charge.
17
+ - **The public IPv4 is NOT free.** Basic public IPs, the ones the free account
18
+ once covered, were retired on 2025-09-30; a Standard static IPv4 is billed by
19
+ the hour whether or not the VM runs. `create` says so, and `public_ip=False`
20
+ leaves it out (and the VM unreachable from outside).
21
+ - **Everything goes in one resource group of its own**, so `delete` removes the
22
+ group and nothing that bills -- a NIC, a disk, an IP -- outlives the VM.
23
+
24
+ The free hours exist only on a free account (or Azure for Students), or on one
25
+ upgraded from it during its first 12 months. A pay-as-you-go subscription takes
26
+ the same request and bills it, so `create` reads the subscription's offer first
27
+ and refuses anything else unless told `paid_ok=True`.
28
+
29
+ The sign-in is `auth` (`mk azure.login`); the calls are `arm`. No `az`.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+ from typing import Any
37
+ from urllib.parse import quote
38
+
39
+ from make import config, note, step, warn
40
+ from make.errors import ConfigError, MakeError
41
+
42
+ from . import arm
43
+ from .template import DISK_GB, vm_template
44
+
45
+ __all__ = [
46
+ "FREE_SIZES",
47
+ "Azure",
48
+ "create",
49
+ "delete",
50
+ "free_offer",
51
+ "list_vms",
52
+ "resource_group",
53
+ "ssh_key",
54
+ "subscription",
55
+ ]
56
+
57
+ #: The sizes the free account covers, and the architecture each one runs.
58
+ FREE_SIZES = {"Standard_B1s": "x64", "Standard_B2ats_v2": "x64", "Standard_B2pts_v2": "Arm64"}
59
+
60
+ #: `subscriptionPolicies.quotaId` prefixes of the offers that carry free services.
61
+ FREE_OFFERS = ("FreeTrial_", "AzureForStudents_")
62
+
63
+ SUBSCRIPTIONS_API = "2022-12-01"
64
+ RESOURCES_API = "2022-09-01"
65
+ SKUS_API = "2021-07-01"
66
+ COMPUTE_API = "2024-07-01"
67
+ NETWORK_API = "2023-09-01"
68
+
69
+
70
+ @config.section("azure")
71
+ @dataclass
72
+ class Azure:
73
+ """Who signs in, where the VM goes, and who logs in to it."""
74
+
75
+ tenant: str = ""
76
+ """The Entra tenant (id or domain). Empty: the only one `azure.login` has saved."""
77
+
78
+ subscription: str = ""
79
+ """Subscription id or name. Empty: the only one the sign-in can reach."""
80
+
81
+ location: str = "westeurope"
82
+ """Any region that offers the size; the free hours are not tied to one."""
83
+
84
+ size: str = "Standard_B1s"
85
+ """One of `FREE_SIZES`. B1s is 1 vCPU and 1 GiB; the v2 sizes are 2 vCPU and 1 GiB."""
86
+
87
+ admin: str = "azureuser"
88
+ """The login user on the VM."""
89
+
90
+ ssh_key: str = ""
91
+ """A public key file. Empty: `~/.ssh/id_ed25519.pub`, then `~/.ssh/id_rsa.pub`."""
92
+
93
+
94
+ def _tenant() -> str:
95
+ return Azure.get().tenant
96
+
97
+
98
+ # -- the account -----------------------------------------------------------
99
+
100
+
101
+ def subscriptions() -> list[dict] | None:
102
+ """Every subscription the sign-in reaches; `None` under `--dry-run`."""
103
+ return arm.collect("/subscriptions", SUBSCRIPTIONS_API, tenant=_tenant())
104
+
105
+
106
+ def subscription() -> dict:
107
+ """The subscription to act in, refusing a sign-in that has none.
108
+
109
+ Under `--dry-run` a stand-in comes back, so the plan keeps going.
110
+ """
111
+ found = subscriptions()
112
+ if found is None:
113
+ return {
114
+ "subscriptionId": "<subscription>",
115
+ "displayName": "<dry-run>",
116
+ "subscriptionPolicies": {"quotaId": "FreeTrial_<dry-run>"},
117
+ }
118
+ wanted = Azure.get().subscription
119
+ if wanted:
120
+ for entry in found:
121
+ if wanted in (entry.get("subscriptionId"), entry.get("displayName")):
122
+ return entry
123
+ raise ConfigError(
124
+ f"the sign-in cannot reach subscription {wanted}",
125
+ hint="it reaches: " + (", ".join(e.get("displayName", "?") for e in found) or "none"),
126
+ )
127
+ enabled = [e for e in found if e.get("state", "Enabled") == "Enabled"]
128
+ if not enabled:
129
+ raise MakeError(
130
+ "the Azure sign-in has no subscription",
131
+ hint="sign up for a free account at https://azure.microsoft.com/free, or "
132
+ "`mk azure.login --tenant <id>` as an identity that has one",
133
+ )
134
+ if len(enabled) > 1:
135
+ raise ConfigError(
136
+ f"the sign-in reaches {len(enabled)} subscriptions",
137
+ hint="Azure.configure(subscription=...) -- one of: "
138
+ + ", ".join(e.get("displayName", "?") for e in enabled),
139
+ )
140
+ return enabled[0]
141
+
142
+
143
+ def free_offer(entry: dict) -> str | None:
144
+ """The subscription's offer (`quotaId`) if it carries the free services, else None."""
145
+ quota = (entry.get("subscriptionPolicies") or {}).get("quotaId", "")
146
+ return quota if quota.startswith(FREE_OFFERS) else None
147
+
148
+
149
+ def ssh_key(given: str = "") -> Path:
150
+ """The public key the VM will accept."""
151
+ candidates = (
152
+ [Path(given).expanduser()]
153
+ if given
154
+ else [Path("~/.ssh/id_ed25519.pub").expanduser(), Path("~/.ssh/id_rsa.pub").expanduser()]
155
+ )
156
+ for candidate in candidates:
157
+ if candidate.is_file():
158
+ return candidate
159
+ raise ConfigError(
160
+ f"no SSH public key at {', '.join(str(c) for c in candidates)}",
161
+ hint="ssh-keygen -t ed25519, or Azure.configure(ssh_key=...)",
162
+ )
163
+
164
+
165
+ def resource_group(name: str) -> str:
166
+ """The resource group a VM lives in: one per VM, so deleting it deletes all of it."""
167
+ return f"{name}-rg"
168
+
169
+
170
+ def _group_path(sub: str, name: str) -> str:
171
+ return f"/subscriptions/{sub}/resourcegroups/{resource_group(name)}"
172
+
173
+
174
+ # -- the VM ----------------------------------------------------------------
175
+
176
+
177
+ def _available(sub: str, size: str, location: str) -> None:
178
+ """Refuse a size the region will not sell this subscription.
179
+
180
+ Free accounts are routinely restricted from B-series capacity in the busy
181
+ regions; the deployment would find out only after the group exists, as
182
+ `SkuNotAvailable`. Asking first costs one call and names the fix.
183
+ """
184
+ skus = arm.collect(
185
+ f"/subscriptions/{sub}/providers/Microsoft.Compute/skus",
186
+ SKUS_API,
187
+ tenant=_tenant(),
188
+ **{"$filter": f"location eq '{location}'"},
189
+ )
190
+ if skus is None:
191
+ return
192
+ matches = [s for s in skus if s.get("name") == size and s.get("resourceType") == "virtualMachines"]
193
+ if not matches:
194
+ raise MakeError(f"{location} does not offer {size}", hint="pick another location")
195
+ if any(r.get("type") == "Location" for s in matches for r in s.get("restrictions") or []):
196
+ raise MakeError(
197
+ f"{size} is restricted for this subscription in {location}",
198
+ hint="free accounts often are in busy regions; try another, e.g. --location swedencentral",
199
+ )
200
+
201
+
202
+ def create(
203
+ name: str,
204
+ *,
205
+ location: str | None = None,
206
+ size: str | None = None,
207
+ public_ip: bool = True,
208
+ paid_ok: bool = False,
209
+ ) -> dict:
210
+ """Create a free-tier Ubuntu VM; return `{"publicIp": ..., "resourceGroup": ...}`.
211
+
212
+ Refuses, before anything is created: a size the free account does not
213
+ cover; a subscription without the free services (unless `paid_ok`); a
214
+ region that will not sell the size; a name already taken.
215
+ """
216
+ settings = Azure.get()
217
+ location = location or settings.location
218
+ size = size or settings.size
219
+ if size not in FREE_SIZES:
220
+ raise MakeError(f"{size} is not a free-tier size", hint="one of: " + ", ".join(FREE_SIZES))
221
+ key = ssh_key(settings.ssh_key)
222
+
223
+ entry = subscription()
224
+ sub, label = entry["subscriptionId"], entry.get("displayName", "?")
225
+ offer = free_offer(entry)
226
+ if offer:
227
+ step(f"subscription {label} ({offer})")
228
+ elif paid_ok:
229
+ warn(f"subscription {label} has no free services: this VM is billed in full")
230
+ else:
231
+ raise MakeError(
232
+ f"subscription {label} is not a free account, so this VM would be billed",
233
+ hint="a free account upgraded to pay-as-you-go keeps the free hours for its first 12 "
234
+ "months; if this is one, pass --paid-ok",
235
+ )
236
+
237
+ group = _group_path(sub, name)
238
+ if arm.call("GET", group, RESOURCES_API, tenant=_tenant(), missing_ok=True):
239
+ raise MakeError(f"{resource_group(name)} already exists", hint=f"mk azure.delete {name}")
240
+ if any(v.get("properties", {}).get("hardwareProfile", {}).get("vmSize") == size for v in list_vms(sub)):
241
+ warn(f"another {size} already exists: two running all month exceed the 750 free hours")
242
+
243
+ _available(sub, size, location)
244
+
245
+ arch = FREE_SIZES[size]
246
+ step(f"{name}: {size} ({arch}), Ubuntu 24.04, {DISK_GB} GiB P6 SSD, in {location}")
247
+ arm.call(
248
+ "PUT",
249
+ group,
250
+ RESOURCES_API,
251
+ tenant=_tenant(),
252
+ body={"location": location, "tags": {"managed-by": "mkrun"}},
253
+ )
254
+ deployment = f"{group}/providers/Microsoft.Resources/deployments/{quote(name)}-vm"
255
+ template = vm_template(
256
+ name,
257
+ size=size,
258
+ arch=arch,
259
+ admin=settings.admin,
260
+ public_key=key.read_text(),
261
+ location=location,
262
+ public_ip=public_ip,
263
+ )
264
+ arm.call(
265
+ "PUT",
266
+ deployment,
267
+ RESOURCES_API,
268
+ tenant=_tenant(),
269
+ body={"properties": {"mode": "Incremental", "template": template}},
270
+ )
271
+ done = arm.wait_deployment(deployment, RESOURCES_API, tenant=_tenant()) or {}
272
+ outputs = (done.get("properties") or {}).get("outputs") or {}
273
+ address = (outputs.get("publicIp") or {}).get("value") or ("<ip>" if not done else "")
274
+ if public_ip:
275
+ note("the public IPv4 is the one part of this VM the free tier does not cover (about $3.65 a month)")
276
+ if address:
277
+ note(f"ssh {settings.admin}@{address}")
278
+ return {"publicIp": address, "resourceGroup": resource_group(name)}
279
+
280
+
281
+ def list_vms(sub: str | None = None) -> list[dict]:
282
+ """Every VM in the subscription, with its power state (`statusOnly=true`)."""
283
+ sub = sub or subscription()["subscriptionId"]
284
+ found = arm.collect(
285
+ f"/subscriptions/{sub}/providers/Microsoft.Compute/virtualMachines",
286
+ COMPUTE_API,
287
+ tenant=_tenant(),
288
+ statusOnly="true",
289
+ )
290
+ return found or []
291
+
292
+
293
+ def public_ips(sub: str) -> dict[str, str]:
294
+ """Public IP name -> address, across the subscription."""
295
+ found = arm.collect(
296
+ f"/subscriptions/{sub}/providers/Microsoft.Network/publicIPAddresses", NETWORK_API, tenant=_tenant()
297
+ )
298
+ return {p.get("name", ""): (p.get("properties") or {}).get("ipAddress", "") for p in found or []}
299
+
300
+
301
+ def power_state(entry: dict) -> str:
302
+ statuses = ((entry.get("properties") or {}).get("instanceView") or {}).get("statuses") or []
303
+ for status in statuses:
304
+ code = status.get("code", "")
305
+ if code.startswith("PowerState/"):
306
+ return code.removeprefix("PowerState/")
307
+ return "?"
308
+
309
+
310
+ def delete(name: str, *, wait: bool = True) -> None:
311
+ """Delete a VM's resource group: the VM, its disk, NIC, IP and network, at once."""
312
+ sub = subscription()["subscriptionId"]
313
+ group = _group_path(sub, name)
314
+ found = arm.call("GET", group, RESOURCES_API, tenant=_tenant(), missing_ok=True)
315
+ if found is None and not _dry():
316
+ raise MakeError(f"no resource group {resource_group(name)}", hint="mk azure.vms lists what exists")
317
+ answer = arm.request("DELETE", arm.url(group, RESOURCES_API), tenant=_tenant())
318
+ if wait:
319
+ step(f"deleting {resource_group(name)} (a few minutes)")
320
+ arm.wait_location(answer, tenant=_tenant())
321
+ note(f"deleted {resource_group(name)}; nothing it held is billed any more")
322
+ else:
323
+ note(f"deleting {resource_group(name)} in the background")
324
+
325
+
326
+ def _dry() -> bool:
327
+ from make.context import current
328
+
329
+ return current().dry_run
330
+
331
+
332
+ def describe(entry: dict, addresses: dict[str, str]) -> dict[str, Any]:
333
+ """The fields `azure.vms` prints."""
334
+ size = (entry.get("properties") or {}).get("hardwareProfile", {}).get("vmSize", "?")
335
+ name = entry.get("name", "?")
336
+ return {
337
+ "name": name,
338
+ "size": size,
339
+ "free": size in FREE_SIZES,
340
+ "state": power_state(entry),
341
+ "ip": addresses.get(f"{name}-ip", ""),
342
+ }
@@ -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,9 @@
1
+ make_azure/__init__.py,sha256=4aOQHH61cDrJ2cB-_Am-p3to6i8qBCIEreSkcmbxMl4,976
2
+ make_azure/arm.py,sha256=84FMA4HBdiEMTL5a1GmYVvjD0Q_P6OHg_H7TYv-Ix4Y,7650
3
+ make_azure/auth.py,sha256=lGQibmkH7O-5GeuSh61zVyp7W4FWLrxmS9HvpPqfQ3k,9330
4
+ make_azure/azure.py,sha256=3K9RjJ1q_5nlAgbEyjSrF_ibyahmhKle0Ec0lpq7XmI,3156
5
+ make_azure/template.py,sha256=Gk2NKhTWmDZBHLOTTu3x0GNu8oGRiWjVWFJFAr2o394,6530
6
+ make_azure/vm.py,sha256=iqSmYRGyK8fZWhpjR9UU5xMoZckvy00udLPWizoBTTc,12760
7
+ make_azure-0.2.0.dist-info/METADATA,sha256=Tm-tM9JaMfCf_c8i_UgsjWad1S1y5y-ZBx5r22r2kPk,7455
8
+ make_azure-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ make_azure-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any