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