agentworks-cli 0.2.1__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.
- agentworks/__init__.py +1 -0
- agentworks/agents/__init__.py +0 -0
- agentworks/agents/manager.py +1095 -0
- agentworks/agents/templates.py +145 -0
- agentworks/catalog.py +264 -0
- agentworks/catalog.toml +131 -0
- agentworks/cli.py +1462 -0
- agentworks/completions/__init__.py +33 -0
- agentworks/completions/bash.py +179 -0
- agentworks/completions/install.py +122 -0
- agentworks/completions/powershell.py +270 -0
- agentworks/completions/spec.py +216 -0
- agentworks/completions/zsh.py +256 -0
- agentworks/config.py +894 -0
- agentworks/db.py +1083 -0
- agentworks/doctor.py +430 -0
- agentworks/git_credentials/__init__.py +0 -0
- agentworks/git_credentials/azdo.py +29 -0
- agentworks/git_credentials/base.py +71 -0
- agentworks/git_credentials/github.py +22 -0
- agentworks/nerf-config.yaml +16 -0
- agentworks/output.py +296 -0
- agentworks/remote_exec.py +286 -0
- agentworks/sample-config.toml +289 -0
- agentworks/sessions/__init__.py +0 -0
- agentworks/sessions/console.py +164 -0
- agentworks/sessions/manager.py +1297 -0
- agentworks/sessions/templates.py +101 -0
- agentworks/sessions/tmux.py +503 -0
- agentworks/sources.py +303 -0
- agentworks/ssh.py +759 -0
- agentworks/ssh_config.py +255 -0
- agentworks/vm_hosts/__init__.py +0 -0
- agentworks/vm_hosts/manager.py +86 -0
- agentworks/vms/__init__.py +0 -0
- agentworks/vms/backup.py +409 -0
- agentworks/vms/base.py +56 -0
- agentworks/vms/bootstrap_script.py +185 -0
- agentworks/vms/cloud_init.py +55 -0
- agentworks/vms/initializer.py +1523 -0
- agentworks/vms/manager.py +1122 -0
- agentworks/vms/provisioners/__init__.py +0 -0
- agentworks/vms/provisioners/azure.py +602 -0
- agentworks/vms/provisioners/lima.py +295 -0
- agentworks/vms/provisioners/proxmox.py +279 -0
- agentworks/vms/provisioners/proxmox_api.py +261 -0
- agentworks/vms/provisioners/wsl2.py +340 -0
- agentworks/vms/templates.py +152 -0
- agentworks/workspaces/__init__.py +0 -0
- agentworks/workspaces/backends/__init__.py +0 -0
- agentworks/workspaces/backends/local.py +119 -0
- agentworks/workspaces/backends/vm.py +175 -0
- agentworks/workspaces/manager.py +1080 -0
- agentworks/workspaces/templates.py +76 -0
- agentworks/workspaces/tmuxinator.py +80 -0
- agentworks_cli-0.2.1.dist-info/METADATA +635 -0
- agentworks_cli-0.2.1.dist-info/RECORD +59 -0
- agentworks_cli-0.2.1.dist-info/WHEEL +4 -0
- agentworks_cli-0.2.1.dist-info/entry_points.txt +2 -0
|
File without changes
|
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"""Azure VM provisioner -- creates and manages VMs via the Azure SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import contextlib
|
|
7
|
+
from typing import TYPE_CHECKING, Protocol
|
|
8
|
+
|
|
9
|
+
from agentworks import output
|
|
10
|
+
from agentworks.db import VMStatus
|
|
11
|
+
from agentworks.ssh import ExecTarget, SSHError, SSHTarget
|
|
12
|
+
from agentworks.vms.base import ProvisionResult, VMProvisioner
|
|
13
|
+
from agentworks.vms.bootstrap_script import generate_bootstrap_script, vm_hostname
|
|
14
|
+
from agentworks.vms.cloud_init import PROVISIONING_PACKAGES, generate_cloud_init
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from azure.mgmt.compute import ComputeManagementClient
|
|
18
|
+
from azure.mgmt.network import NetworkManagementClient
|
|
19
|
+
|
|
20
|
+
from agentworks.config import Config
|
|
21
|
+
from agentworks.db import VMRow
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _HasSubscriptionId(Protocol):
|
|
25
|
+
"""Structural protocol for anything with subscription_id (AzureConfig or _MinimalAzureConfig)."""
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def subscription_id(self) -> str: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AzureError(RuntimeError):
|
|
32
|
+
"""An Azure API operation failed.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
summary: A concise, user-facing error message.
|
|
36
|
+
detail: The full error details (for logs).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, summary: str, detail: str) -> None:
|
|
40
|
+
super().__init__(summary)
|
|
41
|
+
self.summary = summary
|
|
42
|
+
self.detail = detail
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _wrap_azure_error(exc: Exception) -> AzureError:
|
|
46
|
+
"""Convert an Azure SDK exception into an AzureError."""
|
|
47
|
+
from azure.core.exceptions import HttpResponseError
|
|
48
|
+
|
|
49
|
+
if isinstance(exc, HttpResponseError):
|
|
50
|
+
# Walk inner errors to find the most specific message
|
|
51
|
+
code = exc.error.code if exc.error else None
|
|
52
|
+
message = exc.error.message if exc.error else str(exc)
|
|
53
|
+
|
|
54
|
+
if exc.error and exc.error.details:
|
|
55
|
+
inner = exc.error.details[0]
|
|
56
|
+
code = inner.code or code
|
|
57
|
+
message = inner.message or message
|
|
58
|
+
|
|
59
|
+
summary = f"{code}: {_trim_message(str(message))}" if code else _trim_message(str(message))
|
|
60
|
+
return AzureError(summary, detail=str(exc))
|
|
61
|
+
|
|
62
|
+
return AzureError(str(exc), detail=str(exc))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _trim_message(message: str) -> str:
|
|
66
|
+
"""Trim an Azure error message to the first meaningful sentence."""
|
|
67
|
+
# Cut at first URL or "Learn more" / "Submit a request" noise
|
|
68
|
+
for marker in [". Setup Alerts", ". Learn more", ". Submit a request", " https://"]:
|
|
69
|
+
idx = message.find(marker)
|
|
70
|
+
if idx != -1:
|
|
71
|
+
return message[: idx + 1] if marker.startswith(".") else message[:idx]
|
|
72
|
+
return message
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _get_credential() -> object:
|
|
76
|
+
"""Get an Azure credential.
|
|
77
|
+
|
|
78
|
+
Tries DefaultAzureCredential first (picks up az login, env vars,
|
|
79
|
+
managed identity, etc.). Falls back to interactive browser login
|
|
80
|
+
if nothing else works.
|
|
81
|
+
|
|
82
|
+
Returns object to avoid a hard import of azure.core at module load time.
|
|
83
|
+
Callers cast to the appropriate type when constructing SDK clients.
|
|
84
|
+
"""
|
|
85
|
+
from azure.core.exceptions import ClientAuthenticationError
|
|
86
|
+
from azure.identity import DefaultAzureCredential, InteractiveBrowserCredential
|
|
87
|
+
|
|
88
|
+
cred = DefaultAzureCredential()
|
|
89
|
+
try:
|
|
90
|
+
cred.get_token("https://management.azure.com/.default")
|
|
91
|
+
return cred
|
|
92
|
+
except ClientAuthenticationError:
|
|
93
|
+
output.info("No Azure credentials found, opening browser for login...")
|
|
94
|
+
return InteractiveBrowserCredential()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _compute_client(az: _HasSubscriptionId) -> ComputeManagementClient:
|
|
98
|
+
"""Create a ComputeManagementClient."""
|
|
99
|
+
from azure.mgmt.compute import ComputeManagementClient
|
|
100
|
+
|
|
101
|
+
# _get_credential() returns a TokenCredential-compatible object; the cast
|
|
102
|
+
# avoids a hard azure.core import at module load time.
|
|
103
|
+
return ComputeManagementClient(_get_credential(), az.subscription_id) # type: ignore[arg-type]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _network_client(az: _HasSubscriptionId) -> NetworkManagementClient:
|
|
107
|
+
"""Create a NetworkManagementClient."""
|
|
108
|
+
from azure.mgmt.network import NetworkManagementClient
|
|
109
|
+
|
|
110
|
+
# Same as _compute_client: credential is TokenCredential-compatible at runtime.
|
|
111
|
+
return NetworkManagementClient(_get_credential(), az.subscription_id) # type: ignore[arg-type]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class AzureProvisioner(VMProvisioner):
|
|
115
|
+
"""Provisions Azure VMs via the Azure Python SDK."""
|
|
116
|
+
|
|
117
|
+
def create(
|
|
118
|
+
self,
|
|
119
|
+
vm_name: str,
|
|
120
|
+
config: Config,
|
|
121
|
+
*,
|
|
122
|
+
azure_vm_size: str = "Standard_B2s",
|
|
123
|
+
disk: int = 50,
|
|
124
|
+
admin_username: str = "agentworks",
|
|
125
|
+
tailscale_auth_key: str | None = None,
|
|
126
|
+
) -> ProvisionResult:
|
|
127
|
+
assert config.azure is not None, "Azure config is required"
|
|
128
|
+
az = config.azure
|
|
129
|
+
|
|
130
|
+
output.info("Connecting to Azure...")
|
|
131
|
+
output.info(f"Provisioning Azure VM '{vm_name}' in {az.region} (size: {azure_vm_size})...")
|
|
132
|
+
if config.vm.swap > 0:
|
|
133
|
+
output.detail(f"Swap: {config.vm.swap} GiB")
|
|
134
|
+
|
|
135
|
+
ssh_pub_key = config.operator.ssh_public_key.read_text().strip()
|
|
136
|
+
|
|
137
|
+
# Generate the same bootstrap script used by Lima, wrapped in
|
|
138
|
+
# cloud-init write_files + runcmd for delivery via Azure custom_data.
|
|
139
|
+
if tailscale_auth_key:
|
|
140
|
+
bootstrap = generate_bootstrap_script(
|
|
141
|
+
admin_username=admin_username,
|
|
142
|
+
ssh_public_key=ssh_pub_key,
|
|
143
|
+
provisioning_packages=PROVISIONING_PACKAGES,
|
|
144
|
+
tailscale_auth_key=tailscale_auth_key,
|
|
145
|
+
hostname=vm_hostname("azure", vm_name),
|
|
146
|
+
swap=config.vm.swap,
|
|
147
|
+
)
|
|
148
|
+
cloud_init = generate_cloud_init(bootstrap)
|
|
149
|
+
else:
|
|
150
|
+
# No Tailscale key -- minimal cloud-init, bootstrap deferred to Phase A
|
|
151
|
+
cloud_init = "#cloud-config\npackage_update: true\npackages:\n - openssh-server\n"
|
|
152
|
+
cloud_init_b64 = base64.b64encode(cloud_init.encode()).decode()
|
|
153
|
+
|
|
154
|
+
compute = _compute_client(az)
|
|
155
|
+
network = _network_client(az)
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
# Create public IP
|
|
159
|
+
output.detail("Creating public IP...")
|
|
160
|
+
ip_poller = network.public_ip_addresses.begin_create_or_update( # type: ignore[call-overload]
|
|
161
|
+
az.resource_group,
|
|
162
|
+
f"{vm_name}-ip",
|
|
163
|
+
{
|
|
164
|
+
"location": az.region,
|
|
165
|
+
"sku": {"name": "Standard"},
|
|
166
|
+
"public_ip_allocation_method": "Static",
|
|
167
|
+
"tags": {"owner": "agentworks"},
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
ip_result = ip_poller.result()
|
|
171
|
+
public_ip = ip_result.ip_address or ""
|
|
172
|
+
|
|
173
|
+
# Create NSG with SSH rule
|
|
174
|
+
output.detail("Creating network security group...")
|
|
175
|
+
nsg_poller = network.network_security_groups.begin_create_or_update( # type: ignore[call-overload]
|
|
176
|
+
az.resource_group,
|
|
177
|
+
f"{vm_name}-nsg",
|
|
178
|
+
{
|
|
179
|
+
"location": az.region,
|
|
180
|
+
"security_rules": [
|
|
181
|
+
{
|
|
182
|
+
"name": "SSH",
|
|
183
|
+
"protocol": "Tcp",
|
|
184
|
+
"source_port_range": "*",
|
|
185
|
+
"destination_port_range": "22",
|
|
186
|
+
"source_address_prefix": "*",
|
|
187
|
+
"destination_address_prefix": "*",
|
|
188
|
+
"access": "Allow",
|
|
189
|
+
"priority": 1000,
|
|
190
|
+
"direction": "Inbound",
|
|
191
|
+
}
|
|
192
|
+
],
|
|
193
|
+
"tags": {"owner": "agentworks"},
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
nsg_result = nsg_poller.result()
|
|
197
|
+
|
|
198
|
+
# Create NIC
|
|
199
|
+
output.detail("Creating network interface...")
|
|
200
|
+
|
|
201
|
+
# Need a subnet -- use default VNet or create one
|
|
202
|
+
vnet_name = f"{vm_name}-vnet"
|
|
203
|
+
subnet_name = "default"
|
|
204
|
+
vnet_poller = network.virtual_networks.begin_create_or_update( # type: ignore[call-overload]
|
|
205
|
+
az.resource_group,
|
|
206
|
+
vnet_name,
|
|
207
|
+
{
|
|
208
|
+
"location": az.region,
|
|
209
|
+
"address_space": {"address_prefixes": ["10.0.0.0/16"]},
|
|
210
|
+
"subnets": [
|
|
211
|
+
{
|
|
212
|
+
"name": subnet_name,
|
|
213
|
+
"address_prefix": "10.0.0.0/24",
|
|
214
|
+
}
|
|
215
|
+
],
|
|
216
|
+
"tags": {"owner": "agentworks"},
|
|
217
|
+
},
|
|
218
|
+
)
|
|
219
|
+
vnet_result = vnet_poller.result()
|
|
220
|
+
subnet_id = vnet_result.subnets[0].id
|
|
221
|
+
|
|
222
|
+
nic_poller = network.network_interfaces.begin_create_or_update( # type: ignore[call-overload]
|
|
223
|
+
az.resource_group,
|
|
224
|
+
f"{vm_name}-nic",
|
|
225
|
+
{
|
|
226
|
+
"location": az.region,
|
|
227
|
+
"ip_configurations": [
|
|
228
|
+
{
|
|
229
|
+
"name": "default",
|
|
230
|
+
"subnet": {"id": subnet_id},
|
|
231
|
+
"public_ip_address": {"id": ip_result.id},
|
|
232
|
+
}
|
|
233
|
+
],
|
|
234
|
+
"network_security_group": {"id": nsg_result.id},
|
|
235
|
+
"tags": {"owner": "agentworks"},
|
|
236
|
+
},
|
|
237
|
+
)
|
|
238
|
+
nic_result = nic_poller.result()
|
|
239
|
+
|
|
240
|
+
# Create VM
|
|
241
|
+
output.detail("Creating VM...")
|
|
242
|
+
vm_poller = compute.virtual_machines.begin_create_or_update( # type: ignore[call-overload]
|
|
243
|
+
az.resource_group,
|
|
244
|
+
vm_name,
|
|
245
|
+
{
|
|
246
|
+
"location": az.region,
|
|
247
|
+
"hardware_profile": {"vm_size": azure_vm_size},
|
|
248
|
+
"storage_profile": {
|
|
249
|
+
"image_reference": {
|
|
250
|
+
"publisher": "Debian",
|
|
251
|
+
"offer": "debian-12",
|
|
252
|
+
"sku": "12-gen2",
|
|
253
|
+
"version": "latest",
|
|
254
|
+
},
|
|
255
|
+
"os_disk": {
|
|
256
|
+
"create_option": "FromImage",
|
|
257
|
+
"disk_size_gb": disk,
|
|
258
|
+
"managed_disk": {"storage_account_type": "StandardSSD_LRS"},
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
"os_profile": {
|
|
262
|
+
"computer_name": vm_name,
|
|
263
|
+
"admin_username": admin_username,
|
|
264
|
+
"custom_data": cloud_init_b64,
|
|
265
|
+
"linux_configuration": {
|
|
266
|
+
"disable_password_authentication": True,
|
|
267
|
+
"ssh": {
|
|
268
|
+
"public_keys": [
|
|
269
|
+
{
|
|
270
|
+
"path": f"/home/{admin_username}/.ssh/authorized_keys",
|
|
271
|
+
"key_data": ssh_pub_key,
|
|
272
|
+
}
|
|
273
|
+
]
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
"network_profile": {
|
|
278
|
+
"network_interfaces": [{"id": nic_result.id}],
|
|
279
|
+
},
|
|
280
|
+
"tags": {"owner": "agentworks"},
|
|
281
|
+
},
|
|
282
|
+
)
|
|
283
|
+
vm_result = vm_poller.result()
|
|
284
|
+
resource_id = vm_result.id or ""
|
|
285
|
+
|
|
286
|
+
except Exception as exc:
|
|
287
|
+
output.detail("Cleaning up resources...")
|
|
288
|
+
_cleanup_vm_resources(compute, network, az.resource_group, vm_name)
|
|
289
|
+
raise _wrap_azure_error(exc) from exc
|
|
290
|
+
|
|
291
|
+
output.detail(f"Azure VM '{vm_name}' provisioned (IP: {public_ip}).")
|
|
292
|
+
|
|
293
|
+
import sys
|
|
294
|
+
|
|
295
|
+
exec_target = ExecTarget(
|
|
296
|
+
ssh=SSHTarget(
|
|
297
|
+
host=public_ip,
|
|
298
|
+
user=admin_username,
|
|
299
|
+
identity_file=config.operator.ssh_private_key,
|
|
300
|
+
force_tty=sys.platform == "win32",
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# If bootstrap was embedded in cloud-init, wait for it to finish
|
|
305
|
+
# and extract the Tailscale IP.
|
|
306
|
+
tailscale_ip = None
|
|
307
|
+
bootstrap_complete = False
|
|
308
|
+
if tailscale_auth_key:
|
|
309
|
+
tailscale_ip = self._wait_for_bootstrap(exec_target, vm_name)
|
|
310
|
+
if tailscale_ip:
|
|
311
|
+
bootstrap_complete = True
|
|
312
|
+
|
|
313
|
+
return ProvisionResult(
|
|
314
|
+
admin_exec_target=exec_target,
|
|
315
|
+
azure_resource_id=resource_id or None,
|
|
316
|
+
bootstrap_complete=bootstrap_complete,
|
|
317
|
+
tailscale_ip=tailscale_ip,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def _wait_for_bootstrap(self, exec_target: ExecTarget, vm_name: str) -> str | None:
|
|
321
|
+
"""Wait for cloud-init to finish and return the Tailscale IP.
|
|
322
|
+
|
|
323
|
+
SSH may not be immediately available after VM creation, so we retry.
|
|
324
|
+
Returns None if we cannot get the IP (Phase A will handle it).
|
|
325
|
+
"""
|
|
326
|
+
import time
|
|
327
|
+
|
|
328
|
+
from agentworks.ssh import run as ssh_run
|
|
329
|
+
|
|
330
|
+
output.detail("Waiting for cloud-init bootstrap to complete (this may take several minutes)...")
|
|
331
|
+
|
|
332
|
+
# Wait for SSH to become available
|
|
333
|
+
assert exec_target.ssh is not None
|
|
334
|
+
for attempt in range(30):
|
|
335
|
+
try:
|
|
336
|
+
ssh_run(exec_target.ssh, "echo ok", check=True, timeout=10)
|
|
337
|
+
break
|
|
338
|
+
except SSHError:
|
|
339
|
+
if attempt == 29:
|
|
340
|
+
output.warn("SSH not available, deferring bootstrap to Phase A")
|
|
341
|
+
return None
|
|
342
|
+
time.sleep(10)
|
|
343
|
+
|
|
344
|
+
# Wait for cloud-init to finish
|
|
345
|
+
try:
|
|
346
|
+
ssh_run(
|
|
347
|
+
exec_target.ssh,
|
|
348
|
+
"cloud-init status --wait",
|
|
349
|
+
check=True,
|
|
350
|
+
timeout=600,
|
|
351
|
+
)
|
|
352
|
+
except SSHError as e:
|
|
353
|
+
output.warn(f"cloud-init wait failed: {e}")
|
|
354
|
+
output.warn("Deferring bootstrap to Phase A")
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
# Get Tailscale IP
|
|
358
|
+
try:
|
|
359
|
+
result = ssh_run(exec_target.ssh, "sudo tailscale ip -4", check=True, timeout=15)
|
|
360
|
+
tailscale_ip = result.stdout.strip()
|
|
361
|
+
output.detail(f"Tailscale IP: {tailscale_ip}")
|
|
362
|
+
return tailscale_ip
|
|
363
|
+
except SSHError as e:
|
|
364
|
+
output.warn(f"could not retrieve Tailscale IP: {e}")
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
def start(self, vm: VMRow) -> None:
|
|
368
|
+
output.info(f"Starting Azure VM '{vm.name}'...")
|
|
369
|
+
assert vm.azure_resource_id is not None
|
|
370
|
+
rg, name, az_cfg = _parse_resource_id(vm.azure_resource_id)
|
|
371
|
+
try:
|
|
372
|
+
compute = _compute_client(az_cfg)
|
|
373
|
+
compute.virtual_machines.begin_start(rg, name).result()
|
|
374
|
+
except Exception as exc:
|
|
375
|
+
raise _wrap_azure_error(exc) from exc
|
|
376
|
+
output.info(f"Azure VM '{vm.name}' started")
|
|
377
|
+
|
|
378
|
+
def stop(self, vm: VMRow) -> None:
|
|
379
|
+
output.info(f"Deallocating Azure VM '{vm.name}'...")
|
|
380
|
+
assert vm.azure_resource_id is not None
|
|
381
|
+
rg, name, az_cfg = _parse_resource_id(vm.azure_resource_id)
|
|
382
|
+
try:
|
|
383
|
+
compute = _compute_client(az_cfg)
|
|
384
|
+
compute.virtual_machines.begin_deallocate(rg, name).result()
|
|
385
|
+
except Exception as exc:
|
|
386
|
+
raise _wrap_azure_error(exc) from exc
|
|
387
|
+
output.info(f"Azure VM '{vm.name}' deallocated")
|
|
388
|
+
|
|
389
|
+
def delete(self, vm: VMRow) -> None:
|
|
390
|
+
output.info(f"Deleting Azure VM '{vm.name}'...")
|
|
391
|
+
if vm.azure_resource_id is None:
|
|
392
|
+
output.warn("no Azure resource ID, skipping Azure cleanup")
|
|
393
|
+
return
|
|
394
|
+
|
|
395
|
+
rg, name, az_cfg = _parse_resource_id(vm.azure_resource_id)
|
|
396
|
+
compute = _compute_client(az_cfg)
|
|
397
|
+
network = _network_client(az_cfg)
|
|
398
|
+
|
|
399
|
+
# Delete VM first (must complete before dependent resources)
|
|
400
|
+
with contextlib.suppress(Exception):
|
|
401
|
+
compute.virtual_machines.begin_delete(rg, name).result()
|
|
402
|
+
|
|
403
|
+
_cleanup_vm_resources(compute, network, rg, name)
|
|
404
|
+
|
|
405
|
+
output.info(f"Azure VM '{vm.name}' deleted")
|
|
406
|
+
|
|
407
|
+
def attach_public_ip(self, vm: VMRow) -> str:
|
|
408
|
+
"""Attach a temporary public IP to the VM's NIC. Returns the IP address."""
|
|
409
|
+
assert vm.azure_resource_id is not None
|
|
410
|
+
rg, name, az_cfg = _parse_resource_id(vm.azure_resource_id)
|
|
411
|
+
network = _network_client(az_cfg)
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
# Create (or re-create) the public IP
|
|
415
|
+
output.detail("Attaching temporary public IP...")
|
|
416
|
+
ip_poller = network.public_ip_addresses.begin_create_or_update( # type: ignore[call-overload]
|
|
417
|
+
rg,
|
|
418
|
+
f"{name}-ip",
|
|
419
|
+
{
|
|
420
|
+
"location": _get_vm_location(vm),
|
|
421
|
+
"sku": {"name": "Standard"},
|
|
422
|
+
"public_ip_allocation_method": "Static",
|
|
423
|
+
"tags": {"owner": "agentworks"},
|
|
424
|
+
},
|
|
425
|
+
)
|
|
426
|
+
ip_result = ip_poller.result()
|
|
427
|
+
|
|
428
|
+
# Attach to NIC
|
|
429
|
+
nic = network.network_interfaces.get(rg, f"{name}-nic")
|
|
430
|
+
if nic.ip_configurations:
|
|
431
|
+
# Azure SDK accepts dict for PublicIPAddress at runtime despite type stubs
|
|
432
|
+
nic.ip_configurations[0].public_ip_address = {"id": ip_result.id} # type: ignore[assignment]
|
|
433
|
+
network.network_interfaces.begin_create_or_update(
|
|
434
|
+
rg,
|
|
435
|
+
f"{name}-nic",
|
|
436
|
+
nic,
|
|
437
|
+
).result()
|
|
438
|
+
|
|
439
|
+
except Exception as exc:
|
|
440
|
+
raise _wrap_azure_error(exc) from exc
|
|
441
|
+
|
|
442
|
+
return ip_result.ip_address or ""
|
|
443
|
+
|
|
444
|
+
def detach_public_ip(self, vm: VMRow) -> None:
|
|
445
|
+
"""Detach and delete the public IP from the VM's NIC."""
|
|
446
|
+
assert vm.azure_resource_id is not None
|
|
447
|
+
rg, name, az_cfg = _parse_resource_id(vm.azure_resource_id)
|
|
448
|
+
network = _network_client(az_cfg)
|
|
449
|
+
|
|
450
|
+
output.detail("Removing public IP...")
|
|
451
|
+
# Detach from NIC
|
|
452
|
+
with contextlib.suppress(Exception):
|
|
453
|
+
nic = network.network_interfaces.get(rg, f"{name}-nic")
|
|
454
|
+
if nic.ip_configurations:
|
|
455
|
+
nic.ip_configurations[0].public_ip_address = None
|
|
456
|
+
network.network_interfaces.begin_create_or_update(
|
|
457
|
+
rg,
|
|
458
|
+
f"{name}-nic",
|
|
459
|
+
nic,
|
|
460
|
+
).result()
|
|
461
|
+
|
|
462
|
+
# Delete the public IP resource
|
|
463
|
+
with contextlib.suppress(Exception):
|
|
464
|
+
network.public_ip_addresses.begin_delete(rg, f"{name}-ip").result()
|
|
465
|
+
|
|
466
|
+
def admin_exec_target(self, vm: VMRow, *, config: object | None = None) -> ExecTarget:
|
|
467
|
+
assert vm.azure_resource_id is not None
|
|
468
|
+
rg, name, az_cfg = _parse_resource_id(vm.azure_resource_id)
|
|
469
|
+
try:
|
|
470
|
+
compute = _compute_client(az_cfg)
|
|
471
|
+
vm_info = compute.virtual_machines.get(
|
|
472
|
+
rg,
|
|
473
|
+
name,
|
|
474
|
+
expand="instanceView",
|
|
475
|
+
)
|
|
476
|
+
except Exception as exc:
|
|
477
|
+
raise _wrap_azure_error(exc) from exc
|
|
478
|
+
|
|
479
|
+
# Walk NICs to find the public IP (may not exist if detached)
|
|
480
|
+
public_ip = _get_vm_public_ip(vm_info, az_cfg)
|
|
481
|
+
import sys
|
|
482
|
+
|
|
483
|
+
# Include identity file if config is available (needed for SSH auth
|
|
484
|
+
# via public IP, e.g., during Tailscale logout on delete)
|
|
485
|
+
identity_file = None
|
|
486
|
+
if config is not None:
|
|
487
|
+
identity_file = getattr(getattr(config, "operator", None), "ssh_private_key", None)
|
|
488
|
+
|
|
489
|
+
return ExecTarget(
|
|
490
|
+
ssh=SSHTarget(
|
|
491
|
+
host=public_ip,
|
|
492
|
+
user=vm.admin_username,
|
|
493
|
+
identity_file=identity_file,
|
|
494
|
+
force_tty=sys.platform == "win32",
|
|
495
|
+
),
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
def status(self, vm: VMRow) -> VMStatus:
|
|
499
|
+
if vm.azure_resource_id is None:
|
|
500
|
+
return VMStatus.UNKNOWN
|
|
501
|
+
rg, name, az_cfg = _parse_resource_id(vm.azure_resource_id)
|
|
502
|
+
try:
|
|
503
|
+
compute = _compute_client(az_cfg)
|
|
504
|
+
instance = compute.virtual_machines.instance_view(rg, name)
|
|
505
|
+
except Exception:
|
|
506
|
+
return VMStatus.UNKNOWN
|
|
507
|
+
|
|
508
|
+
for s in instance.statuses or []:
|
|
509
|
+
code = s.code or ""
|
|
510
|
+
if code == "PowerState/running":
|
|
511
|
+
return VMStatus.RUNNING
|
|
512
|
+
if code == "PowerState/stopped":
|
|
513
|
+
return VMStatus.STOPPED
|
|
514
|
+
if code == "PowerState/deallocated":
|
|
515
|
+
return VMStatus.DEALLOCATED
|
|
516
|
+
return VMStatus.UNKNOWN
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _get_vm_public_ip(vm_info: object, az_cfg: _HasSubscriptionId) -> str:
|
|
520
|
+
"""Resolve the public IP address for a VM from its NIC."""
|
|
521
|
+
network = _network_client(az_cfg)
|
|
522
|
+
|
|
523
|
+
nic_refs = (
|
|
524
|
+
getattr(
|
|
525
|
+
getattr(vm_info, "network_profile", None),
|
|
526
|
+
"network_interfaces",
|
|
527
|
+
[],
|
|
528
|
+
)
|
|
529
|
+
or []
|
|
530
|
+
)
|
|
531
|
+
for nic_ref in nic_refs:
|
|
532
|
+
nic_id = nic_ref.id
|
|
533
|
+
if not nic_id:
|
|
534
|
+
continue
|
|
535
|
+
# Parse NIC resource group and name from ID
|
|
536
|
+
parts = nic_id.split("/")
|
|
537
|
+
rg_idx = next(i for i, p in enumerate(parts) if p.lower() == "resourcegroups")
|
|
538
|
+
nic_rg = parts[rg_idx + 1]
|
|
539
|
+
nic_name = parts[-1]
|
|
540
|
+
|
|
541
|
+
nic = network.network_interfaces.get(nic_rg, nic_name)
|
|
542
|
+
for ip_config in nic.ip_configurations or []:
|
|
543
|
+
pip_ref = ip_config.public_ip_address
|
|
544
|
+
if pip_ref and pip_ref.id:
|
|
545
|
+
pip_parts = pip_ref.id.split("/")
|
|
546
|
+
pip_rg_idx = next(i for i, p in enumerate(pip_parts) if p.lower() == "resourcegroups")
|
|
547
|
+
pip_rg = pip_parts[pip_rg_idx + 1]
|
|
548
|
+
pip_name = pip_parts[-1]
|
|
549
|
+
pip = network.public_ip_addresses.get(pip_rg, pip_name)
|
|
550
|
+
if pip.ip_address:
|
|
551
|
+
return pip.ip_address
|
|
552
|
+
return ""
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _cleanup_vm_resources(
|
|
556
|
+
compute: ComputeManagementClient,
|
|
557
|
+
network: NetworkManagementClient,
|
|
558
|
+
rg: str,
|
|
559
|
+
name: str,
|
|
560
|
+
) -> None:
|
|
561
|
+
"""Best-effort cleanup of all resources associated with a VM."""
|
|
562
|
+
for cleanup in [
|
|
563
|
+
lambda: network.network_interfaces.begin_delete(rg, f"{name}-nic").result(),
|
|
564
|
+
lambda: network.public_ip_addresses.begin_delete(rg, f"{name}-ip").result(),
|
|
565
|
+
lambda: network.network_security_groups.begin_delete(rg, f"{name}-nsg").result(),
|
|
566
|
+
lambda: network.virtual_networks.begin_delete(rg, f"{name}-vnet").result(),
|
|
567
|
+
]:
|
|
568
|
+
with contextlib.suppress(Exception):
|
|
569
|
+
cleanup() # type: ignore[no-untyped-call]
|
|
570
|
+
|
|
571
|
+
# OS disk name is generated by Azure, find by tag
|
|
572
|
+
with contextlib.suppress(Exception):
|
|
573
|
+
for disk in compute.disks.list_by_resource_group(rg):
|
|
574
|
+
disk_name = disk.name or ""
|
|
575
|
+
if disk.tags and disk.tags.get("owner") == "agentworks" and name in disk_name and disk_name:
|
|
576
|
+
compute.disks.begin_delete(rg, disk_name).result()
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _get_vm_location(vm: VMRow) -> str:
|
|
580
|
+
"""Get the Azure region for a VM by querying the compute API."""
|
|
581
|
+
assert vm.azure_resource_id is not None
|
|
582
|
+
rg, name, az_cfg = _parse_resource_id(vm.azure_resource_id)
|
|
583
|
+
compute = _compute_client(az_cfg)
|
|
584
|
+
vm_info = compute.virtual_machines.get(rg, name)
|
|
585
|
+
return vm_info.location or "eastus"
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
class _MinimalAzureConfig:
|
|
589
|
+
"""Minimal config for SDK clients, parsed from a resource ID."""
|
|
590
|
+
|
|
591
|
+
def __init__(self, subscription_id: str) -> None:
|
|
592
|
+
self.subscription_id = subscription_id
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _parse_resource_id(resource_id: str) -> tuple[str, str, _MinimalAzureConfig]:
|
|
596
|
+
"""Extract resource group, VM name, and a config from an Azure resource ID."""
|
|
597
|
+
parts = resource_id.split("/")
|
|
598
|
+
sub_idx = next(i for i, p in enumerate(parts) if p.lower() == "subscriptions")
|
|
599
|
+
rg_idx = next(i for i, p in enumerate(parts) if p.lower() == "resourcegroups")
|
|
600
|
+
name_idx = next(i for i, p in enumerate(parts) if p.lower() == "virtualmachines")
|
|
601
|
+
cfg = _MinimalAzureConfig(parts[sub_idx + 1])
|
|
602
|
+
return parts[rg_idx + 1], parts[name_idx + 1], cfg
|