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 +32 -0
- azure_vm/_backend.py +131 -0
- azure_vm/_discovery.py +50 -0
- azure_vm/_templates.py +238 -0
- azure_vm/_workspace.py +175 -0
- azure_vm/client.py +302 -0
- azure_vm/devtools/__init__.py +6 -0
- azure_vm/devtools/code_eval.py +665 -0
- azure_vm/devtools/package_report.py +236 -0
- azure_vm/devtools/quality.py +36 -0
- azure_vm/e2e.py +250 -0
- azure_vm/exceptions.py +62 -0
- azure_vm/models.py +169 -0
- azure_vm/testing.py +5 -0
- azure_vm/vm.py +286 -0
- azure_vm_sdk-0.6.0.dist-info/METADATA +265 -0
- azure_vm_sdk-0.6.0.dist-info/RECORD +20 -0
- azure_vm_sdk-0.6.0.dist-info/WHEEL +4 -0
- azure_vm_sdk-0.6.0.dist-info/entry_points.txt +5 -0
- azure_vm_sdk-0.6.0.dist-info/licenses/LICENSE +21 -0
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
|
+
)
|