Skip to main content

make-azure

A virtual machine on Azure's free tier, for 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.

# 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
$ 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.

Metadata

Release files for make-azure 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for make-azure 0.2.0
File Size Uploaded
make_azure-0.2.0.tar.gz 21.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for make-azure 0.2.0
File Interpreter ABI Platform
make_azure-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 42.1 kB

Release files / make_azure-0.2.0.tar.gz

Download URL make_azure-0.2.0.tar.gz
Size 21.8 kB
Tags Source
SHA-256 checksum
How to use checksums
cd9aa2e56fe6d2cfa2c59f618f44f227b9f4fae6fb23c6b1a90f55f3715723cd
BLAKE2b-256 checksum
How to use checksums
b024006a3cac842f2524898a0166ab6bba3e2a644edb6e4e2afae3c192993b2b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.10.7 {"installer":{"name":"uv","version":"0.10.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / make_azure-0.2.0-py3-none-any.whl

Download URL make_azure-0.2.0-py3-none-any.whl
Size 20.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b0c5b126d631a123db4463f889ff034fd5ec7fe61f07a265d3a95372c9d75ee9
BLAKE2b-256 checksum
How to use checksums
8efacb2bbcaf90f1d8422d8835ded18373407ac42c09d2dfa3e975b423ce65ef
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.10.7 {"installer":{"name":"uv","version":"0.10.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release history Release notifications | RSS feed

0.3.0

2 release files

This release

0.2.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page