Metadata-Version: 2.5
Name: make-azure
Version: 0.2.0
Summary: Azure tasks for mkrun -- sign in with azure-identity, and create a free-tier virtual machine with nothing billed left behind.
Project-URL: Homepage, https://make.optersoft.com
Author-email: "Optersoft, S.L." <david@optersoft.com>
License-Expression: MIT OR Apache-2.0
Keywords: az,azure,free tier,mk,mkrun,virtual machine
Requires-Python: >=3.11
Requires-Dist: azure-identity>=1.16
Requires-Dist: mkrun>=0.4.1
Description-Content-Type: text/markdown

# make-azure

A virtual machine on **Azure's free tier**, for [`mkrun`](https://pypi.org/project/mkrun/):
signed in with Microsoft's own `azure-identity`, created with every field that
decides the bill set explicitly, and removed with nothing billed left behind.
No `az` CLI.

```python
# Makefile.py in a consuming repo
# /// script
# requires-python = ">=3.11"
# dependencies = ["mkrun>=0.4.1", "make-azure>=0.2"]
# ///
from make_azure import azure  # importing is what registers the group
from make_azure.vm import Azure

Azure.configure(tenant="contoso.onmicrosoft.com", location="swedencentral")  # optional
```

```console
$ mk azure.login --tenant <id or domain>   # once; a browser opens
$ mk azure.login --tenant <t> --device-code  # no browser here: enter a code elsewhere
$ mk azure.account                         # which subscription, and is it a free one
$ mk azure.vm box                          # Ubuntu 24.04 on a B1s, SSH open
$ mk azure.vm arm --size Standard_B2pts_v2
$ mk azure.vms                             # what the subscription has
$ mk azure.delete box                      # the VM and everything it made
$ mk -n azure.vm box                       # the plan; nothing is sent, no sign-in needed
```

## Signing in

`azure.login` runs the sign-in inside its own process, so the browser's
redirect goes back to the process that is waiting for it. It keeps two things:

- an **authentication record** (account, tenant, username; no secret) in
  `~/.make/azure/<tenant>.json`, which says which account later calls use;
- the **tokens**, in MSAL's encrypted cache: the macOS Keychain, or libsecret
  on Linux. Unencrypted storage is refused, never fallen back to.

Every later task gets its token silently from those two. It never opens a
browser in the middle of `azure.vm`: an expired sign-in is an error that says
`mk azure.login`.

In CI, an identity in the environment wins: `AZURE_CLIENT_ID` + `AZURE_TENANT_ID`
with `AZURE_FEDERATED_TOKEN_FILE` (workload identity) or `AZURE_CLIENT_SECRET`.
The secret's name marks it as a credential, so mkrun withholds it from child
processes and redacts it.

⚠ **There is no `az` fallback**, on purpose. One address can be both a personal
Microsoft account and a work account, and silently reusing whichever `az`
session is around is how the wrong one gets used.

⚠ **MFA is asked for at sign-in, on purpose.** Since Azure's mandatory MFA
(2025), Resource Manager answers any create, update or delete made without it
with `401 RequestDisallowedByAzure` and a claims challenge (`acrs: p1`). Reads
still pass, so everything up to the first write would work and then fail.
`azure.login` asks for those claims up front, and `arm` answers a challenge
silently if one still comes. The user does MFA once, at sign-in, as the portal
makes them.

⚠ **School and work tenants often block `--device-code`.** Microsoft's managed
Conditional Access policy against device-code phishing answers
`AADSTS53003` *"does not meet the criteria"* after a successful sign-in, and
the device-code flow then polls until its code expires (about 15 minutes):
Ctrl-C. Use the browser flow, which the same policy allows. Measured on a
school tenant (Azure for Students), 2026-10-07: device code refused, browser
admitted, VM created, SSH in, deleted.

Two browser-flow traps, both with a fix on screen:

- **The redirect has to reach this process.** The sign-in URL is printed as
  well as opened. Open it in the browser you actually sign in with; if you sign
  in in some other tab, nothing comes back and `azure.login` times out after 5
  minutes.
- **`state mismatch: … vs None`** means an *old* `localhost:8400` tab (a
  previous *"Authentication complete"* page, reloaded or restored) answered
  first. Close every such tab and run `azure.login` again.

`azure-identity` signs in as the Azure CLI's public client
(`04b07795-8ddb-461a-bbee-02f9e1bf7b46`), so a tenant that blocks `az` outright
blocks this too.

## What "free" means here

An Azure free account gives, for its first 12 months, **750 hours a month** of
each of `Standard_B1s`, `Standard_B2ats_v2` (AMD) and `Standard_B2pts_v2` (Arm).
That's one VM running all month. It also gives **two 64 GiB P6 managed disks**.
Azure for Students carries the same free services. Everything else bills:

| | a portal or `az vm create` default | here |
|---|---|---|
| OS disk | 30 GiB → billed as **P4**, a meter the offer does not cover | **64 GiB Premium SSD = P6**, the free one |
| size | `Standard_DS1_v2` and friends | `Standard_B1s`; anything outside the three is refused |
| resource group | one you name, shared | `<name>-rg`, one per VM, so `delete` takes all of it |
| public IP | Standard static IPv4 | the same, **and it is billed** (about $3.65 a month) |

⚠ **The public IPv4 is the one cost.** Basic public IPs, which the free account
used to cover, were retired on 2025-09-30, and a Standard IPv4 bills by the hour
whether or not the VM runs. `--no-public-ip` leaves it out; the VM is then
reachable only from inside its network.

⚠ **A pay-as-you-go subscription takes the same request and pays for it.**
`azure.vm` reads the subscription's offer (`quotaId`) and refuses anything but
a free account or Azure for Students. A free account upgraded to pay-as-you-go
keeps its free hours until month 12 but reports the paid offer; `--paid-ok` is
for that case.

It also refuses, before creating anything: a sign-in that reaches no
subscription, or several when none is named; a region that will not sell the
size to this subscription (free accounts are often restricted in busy regions,
so try another `--location`); and a name already taken. A second VM of the
same size is allowed, with a warning: two running all month exceed the 750 hours.

## How it talks to Azure

Plain REST to Resource Manager through `make.http`, with a bearer token from
`azure-identity`. No `azure-mgmt-*` SDKs. The VM is **one template
deployment** (`template.py`): network, NSG, IP, NIC and VM in a single request.
Azure works out the order, and a failure reports Azure's own reason
(`SkuNotAvailable: …`). `azure-identity` is imported only when a token is
needed, so `mk --list` never pays its import time.

## Settings

`Azure.configure(...)` in the task file, an `[azure]` table in the config file,
or `MAKE_AZURE_<FIELD>` in the environment:

| field | default | |
|---|---|---|
| `tenant` | the only one `azure.login` saved | tenant id or domain |
| `subscription` | the only one the sign-in reaches | id or display name |
| `location` | `westeurope` | any region with B-series capacity |
| `size` | `Standard_B1s` | one of the three free sizes |
| `admin` | `azureuser` | the login user |
| `ssh_key` | `~/.ssh/id_ed25519.pub`, then `id_rsa.pub` | the public key the VM accepts |

The group is `azure`, and it merges with a repo's own `azure.*` tasks; this
package claims `login`, `account`, `vm`, `vms` and `delete`, nothing else.
