azure-vm-sdk 0.6.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.
azure_vm/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """Public API for creating and managing Azure VMs through OpenTofu."""
2
+
3
+ from ._backend import CommandResult
4
+ from .client import AzureClient
5
+ from .exceptions import (
6
+ AzureVmCommandError,
7
+ AzureVmError,
8
+ AzureVmTimeoutError,
9
+ SshConnectionError,
10
+ TofuNotInstalledError,
11
+ VmNotFoundError,
12
+ )
13
+ from .models import ImageInfo, VmArchitecture, VmConfig, VmInfo, VmSize, VmState
14
+ from .vm import AzureVM
15
+
16
+ __all__ = [
17
+ "AzureClient",
18
+ "AzureVM",
19
+ "AzureVmCommandError",
20
+ "AzureVmError",
21
+ "AzureVmTimeoutError",
22
+ "CommandResult",
23
+ "ImageInfo",
24
+ "SshConnectionError",
25
+ "TofuNotInstalledError",
26
+ "VmArchitecture",
27
+ "VmConfig",
28
+ "VmInfo",
29
+ "VmNotFoundError",
30
+ "VmSize",
31
+ "VmState",
32
+ ]
azure_vm/_backend.py ADDED
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from dataclasses import dataclass
5
+ from typing import Protocol
6
+
7
+
8
+ @dataclass
9
+ class CommandResult:
10
+ args: list[str]
11
+ returncode: int
12
+ stdout: str
13
+ stderr: str
14
+
15
+ @property
16
+ def success(self) -> bool:
17
+ return self.returncode == 0
18
+
19
+
20
+ class CommandBackend(Protocol):
21
+ def run(
22
+ self,
23
+ args: list[str],
24
+ *,
25
+ cwd: str | None = None,
26
+ env: dict[str, str] | None = None,
27
+ ) -> CommandResult: ...
28
+
29
+
30
+ class TofuBackend:
31
+ """Real backend — invokes the OpenTofu CLI via subprocess."""
32
+
33
+ def run(
34
+ self,
35
+ args: list[str],
36
+ *,
37
+ cwd: str | None = None,
38
+ env: dict[str, str] | None = None,
39
+ ) -> CommandResult:
40
+ try:
41
+ proc = subprocess.run(
42
+ args, capture_output=True, text=True, cwd=cwd, env=env
43
+ )
44
+ except FileNotFoundError as err:
45
+ from .exceptions import TofuNotInstalledError
46
+
47
+ raise TofuNotInstalledError() from err
48
+ return CommandResult(
49
+ args=args,
50
+ returncode=proc.returncode,
51
+ stdout=proc.stdout,
52
+ stderr=proc.stderr,
53
+ )
54
+
55
+
56
+ class FakeBackend:
57
+ """Test backend — returns pre-configured responses and records all calls."""
58
+
59
+ def __init__(
60
+ self,
61
+ responses: dict[tuple[str, ...], CommandResult] | None = None,
62
+ ) -> None:
63
+ self._responses: dict[tuple[str, ...], CommandResult] = responses or {}
64
+ self._queues: dict[tuple[str, ...], list[CommandResult]] = {}
65
+ self._calls: list[list[str]] = []
66
+ self._cwds: list[str | None] = []
67
+ self._envs: list[dict[str, str] | None] = []
68
+ self._default: CommandResult | None = None
69
+
70
+ def set_default(self, result: CommandResult) -> None:
71
+ self._default = result
72
+
73
+ def push(self, *args: str, result: CommandResult) -> None:
74
+ self._queues.setdefault(args, []).append(result)
75
+
76
+ def run(
77
+ self,
78
+ args: list[str],
79
+ *,
80
+ cwd: str | None = None,
81
+ env: dict[str, str] | None = None,
82
+ ) -> CommandResult:
83
+ self._calls.append(list(args))
84
+ self._cwds.append(cwd)
85
+ self._envs.append(env)
86
+ key = tuple(args)
87
+ if self._queues.get(key):
88
+ return self._queues[key].pop(0)
89
+ if key in self._responses:
90
+ return self._responses[key]
91
+ if self._default is not None:
92
+ return self._default
93
+ raise KeyError(f"FakeBackend: no response configured for {args!r}")
94
+
95
+ @property
96
+ def calls(self) -> list[list[str]]:
97
+ return list(self._calls)
98
+
99
+ @property
100
+ def cwds(self) -> list[str | None]:
101
+ return list(self._cwds)
102
+
103
+ def last_call(self) -> list[str]:
104
+ return self._calls[-1] if self._calls else []
105
+
106
+ def last_cwd(self) -> str | None:
107
+ return self._cwds[-1] if self._cwds else None
108
+
109
+ @property
110
+ def envs(self) -> list[dict[str, str] | None]:
111
+ return list(self._envs)
112
+
113
+ def last_env(self) -> dict[str, str] | None:
114
+ return self._envs[-1] if self._envs else None
115
+
116
+
117
+ def run_command(
118
+ backend: CommandBackend,
119
+ args: list[str],
120
+ *,
121
+ cwd: str | None = None,
122
+ ) -> CommandResult:
123
+ """Run *args* through *backend* and raise on failure."""
124
+ from .exceptions import AzureVmCommandError
125
+
126
+ result = backend.run(args, cwd=cwd)
127
+ if not result.success:
128
+ raise AzureVmCommandError(
129
+ result.args, result.returncode, result.stdout, result.stderr
130
+ )
131
+ return result
azure_vm/_discovery.py ADDED
@@ -0,0 +1,50 @@
1
+ """Azure Marketplace image and VM size discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from ._backend import CommandBackend, run_command
8
+ from .models import ImageInfo, VmSize
9
+
10
+
11
+ def list_images(
12
+ backend: CommandBackend,
13
+ publisher: str = "Canonical",
14
+ ) -> list[ImageInfo]:
15
+ """List available VM images from Azure Marketplace."""
16
+ result = run_command(
17
+ backend,
18
+ [
19
+ "az",
20
+ "vm",
21
+ "image",
22
+ "list",
23
+ "--publisher",
24
+ publisher,
25
+ "--all",
26
+ "--output",
27
+ "json",
28
+ ],
29
+ )
30
+ return ImageInfo.from_az_image_list(json.loads(result.stdout))
31
+
32
+
33
+ def list_sizes(
34
+ backend: CommandBackend,
35
+ location: str,
36
+ ) -> list[VmSize]:
37
+ """List available VM sizes in an Azure region."""
38
+ result = run_command(
39
+ backend,
40
+ [
41
+ "az",
42
+ "vm",
43
+ "list-sizes",
44
+ "--location",
45
+ location,
46
+ "--output",
47
+ "json",
48
+ ],
49
+ )
50
+ return VmSize.from_az_vm_size_list(json.loads(result.stdout))
azure_vm/_templates.py ADDED
@@ -0,0 +1,238 @@
1
+ """HCL templates and helper functions for OpenTofu workspace generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+
9
+ SHARED_TEMPLATE = """\
10
+ terraform {{
11
+ required_providers {{
12
+ azurerm = {{
13
+ source = "hashicorp/azurerm"
14
+ version = "~> 4.0"
15
+ }}
16
+ }}
17
+ }}
18
+
19
+ provider "azurerm" {{
20
+ features {{}}
21
+ resource_provider_registrations = "none"
22
+ }}
23
+
24
+ resource "azurerm_resource_group" "main" {{
25
+ name = "{resource_group}"
26
+ location = "{location}"
27
+ }}
28
+ """
29
+
30
+ VM_TEMPLATE = """\
31
+ terraform {{
32
+ required_providers {{
33
+ azurerm = {{
34
+ source = "hashicorp/azurerm"
35
+ version = "~> 4.0"
36
+ }}
37
+ }}
38
+ }}
39
+
40
+ provider "azurerm" {{
41
+ features {{}}
42
+ resource_provider_registrations = "none"
43
+ }}
44
+
45
+ data "azurerm_resource_group" "main" {{
46
+ name = "{resource_group}"
47
+ }}
48
+
49
+ resource "azurerm_virtual_network" "vm" {{
50
+ name = "${{var.vm_name}}-vnet"
51
+ address_space = ["10.0.0.0/16"]
52
+ location = data.azurerm_resource_group.main.location
53
+ resource_group_name = data.azurerm_resource_group.main.name
54
+ }}
55
+
56
+ resource "azurerm_subnet" "vm" {{
57
+ name = "${{var.vm_name}}-subnet"
58
+ resource_group_name = data.azurerm_resource_group.main.name
59
+ virtual_network_name = azurerm_virtual_network.vm.name
60
+ address_prefixes = ["10.0.1.0/24"]
61
+ }}
62
+
63
+ resource "azurerm_network_security_group" "vm" {{
64
+ name = "${{var.vm_name}}-nsg"
65
+ location = data.azurerm_resource_group.main.location
66
+ resource_group_name = data.azurerm_resource_group.main.name
67
+
68
+ security_rule {{
69
+ name = "SSH"
70
+ priority = 1001
71
+ direction = "Inbound"
72
+ access = "Allow"
73
+ protocol = "Tcp"
74
+ source_port_range = "*"
75
+ destination_port_range = "22"
76
+ source_address_prefix = "*"
77
+ destination_address_prefix = "*"
78
+ }}
79
+ {extra_security_rules}}}
80
+
81
+ resource "azurerm_subnet_network_security_group_association" "vm" {{
82
+ subnet_id = azurerm_subnet.vm.id
83
+ network_security_group_id = azurerm_network_security_group.vm.id
84
+ }}
85
+
86
+ resource "azurerm_public_ip" "vm" {{
87
+ name = "${{var.vm_name}}-pip"
88
+ location = data.azurerm_resource_group.main.location
89
+ resource_group_name = data.azurerm_resource_group.main.name
90
+ allocation_method = "Static"
91
+ }}
92
+
93
+ resource "azurerm_network_interface" "vm" {{
94
+ name = "${{var.vm_name}}-nic"
95
+ location = data.azurerm_resource_group.main.location
96
+ resource_group_name = data.azurerm_resource_group.main.name
97
+
98
+ ip_configuration {{
99
+ name = "internal"
100
+ subnet_id = azurerm_subnet.vm.id
101
+ private_ip_address_allocation = "Dynamic"
102
+ public_ip_address_id = azurerm_public_ip.vm.id
103
+ }}
104
+ }}
105
+
106
+ resource "azurerm_linux_virtual_machine" "vm" {{
107
+ name = var.vm_name
108
+ location = data.azurerm_resource_group.main.location
109
+ resource_group_name = data.azurerm_resource_group.main.name
110
+ network_interface_ids = [azurerm_network_interface.vm.id]
111
+ size = var.vm_size
112
+ computer_name = var.vm_name
113
+ admin_username = "{ssh_username}"
114
+ disable_password_authentication = true
115
+
116
+ admin_ssh_key {{
117
+ username = "{ssh_username}"
118
+ public_key = file("{ssh_public_key_path}")
119
+ }}
120
+
121
+ os_disk {{
122
+ caching = "ReadWrite"
123
+ storage_account_type = "Standard_LRS"
124
+ disk_size_gb = var.disk_size_gb
125
+ }}
126
+
127
+ source_image_reference {{
128
+ publisher = "{image_publisher}"
129
+ offer = "{image_offer}"
130
+ sku = "{image_sku}"
131
+ version = "{image_version}"
132
+ }}
133
+ {custom_data_block}
134
+ }}
135
+
136
+ variable "vm_name" {{
137
+ type = string
138
+ }}
139
+
140
+ variable "vm_size" {{
141
+ type = string
142
+ default = "Standard_B1s"
143
+ }}
144
+
145
+ variable "disk_size_gb" {{
146
+ type = number
147
+ default = 30
148
+ }}
149
+
150
+ variable "desired_state" {{
151
+ type = string
152
+ default = "running"
153
+ }}
154
+
155
+ output "vm_ip" {{
156
+ value = azurerm_public_ip.vm.ip_address
157
+ }}
158
+
159
+ output "vm_state" {{
160
+ value = var.desired_state
161
+ }}
162
+
163
+ output "location" {{
164
+ value = data.azurerm_resource_group.main.location
165
+ }}
166
+
167
+ output "vm_size" {{
168
+ value = var.vm_size
169
+ }}
170
+
171
+ output "image_urn" {{
172
+ value = "{image_urn}"
173
+ }}
174
+
175
+ output "resource_group" {{
176
+ value = "{resource_group}"
177
+ }}
178
+ """
179
+
180
+
181
+ def parse_image_urn(image_urn: str | None) -> tuple[str, str, str, str]:
182
+ """Parse an Azure image URN into (publisher, offer, sku, version)."""
183
+ if not image_urn:
184
+ return ("Canonical", "ubuntu-24_04-lts", "server-gen1", "latest")
185
+ parts = image_urn.split(":")
186
+ return (
187
+ parts[0] if len(parts) > 0 and parts[0] else "Canonical",
188
+ parts[1] if len(parts) > 1 and parts[1] else "ubuntu-24_04-lts",
189
+ parts[2] if len(parts) > 2 and parts[2] else "server-gen1",
190
+ parts[3] if len(parts) > 3 and parts[3] else "latest",
191
+ )
192
+
193
+
194
+ def resolve_ssh_path(
195
+ ssh_key_path: str | None,
196
+ default_path: str | None = None,
197
+ ) -> str:
198
+ """Resolve an SSH key path, expanding ~ and applying fallback chain."""
199
+ return str(Path(ssh_key_path or default_path or "~/.ssh/id_rsa.pub").expanduser())
200
+
201
+
202
+ def write_cloud_init(workspace: Path, config: dict | str | None) -> str:
203
+ """Write cloud-init file to *workspace*. Returns the HCL custom_data block."""
204
+ if config is None:
205
+ return ""
206
+ if isinstance(config, dict):
207
+ content = "#cloud-config\n" + yaml.dump(config, default_flow_style=False)
208
+ else:
209
+ content = config
210
+ (workspace / "cloud-init.yaml").write_text(content)
211
+ return ' custom_data = filebase64("cloud-init.yaml")\n'
212
+
213
+
214
+ def render_security_rules(open_ports) -> str:
215
+ """Extra inbound TCP allow-rules for the per-VM NSG.
216
+
217
+ Port 22 is skipped (the SSH rule is always rendered). Source is "*",
218
+ matching the SSH rule's posture — these are short-lived e2e VMs; source
219
+ restriction is a possible future enhancement.
220
+ """
221
+ rules: list[str] = []
222
+ for index, port in enumerate(sorted({int(p) for p in (open_ports or [])})):
223
+ if port == 22:
224
+ continue
225
+ rules.append(
226
+ "\n security_rule {\n"
227
+ f' name = "Port{port}"\n'
228
+ f" priority = {1010 + index}\n"
229
+ ' direction = "Inbound"\n'
230
+ ' access = "Allow"\n'
231
+ ' protocol = "Tcp"\n'
232
+ ' source_port_range = "*"\n'
233
+ f' destination_port_range = "{port}"\n'
234
+ ' source_address_prefix = "*"\n'
235
+ ' destination_address_prefix = "*"\n'
236
+ " }\n"
237
+ )
238
+ return "".join(rules)
azure_vm/_workspace.py ADDED
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Iterator
5
+ from pathlib import Path
6
+
7
+ from ._backend import CommandBackend, run_command
8
+ from ._templates import (
9
+ SHARED_TEMPLATE,
10
+ VM_TEMPLATE,
11
+ parse_image_urn,
12
+ render_security_rules,
13
+ resolve_ssh_path,
14
+ write_cloud_init,
15
+ )
16
+ from .models import VmInfo
17
+
18
+
19
+ class Workspace:
20
+ """Encapsulates filesystem operations on the Azure VM workspace directory.
21
+
22
+ Each VM gets a sub-directory under *root*. Shared infrastructure lives in
23
+ ``.shared/``.
24
+ """
25
+
26
+ def __init__(self, root: Path, backend: CommandBackend) -> None:
27
+ self._root = root
28
+ self._backend = backend
29
+
30
+ # ------------------------------------------------------------------- paths
31
+
32
+ def vm_dir(self, name: str) -> Path:
33
+ return self._root / name
34
+
35
+ def shared_dir(self) -> Path:
36
+ return self._root / ".shared"
37
+
38
+ def vm_exists(self, name: str) -> bool:
39
+ return (self.vm_dir(name) / "main.tf").exists()
40
+
41
+ # ----------------------------------------------------------- shared infra
42
+
43
+ def ensure_shared_infra(self, resource_group: str, location: str) -> None:
44
+ shared = self.shared_dir()
45
+ hcl = SHARED_TEMPLATE.format(
46
+ resource_group=resource_group,
47
+ location=location,
48
+ )
49
+ main_tf = shared / "main.tf"
50
+ # Re-render from the current configuration: an existing workspace must
51
+ # never silently pin stale parameters. Re-apply only on actual change.
52
+ if main_tf.exists() and main_tf.read_text() == hcl:
53
+ return
54
+ shared.mkdir(parents=True, exist_ok=True)
55
+ main_tf.write_text(hcl)
56
+ run_command(self._backend, ["tofu", "init"], cwd=str(shared))
57
+ _try_import_resource_group(self._backend, shared, resource_group)
58
+ run_command(self._backend, ["tofu", "apply", "-auto-approve"], cwd=str(shared))
59
+
60
+ # --------------------------------------------------------------- vm ops
61
+
62
+ def write_vm_workspace(
63
+ self,
64
+ name: str,
65
+ vm_size: str,
66
+ disk_size_gb: int,
67
+ image_urn: str | None,
68
+ cloud_init_config: dict | str | None,
69
+ ssh_key_path: str | None,
70
+ *,
71
+ resource_group: str,
72
+ location: str,
73
+ ssh_username: str,
74
+ default_ssh_key: str | None = None,
75
+ open_ports=None,
76
+ ) -> None:
77
+ workspace = self.vm_dir(name)
78
+ workspace.mkdir(parents=True, exist_ok=True)
79
+
80
+ cloud_init_block = write_cloud_init(workspace, cloud_init_config)
81
+ publisher, offer, sku, version = parse_image_urn(image_urn)
82
+ effective_ssh_path = resolve_ssh_path(ssh_key_path, default_ssh_key)
83
+
84
+ full_urn = image_urn or "Canonical:ubuntu-24_04-lts:server-gen1:latest"
85
+
86
+ hcl = VM_TEMPLATE.format(
87
+ resource_group=resource_group,
88
+ location=location,
89
+ ssh_username=ssh_username,
90
+ ssh_public_key_path=effective_ssh_path,
91
+ image_publisher=publisher,
92
+ image_offer=offer,
93
+ image_sku=sku,
94
+ image_version=version,
95
+ image_urn=full_urn,
96
+ custom_data_block=cloud_init_block,
97
+ extra_security_rules=render_security_rules(open_ports),
98
+ )
99
+ (workspace / "main.tf").write_text(hcl)
100
+
101
+ tfvars = (
102
+ f'vm_name = "{name}"\n'
103
+ f'vm_size = "{vm_size}"\n'
104
+ f"disk_size_gb = {disk_size_gb}\n"
105
+ )
106
+ (workspace / "terraform.tfvars").write_text(tfvars)
107
+
108
+ def iter_vm_workspaces(self) -> Iterator[Path]:
109
+ """Yield VM workspace directories in sorted order.
110
+
111
+ Hidden entries, non-directories, and directories that hold no
112
+ ``main.tf`` are skipped.
113
+ """
114
+ for item in sorted(self._root.iterdir()):
115
+ if item.name.startswith("."):
116
+ continue
117
+ if not item.is_dir():
118
+ continue
119
+ if not (item / "main.tf").exists():
120
+ continue
121
+ yield item
122
+
123
+ def list_vms(self) -> list[VmInfo]:
124
+ vms: list[VmInfo] = []
125
+ for vm_dir_item in self.iter_vm_workspaces():
126
+ result = self._backend.run(
127
+ ["tofu", "output", "-json"], cwd=str(vm_dir_item)
128
+ )
129
+ if result.success and result.stdout:
130
+ try:
131
+ data = json.loads(result.stdout)
132
+ vms.append(VmInfo.from_tofu_output(data, vm_dir_item.name))
133
+ except json.JSONDecodeError:
134
+ continue
135
+ return vms
136
+
137
+ def purge(self) -> None:
138
+ """Destroy all VM workspaces (preserving .shared/)."""
139
+ for vm_dir_item in self.iter_vm_workspaces():
140
+ run_command(
141
+ self._backend,
142
+ ["tofu", "destroy", "-auto-approve"],
143
+ cwd=str(vm_dir_item),
144
+ )
145
+
146
+
147
+ def _try_import_resource_group(
148
+ backend: CommandBackend,
149
+ shared: Path,
150
+ resource_group: str,
151
+ ) -> None:
152
+ """Try to import an existing Azure resource group into Tofu state.
153
+
154
+ If the resource group already exists in Azure but is not tracked in
155
+ the local Tofu state (e.g. fresh workspace, or state was deleted),
156
+ ``tofu apply`` would fail with "already exists". Importing it first
157
+ makes apply a no-op. When the import fails the resource group
158
+ likely does not exist yet — apply will create it.
159
+ """
160
+ # Resolve the subscription id via Azure CLI (must be authenticated).
161
+ result = backend.run(
162
+ ["az", "account", "show", "--query", "id", "-o", "tsv"],
163
+ )
164
+ if not result.success:
165
+ return # can't query; let apply try anyway
166
+ sub_id = (result.stdout or "").strip()
167
+ if not sub_id:
168
+ return
169
+
170
+ resource_id = f"/subscriptions/{sub_id}/resourceGroups/{resource_group}"
171
+ # Intentionally ignore result — import is best-effort.
172
+ backend.run(
173
+ ["tofu", "import", "azurerm_resource_group.main", resource_id],
174
+ cwd=str(shared),
175
+ )