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 +30 -0
- make_azure/arm.py +207 -0
- make_azure/auth.py +229 -0
- make_azure/azure.py +86 -0
- make_azure/template.py +174 -0
- make_azure/vm.py +342 -0
- make_azure-0.2.0.dist-info/METADATA +152 -0
- make_azure-0.2.0.dist-info/RECORD +9 -0
- make_azure-0.2.0.dist-info/WHEEL +4 -0
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,,
|