Skip to main content

azure-vm-sdk

Python SDK for managing Azure VMs via OpenTofu.

Prerequisites

  • Python 3.11+
  • uv package manager
uv sync

Usage

Single VM

from azure_vm import AzureClient

client = AzureClient(resource_group="my-rg", location="westeurope")
vm = client.launch(name="my-vm", vm_size="Standard_B2s")
print(vm.info())
vm.delete()

SSH connection retries

SSH connections retry transient network failures up to six times by default. The delay doubles from 1 second to a 10-second cap, with jitter between half and all of each delay. Failed sessions are closed before the next attempt. Authentication, host-key and configuration errors fail immediately; commands that have already started are never replayed.

Set ssh_connect_attempts on AzureClient or AzureVM to change the attempt limit (1 disables retries). ssh_connect_timeout defaults to 15 seconds and limits each TCP connect, SSH banner and authentication wait separately. A new connection is also retried when reconnecting an expired cached session.

Stalled sessions

An open session asks the VM for a reply every ssh_keepalive_interval seconds (15 by default), as OpenSSH's ServerAliveInterval does. When a request stays unanswered for ssh_keepalive_interval × ssh_keepalive_count_max seconds (15 × 4 by default) the session is closed and the command or transfer running on it fails with SshStallError, a subclass of SshConnectionError, instead of blocking until the kernel gives up on the TCP connection. A command that prints nothing for a long time is not affected: only the VM's answers are awaited. An interval of 0 disables the probes.

transfer writes <dest>.<unique-id>.partial and renames it to dest once complete. Every attempt uses its own staging file, so concurrent copies do not overwrite one another before the final rename. A transfer whose session stalls or drops is retried on a new connection, up to ssh_transfer_attempts attempts in all (3 by default); a missing file or a refused permission fails at once. A failed upload may leave <dest>.<unique-id>.partial on the VM. Commands are still never retried.

Multiple VMs in parallel

Use launch_many to create N VMs simultaneously. All VMs are provisioned in parallel; if any one fails the already-created VMs are destroyed automatically before the exception is re-raised (fail-fast with rollback).

from azure_vm import AzureClient, VmConfig

client = AzureClient(resource_group="my-rg", location="westeurope")

# Different names, sizes, and images
vms = client.launch_many(
    [
        VmConfig(
            name="frontend",
            vm_size="Standard_B1s",
            image_urn="Canonical:0001-com-ubuntu-server-noble:24_04-lts:latest",
        ),
        VmConfig(
            name="backend",
            vm_size="Standard_B2s",
            image_urn="Canonical:0001-com-ubuntu-server-jammy:22_04-lts:latest",
            disk_size_gb=64,
        ),
        VmConfig(
            name="db",
            vm_size="Standard_D2s_v3",
            disk_size_gb=128,
        ),
    ]
)

for vm in vms:
    print(vm.name, vm.info().ipv4)

VmConfig accepts the same keyword arguments as launch:

Field Default Description
name None (auto-generated) VM name
vm_size "Standard_B1s" Azure VM size
disk_size_gb 30 OS disk size in GB; at least MIN_OS_DISK_GB (30), the Marketplace Linux images' disk
image_urn None (Ubuntu 24.04 LTS) Marketplace image URN
cloud_init_config None cloud-init dict or YAML string
ssh_key_path None (inherits from client) Path to SSH public key
open_ports None Additional inbound TCP ports (SSH is always enabled)
ingress None IngressRules: inbound TCP ports reachable only from their source CIDRs
priority "Regular" "Regular" or "Spot"
eviction_policy None Spot only: "Delete" (effective default) or "Deallocate"
max_bid_price None Spot only: USD/hour, finite and non-negative, or -1 to disable price-based eviction

max_workers caps the thread pool size (default: one thread per VM):

vms = client.launch_many(configs, max_workers=4)

Restricted inbound ports

open_ports opens ports to any source. ingress opens each port only to the CIDRs its IngressRule names, in the same OpenTofu-managed NSG, so the rules are in place from the first apply and stay consistent with the workspace state:

from azure_vm import IngressRule

vm = client.launch(
    name="cloud",
    ingress=[
        IngressRule(8080, ("198.51.100.7/32", "203.0.113.0/24")),
        IngressRule(9090, ("198.51.100.7/32",)),
    ],
)

Rules for the same port merge their sources into one rule named Port<port>. A port may be either open or restricted, not both; port 22 stays governed by the SSH rule. Rule changes reach Azure on the next apply: ensure_running re-renders them, but a VM that is already running keeps the rules it was created with.

OS disk cleanup after eviction

vm.delete() and client.purge() also clean up orphan managed OS disks when Azure has already deleted a Spot VM. Each VM workspace keeps sdk-os-disks.json with exact disk resource IDs and Azure uniqueId values, captured before and after applies. Replacement retains both generations until cleanup succeeds.

Cleanup uses the authenticated Azure CLI (az disk show/delete). It deletes a recorded disk only when its identity is unchanged, diskState is Unattached, and both managedBy and managedByExtended are empty. It never scans the subscription for name matches, touches user data disks, or force-detaches disks. Missing disks are success; authorization, network, identity and attachment errors are reported and unresolved entries remain available for retry. Call delete() or purge() again after resolving the error.

If a partially completed Azure operation leaves a live VM missing from local state, cleanup checks the exact NIC recorded in that workspace. It imports the attached VM only after confirming the expected VM name, subscription, resource group and reciprocal NIC association, then destroys the VM before its network resources and tracks its OS disk. A late attachment causing NicInUse triggers one reconciliation and retry. Foreign attachments and lookup/import failures stop cleanup and preserve the workspace for retry; no name-prefix search is used.

Keep the workspace and its disk registry until cleanup completes. Older local workspaces can recover disk IDs from their retained VM state (including AzureRM versions before 4.15). If both the state and registry have already been lost, the SDK cannot establish ownership and cannot recover those orphan disks. Unverified registry entries without supporting VM state require manual reconciliation. Lifecycle operations on the same workspace must be serialized; parallel creation/deletion of different VM workspaces is supported. Clones copy configuration only, with independent state and disk ownership.

Progressive Spot creation

launch_many_iter(configs, max_workers=8) yields an exported LaunchResult for each completed creation, including failures, while retaining successful VMs. Each result carries config, vm and error: success sets vm, failure sets error. A slow VM does not delay faster outcomes. The worker limit bounds submitted creations independently of fleet size; consuming results advances submission of the remaining configurations.

import json
from contextlib import closing
from pathlib import Path

from azure_vm import VmConfig

configs = [
    VmConfig(name=f"worker-{i}", priority="Spot", max_bid_price=-1) for i in range(100)
]
# Record every intended name before starting any cloud work.
Path("planned-vms.json").write_text(json.dumps([cfg.name for cfg in configs]))
with closing(client.launch_many_iter(configs, max_workers=8)) as outcomes:
    for outcome in outcomes:
        if outcome.error is not None:
            print(outcome.config.name, "creation failed:", outcome.error)
        else:
            print(outcome.vm.name, "created")
            # Hand off SSH/cloud-init preparation to your own bounded worker pool.

Creation completion does not imply SSH or cloud-init readiness. Prepare each VM before scheduling jobs on it. launch and ensure_running accept the same Spot options; cloning preserves the rendered options. None for the bid leaves the provider default (-1). A Spot VM can still be evicted for capacity reasons. The available fields follow the AzureRM Linux VM resource.

Use contextlib.closing if the loop can break or raise (including Ctrl-C). Close stops new submissions, cancels unstarted work and waits for running OpenTofu calls; it never rolls back successful peers. It can therefore block until those calls finish. Outcomes for drained work are not yielded after close, and failed applies can leave partial resources. Reconcile the recorded names with the workspace to resume or delete them. Do not delete the workspace until cleanup has finished. Explicit, unique names are recommended for durable tracking; autogenerated names are available in returned configurations.

launch_many retains its existing transactional rollback behavior. Spot settings are validated before batch creation; inconsistent combinations, non-finite prices and invalid priorities are rejected without provisioning.


End-to-end smoke test

Provision a real Azure VM, wait for SSH readiness, run a verification command, and tear it down. Requires Azure credentials and OpenTofu installed.

# Prerequisites
az login
export AZURE_RESOURCE_GROUP=my-resource-group
export AZURE_LOCATION=westeurope
export AZURE_SSH_PUBLIC_KEY=~/.ssh/id_rsa.pub   # optional

# Run with defaults (single VM)
uv run azure-vm-e2e

# Custom VM size and image
uv run azure-vm-e2e --name my-test-vm --vm-size Standard_D2s_v3 \
    --image-urn "Canonical:0001-com-ubuntu-server-noble:24_04-lts:latest" \
    --timeout 300

# Create 3 identical VMs in parallel (names: worker-0, worker-1, worker-2)
uv run azure-vm-e2e --count 3 --name worker --vm-size Standard_B2s

# Create VMs with different names, sizes, and images
uv run azure-vm-e2e --configs '[
  {"name": "frontend", "vm_size": "Standard_B1s"},
  {"name": "backend",  "vm_size": "Standard_B2s", "disk_size_gb": 64},
  {"name": "db",       "vm_size": "Standard_D2s_v3", "disk_size_gb": 128}
]'

Output shows a 5-step progress:

[1/5] Launching VM 'e2e-1747152000' (size=Standard_B1s) ...
       launch completed in 52.3s
[2/5] Waiting for public IP (timeout=300s) ...
       got IP 20.1.2.3 in 18.5s
[3/5] Waiting for SSH on 20.1.2.3:22 ...
       SSH ready on 20.1.2.3 in 42.1s
[4/5] Running verification command ...
       exit=0  stdout=Linux e2e-1747152000 6.8.0-1020-azure ...
       state=running  location=westeurope  resource_group=my-rg
[5/5] Deleting VM 'e2e-1747152000' ...
       done.

SUCCESS — VM 'e2e-1747152000' completed full lifecycle.

Running tests

# Unit tests only (default, no Azure/OpenTofu needed)
uv run pytest

# Include integration tests (requires Azure + OpenTofu installed).
# --no-cov: the 80% coverage gate targets the unit suite, so it would fail on
# an integration-only run.
uv run pytest -m "integration" --no-cov

# Coverage report (already on by default, with an 80% gate; shown explicitly)
uv run pytest --cov=azure_vm --cov-report=term-missing

Quality checks

# Run all three (ruff + basedpyright + import-linter)
uv run azure-vm-quality

Individual checks:

uv run ruff check .                    # Linting (pycodestyle, pyflakes, isort, bugbear, pyupgrade, pydocstyle, …)
uv run ruff format .                   # Formatting
uv run basedpyright                    # Type checking
uv run bandit -c pyproject.toml -r src # Security static analysis
uv run lint-imports                    # Architecture contract enforcement
uv run pre-commit run --all-files      # Every hook above, all at once

Code evaluation tools

Full audit

Runs automated detection for bugs, excessive coupling, simplification opportunities, code smells, and god classes.

uv run azure-vm-eval           # Human-readable report
uv run azure-vm-eval --json    # JSON output (for CI / machine consumption)

Checks performed:

Category What it detects
Bugs Bare/broad except, raise without from, unused exception classes, mutable defaults
Coupling High fan-out modules, circular dependencies, zero fan-in modules
Simplifications Functions over 30 lines, duplicated logic
Smells God classes (lines, methods, fan-out), modules over 250 lines, __init__ leaking internals

Package cohesion report

Measures internal cohesion and inter-module coupling for every module in azure_vm.

uv run azure-vm-package-report           # Cohesion/coupling table
uv run azure-vm-package-report --edges   # Include individual import edges
uv run azure-vm-package-report --orphans # Show modules with zero internal deps

Metrics:

Column Meaning
internal Imports within the same module
outgoing Imports from other azure_vm modules
incoming Times this module is imported by other azure_vm modules
external Imports from third-party packages
instability outgoing / (incoming + outgoing) — 0 = highly stable (depended on by many), 1 = highly unstable (depends on everything)

Dependency graph visualization

uv run pydeps azure_vm --show-deps --max-bacon 2 --nodot

Rendering the diagram itself additionally needs Graphviz (dot) on PATH; --nodot skips that step and prints the dependency analysis as JSON.

Architecture contracts

Defined in .importlinter:

  1. models_and_exceptions_are_independent — foundation layer must not import higher-level packages
  2. backend_is_independent — _backend must not depend on vm or client
  3. internal_modules_are_independent — _templates, _workspace, _discovery must not depend on the public API layer

Project structure

src/azure_vm/
├── __init__.py        Public API re-exports
├── client.py          AzureClient — VM provisioning orchestrator
├── vm.py              AzureVM — single-VM operations (lifecycle, SSH, exec)
├── _workspace.py      Workspace — filesystem, template writing, scan, purge
├── _discovery.py      Azure Marketplace image discovery
├── _templates.py      HCL generation + image URN / SSH path helpers
├── _backend.py        CommandBackend protocol, TofuBackend, FakeBackend, run_command
├── models.py          VmConfig, VmInfo, VmState, ImageInfo
├── exceptions.py      Exception hierarchy
└── testing.py         Test doubles for consumers (FakeBackend, CommandResult)

Regional VMs in an existing resource group

To place VMs in different regions while sharing one existing resource group, use one workspace per region and disable resource-group management. VM names must remain unique across all workspaces sharing the resource group:

north = AzureClient(
    resource_group="my-existing-rg",
    location="northeurope",
    work_dir="./azure/north",
    manage_resource_group=False,
)

The VM and its network resources use location, independently of the resource group's metadata location. With manage_resource_group=False, the group must already exist and is only read. The default remains True for existing callers. Changing an already managed shared workspace to this mode is rejected; use a fresh workspace so OpenTofu cannot interpret the change as deleting the resource group.

Metadata

Release files for azure-vm-sdk 0.9.3

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

Source distribution (sdist)

Source distribution for azure-vm-sdk 0.9.3
File Size Uploaded Provenance Actions
azure_vm_sdk-0.9.3.tar.gz 164.0 kB With provenance Details for azure_vm_sdk-0.9.3.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for azure-vm-sdk 0.9.3
File Interpreter ABI Platform Actions
azure_vm_sdk-0.9.3-py3-none-any.whl Python 3 none any Details for azure_vm_sdk-0.9.3-py3-none-any.whl

Total release size: 213.5 kB

Release files / azure_vm_sdk-0.9.3.tar.gz

Download URL azure_vm_sdk-0.9.3.tar.gz
Size 164.0 kB
Tags Source
SHA-256 checksum
How to use checksums
addc539b208a650533e20c682e8896da3685e5303fcac8801f8fcbdbd925ce50
BLAKE2b-256 checksum
How to use checksums
673e3c0ae2831668c032ae0e239b2f601975230d4c8d09f284d4db519a1b8d7e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 3, 2026.

Transparency log

Release files / azure_vm_sdk-0.9.3-py3-none-any.whl

Download URL azure_vm_sdk-0.9.3-py3-none-any.whl
Size 49.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
02892fbdcf2db41f266dfc3450342b5a2c21d58321f8a5473dd9c3e0e364b704
BLAKE2b-256 checksum
How to use checksums
d7d15e38b2df9f57f9c7ad932a6bad4b260e06ef72b4cefe9e90f3c07046ad23
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 3, 2026.

Transparency log

Security for azure-vm-sdk 0.9.3

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for azure_vm_sdk-0.9.3.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 3, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

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