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.
Files changed (59) hide show
  1. agentworks/__init__.py +1 -0
  2. agentworks/agents/__init__.py +0 -0
  3. agentworks/agents/manager.py +1095 -0
  4. agentworks/agents/templates.py +145 -0
  5. agentworks/catalog.py +264 -0
  6. agentworks/catalog.toml +131 -0
  7. agentworks/cli.py +1462 -0
  8. agentworks/completions/__init__.py +33 -0
  9. agentworks/completions/bash.py +179 -0
  10. agentworks/completions/install.py +122 -0
  11. agentworks/completions/powershell.py +270 -0
  12. agentworks/completions/spec.py +216 -0
  13. agentworks/completions/zsh.py +256 -0
  14. agentworks/config.py +894 -0
  15. agentworks/db.py +1083 -0
  16. agentworks/doctor.py +430 -0
  17. agentworks/git_credentials/__init__.py +0 -0
  18. agentworks/git_credentials/azdo.py +29 -0
  19. agentworks/git_credentials/base.py +71 -0
  20. agentworks/git_credentials/github.py +22 -0
  21. agentworks/nerf-config.yaml +16 -0
  22. agentworks/output.py +296 -0
  23. agentworks/remote_exec.py +286 -0
  24. agentworks/sample-config.toml +289 -0
  25. agentworks/sessions/__init__.py +0 -0
  26. agentworks/sessions/console.py +164 -0
  27. agentworks/sessions/manager.py +1297 -0
  28. agentworks/sessions/templates.py +101 -0
  29. agentworks/sessions/tmux.py +503 -0
  30. agentworks/sources.py +303 -0
  31. agentworks/ssh.py +759 -0
  32. agentworks/ssh_config.py +255 -0
  33. agentworks/vm_hosts/__init__.py +0 -0
  34. agentworks/vm_hosts/manager.py +86 -0
  35. agentworks/vms/__init__.py +0 -0
  36. agentworks/vms/backup.py +409 -0
  37. agentworks/vms/base.py +56 -0
  38. agentworks/vms/bootstrap_script.py +185 -0
  39. agentworks/vms/cloud_init.py +55 -0
  40. agentworks/vms/initializer.py +1523 -0
  41. agentworks/vms/manager.py +1122 -0
  42. agentworks/vms/provisioners/__init__.py +0 -0
  43. agentworks/vms/provisioners/azure.py +602 -0
  44. agentworks/vms/provisioners/lima.py +295 -0
  45. agentworks/vms/provisioners/proxmox.py +279 -0
  46. agentworks/vms/provisioners/proxmox_api.py +261 -0
  47. agentworks/vms/provisioners/wsl2.py +340 -0
  48. agentworks/vms/templates.py +152 -0
  49. agentworks/workspaces/__init__.py +0 -0
  50. agentworks/workspaces/backends/__init__.py +0 -0
  51. agentworks/workspaces/backends/local.py +119 -0
  52. agentworks/workspaces/backends/vm.py +175 -0
  53. agentworks/workspaces/manager.py +1080 -0
  54. agentworks/workspaces/templates.py +76 -0
  55. agentworks/workspaces/tmuxinator.py +80 -0
  56. agentworks_cli-0.2.1.dist-info/METADATA +635 -0
  57. agentworks_cli-0.2.1.dist-info/RECORD +59 -0
  58. agentworks_cli-0.2.1.dist-info/WHEEL +4 -0
  59. 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