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
@@ -0,0 +1,295 @@
1
+ """Lima VM provisioner -- local and remote VM Host variants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shlex
7
+ import tempfile
8
+ import textwrap
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ from agentworks import output
13
+ from agentworks.db import VMStatus
14
+ from agentworks.ssh import ExecTarget, LimaTarget, RemoteLimaTarget, SSHError, SSHTarget, copy_to
15
+ from agentworks.ssh import run as ssh_run
16
+ from agentworks.vms.base import ProvisionResult, VMProvisioner
17
+ from agentworks.vms.bootstrap_script import generate_bootstrap_script, parse_bootstrap_output, vm_hostname
18
+ from agentworks.vms.cloud_init import PROVISIONING_PACKAGES
19
+
20
+ if TYPE_CHECKING:
21
+ from agentworks.config import Config
22
+ from agentworks.db import VMRow
23
+
24
+ # Lima template for Debian cloud VMs (values substituted at create time).
25
+ # The provision block runs the full bootstrap script (user, packages, swap,
26
+ # SSH key, Tailscale) as a system-level provisioner during limactl start.
27
+ LIMA_TEMPLATE = """\
28
+ # Agentworks Debian VM template for Lima
29
+ arch: default
30
+ images:
31
+ - location: https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2
32
+ arch: x86_64
33
+ - location: https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-arm64.qcow2
34
+ arch: aarch64
35
+ cpus: {cpus}
36
+ memory: {memory}GiB
37
+ disk: {disk}GiB
38
+ ssh:
39
+ localPort: 0
40
+ mountType: virtiofs
41
+ provision:
42
+ - mode: system
43
+ script: |
44
+ {provision_script}
45
+ """
46
+
47
+
48
+ class LimaProvisioner(VMProvisioner):
49
+ """Provisions Lima VMs, either locally or on a remote VM Host."""
50
+
51
+ def __init__(self, vm_host_ssh: str | None = None) -> None:
52
+ """Initialize the Lima provisioner.
53
+
54
+ Args:
55
+ vm_host_ssh: SSH host for remote mode. None for local mode.
56
+ """
57
+ self._vm_host_ssh = vm_host_ssh
58
+
59
+ @property
60
+ def is_remote(self) -> bool:
61
+ return self._vm_host_ssh is not None
62
+
63
+ def _run_lima(self, command: str, *, check: bool = True) -> str:
64
+ """Run a limactl command, locally or on the VM Host."""
65
+ if self.is_remote:
66
+ assert self._vm_host_ssh is not None
67
+ target = SSHTarget(host=self._vm_host_ssh, user=None, login_shell=True)
68
+ result = ssh_run(target, command, check=check)
69
+ return result.stdout
70
+ else:
71
+ import subprocess
72
+
73
+ proc = subprocess.run(
74
+ shlex.split(command),
75
+ capture_output=True,
76
+ text=True,
77
+ encoding="utf-8",
78
+ errors="replace",
79
+ )
80
+ if check and proc.returncode != 0:
81
+ raise SSHError(f"limactl failed: {proc.stderr.strip()}")
82
+ return proc.stdout
83
+
84
+ def create(
85
+ self,
86
+ vm_name: str,
87
+ config: Config,
88
+ *,
89
+ cpus: int = 4,
90
+ memory: int = 8,
91
+ disk: int = 50,
92
+ tailscale_auth_key: str | None = None,
93
+ ) -> ProvisionResult:
94
+ if not self.is_remote:
95
+ import shutil
96
+
97
+ if not shutil.which("limactl"):
98
+ from agentworks.output import VMError
99
+
100
+ raise VMError(
101
+ "'limactl' not found. Lima is not installed on this machine. "
102
+ "For remote Lima VMs, set defaults.vm_host in your config or pass --vm-host."
103
+ )
104
+
105
+ if self.is_remote:
106
+ output.info(f"Connecting to VM host '{self._vm_host_ssh}'...")
107
+ output.info(f"Creating Lima VM '{vm_name}' ({'remote' if self.is_remote else 'local'})...")
108
+ output.detail(f"Resources: {cpus} CPUs, {memory} GiB memory, {disk} GiB disk")
109
+ if config.vm.swap > 0:
110
+ output.detail(f"Swap: {config.vm.swap} GiB")
111
+
112
+ # Generate the full bootstrap script and embed in the Lima provision block.
113
+ # This handles user creation, system packages, swap, SSH key, and Tailscale.
114
+ if tailscale_auth_key:
115
+ ssh_pub_key = config.operator.ssh_public_key.read_text().strip()
116
+ provision_script = generate_bootstrap_script(
117
+ admin_username=config.admin.username,
118
+ ssh_public_key=ssh_pub_key,
119
+ provisioning_packages=PROVISIONING_PACKAGES,
120
+ tailscale_auth_key=tailscale_auth_key,
121
+ hostname=vm_hostname("lima", vm_name),
122
+ swap=config.vm.swap,
123
+ )
124
+ else:
125
+ # No Tailscale key -- provision block is a no-op.
126
+ # Phase A bootstrap will handle everything separately.
127
+ provision_script = (
128
+ "#!/bin/bash\necho '##STEP## Provision'\necho '##SUCCESS## no-op (deferred to Phase A)'\n"
129
+ )
130
+
131
+ # Indent the provision script for YAML embedding (6 spaces)
132
+ indented_script = textwrap.indent(provision_script, " ")
133
+ rendered = LIMA_TEMPLATE.format(cpus=cpus, memory=memory, disk=disk, provision_script=indented_script)
134
+
135
+ if self.is_remote:
136
+ self._create_remote(vm_name, rendered)
137
+ else:
138
+ self._create_local(vm_name, rendered)
139
+
140
+ output.detail(f"Lima VM '{vm_name}' created.")
141
+
142
+ # If Tailscale was provisioned via the provision block, extract the IP
143
+ tailscale_ip = None
144
+ bootstrap_complete = False
145
+ if tailscale_auth_key:
146
+ output.detail("Retrieving Tailscale IP...")
147
+ try:
148
+ ip_output = self._run_lima(f"limactl shell {vm_name} sudo tailscale ip -4")
149
+ tailscale_ip = ip_output.strip()
150
+ bootstrap_complete = True
151
+ output.detail(f"Tailscale IP: {tailscale_ip}")
152
+ except SSHError as e:
153
+ output.warn(f"could not retrieve Tailscale IP: {e}")
154
+ output.warn("Tailscale will be set up during Phase A bootstrap.")
155
+
156
+ if self.is_remote:
157
+ assert self._vm_host_ssh is not None
158
+ return ProvisionResult(
159
+ admin_exec_target=ExecTarget(
160
+ remote_lima=RemoteLimaTarget(vm_name=vm_name, vm_host_ssh=self._vm_host_ssh),
161
+ ),
162
+ bootstrap_complete=bootstrap_complete,
163
+ tailscale_ip=tailscale_ip,
164
+ )
165
+ else:
166
+ return ProvisionResult(
167
+ admin_exec_target=ExecTarget(lima=LimaTarget(vm_name=vm_name)),
168
+ bootstrap_complete=bootstrap_complete,
169
+ tailscale_ip=tailscale_ip,
170
+ )
171
+
172
+ def _create_local(self, vm_name: str, lima_yaml: str) -> None:
173
+ """Create and start a Lima VM locally."""
174
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
175
+ f.write(lima_yaml)
176
+ template_path = f.name
177
+
178
+ try:
179
+ self._run_lima(f"limactl create --name {vm_name} --tty=false {template_path}")
180
+ self._run_lima(f"limactl start {vm_name}")
181
+ except SSHError:
182
+ self._log_provision_errors(vm_name)
183
+ raise
184
+ finally:
185
+ Path(template_path).unlink(missing_ok=True)
186
+
187
+ def _create_remote(self, vm_name: str, lima_yaml: str) -> None:
188
+ """Create and start a Lima VM on a remote VM Host."""
189
+ assert self._vm_host_ssh is not None
190
+ target = SSHTarget(host=self._vm_host_ssh, user=None)
191
+
192
+ # Write Lima YAML locally and copy to VM Host
193
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
194
+ f.write(lima_yaml)
195
+ local_template = f.name
196
+
197
+ remote_template = f"/tmp/agentworks-{vm_name}.yaml"
198
+ copy_to(target, local_template, remote_template)
199
+ Path(local_template).unlink()
200
+
201
+ # Run limactl create + start as a single detached operation
202
+ from agentworks.remote_exec import run_detached
203
+ from agentworks.ssh import SSHLogger
204
+
205
+ ssh_logger = SSHLogger(vm_name, "vm-provision")
206
+ host_target = ExecTarget(
207
+ ssh=SSHTarget(host=self._vm_host_ssh, user=None, login_shell=True),
208
+ logger=ssh_logger,
209
+ )
210
+ lima_cmd = f"limactl create --name {vm_name} --tty=false {remote_template} && limactl start {vm_name}"
211
+ output.detail("Starting and provisioning VM via Lima (this may take several minutes)...")
212
+ result = run_detached(
213
+ host_target,
214
+ lima_cmd,
215
+ label=f"Lima ({vm_name})",
216
+ base_path=f"/tmp/agentworks-lima-{vm_name}",
217
+ timeout=600,
218
+ quiet=True,
219
+ )
220
+ if result.exit_code != 0:
221
+ # Parse structured markers from provision script output if present
222
+ bootstrap = parse_bootstrap_output(result.output, result.exit_code)
223
+ for step in bootstrap.steps:
224
+ if step.error:
225
+ ssh_logger.log_error(f"Provision step '{step.name}': {step.error}")
226
+
227
+ ssh_logger.log_error(f"limactl failed (exit {result.exit_code})")
228
+ ssh_logger.log_error(result.output)
229
+ ssh_logger.close()
230
+ raise SSHError(
231
+ f"limactl create/start failed (exit {result.exit_code})\n"
232
+ f"SSH log: {ssh_logger.path}\n"
233
+ f"Last output:\n{result.output[-1000:]}"
234
+ )
235
+ ssh_logger.close()
236
+
237
+ # Clean up remote temp file
238
+ ssh_run(target, f"rm -f {remote_template}", check=False)
239
+
240
+ def _log_provision_errors(self, vm_name: str) -> None:
241
+ """Attempt to surface provision script errors from Lima logs."""
242
+ try:
243
+ log_output = self._run_lima(
244
+ f"limactl shell {vm_name} cat /var/log/cloud-init-output.log 2>/dev/null || true",
245
+ check=False,
246
+ )
247
+ if log_output.strip():
248
+ bootstrap = parse_bootstrap_output(log_output, 1)
249
+ for step in bootstrap.steps:
250
+ if step.error:
251
+ output.warn(f"Provision error ({step.name}): {step.error}")
252
+ except SSHError:
253
+ pass
254
+
255
+ def start(self, vm: VMRow) -> None:
256
+ output.info(f"Starting Lima VM '{vm.name}'...")
257
+ self._run_lima(f"limactl start {vm.name}")
258
+ output.info(f"Lima VM '{vm.name}' started")
259
+
260
+ def stop(self, vm: VMRow) -> None:
261
+ output.info(f"Stopping Lima VM '{vm.name}'...")
262
+ self._run_lima(f"limactl stop {vm.name}")
263
+ output.info(f"Lima VM '{vm.name}' stopped")
264
+
265
+ def delete(self, vm: VMRow) -> None:
266
+ output.info(f"Deleting Lima VM '{vm.name}'...")
267
+ self._run_lima(f"limactl delete --force {vm.name}", check=False)
268
+ output.info(f"Lima VM '{vm.name}' deleted")
269
+
270
+ def admin_exec_target(self, vm: VMRow, *, config: object | None = None) -> ExecTarget:
271
+ if self.is_remote:
272
+ assert self._vm_host_ssh is not None
273
+ return ExecTarget(
274
+ remote_lima=RemoteLimaTarget(vm_name=vm.name, vm_host_ssh=self._vm_host_ssh),
275
+ )
276
+ return ExecTarget(lima=LimaTarget(vm_name=vm.name))
277
+
278
+ def status(self, vm: VMRow) -> VMStatus:
279
+ try:
280
+ output = self._run_lima(f"limactl list --json {vm.name}", check=False)
281
+ except SSHError:
282
+ return VMStatus.UNKNOWN
283
+
284
+ for line in output.strip().splitlines():
285
+ try:
286
+ entry = json.loads(line)
287
+ except json.JSONDecodeError:
288
+ continue
289
+ raw_status = entry.get("status", "").lower()
290
+ if raw_status == "running":
291
+ return VMStatus.RUNNING
292
+ if raw_status == "stopped":
293
+ return VMStatus.STOPPED
294
+ return VMStatus.UNKNOWN
295
+ return VMStatus.UNKNOWN
@@ -0,0 +1,279 @@
1
+ """Proxmox VE VM provisioner -- creates and manages VMs via the Proxmox REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ import urllib.parse
8
+ from typing import TYPE_CHECKING
9
+
10
+ from agentworks import output
11
+ from agentworks.db import VMStatus
12
+ from agentworks.ssh import ExecTarget, SSHTarget
13
+ from agentworks.vms.base import ProvisionResult, VMProvisioner
14
+ from agentworks.vms.bootstrap_script import generate_bootstrap_script, vm_hostname
15
+ from agentworks.vms.cloud_init import PROVISIONING_PACKAGES
16
+ from agentworks.vms.provisioners.proxmox_api import ProxmoxAPI, ProxmoxAPIError
17
+
18
+ if TYPE_CHECKING:
19
+ from agentworks.config import Config, ProxmoxConfig
20
+ from agentworks.db import VMRow
21
+
22
+
23
+ class ProxmoxProvisioner(VMProvisioner):
24
+ """Provisions VMs on Proxmox VE via clone + cloud-init + guest agent bootstrap."""
25
+
26
+ def __init__(self, proxmox_config: ProxmoxConfig) -> None:
27
+ self._cfg = proxmox_config
28
+ token_secret = os.environ.get("PROXMOX_TOKEN_SECRET", "")
29
+ if not token_secret:
30
+ raise RuntimeError(
31
+ "PROXMOX_TOKEN_SECRET environment variable is required"
32
+ )
33
+ self._api = ProxmoxAPI(
34
+ api_url=proxmox_config.api_url,
35
+ token_id=proxmox_config.token_id,
36
+ token_secret=token_secret,
37
+ verify_ssl=proxmox_config.verify_ssl,
38
+ )
39
+
40
+ def create(
41
+ self,
42
+ vm_name: str,
43
+ config: Config,
44
+ *,
45
+ cpus: int | None = None,
46
+ memory: int | None = None,
47
+ disk: int | None = None,
48
+ admin_username: str = "agentworks",
49
+ tailscale_auth_key: str | None = None,
50
+ ) -> ProvisionResult:
51
+ node = self._cfg.node
52
+ template_vmid = self._cfg.template_vmid
53
+
54
+ output.info(f"Provisioning Proxmox VM '{vm_name}' on node {node}...")
55
+
56
+ # 1. Get next VMID
57
+ newid = self._api.next_id()
58
+ output.detail(f"Allocated VMID: {newid}")
59
+
60
+ # 2. Clone template into the agentworks pool
61
+ output.detail(f"Cloning template {template_vmid}...")
62
+ upid = self._api.clone_vm(
63
+ node, template_vmid, newid, vm_name,
64
+ storage=self._cfg.storage,
65
+ pool=self._cfg.pool,
66
+ )
67
+ self._api.wait_for_task(node, upid)
68
+ output.detail("Clone complete")
69
+
70
+ # 3. Configure VM resources
71
+ vm_config: dict[str, object] = {}
72
+ if cpus is not None:
73
+ vm_config["cores"] = cpus
74
+ if memory is not None:
75
+ vm_config["memory"] = memory * 1024 # GiB -> MiB
76
+
77
+ # Cloud-init: user, SSH key, network
78
+ ssh_pub_key = config.operator.ssh_public_key.read_text().strip()
79
+ vm_config["ciuser"] = admin_username
80
+ vm_config["sshkeys"] = urllib.parse.quote(ssh_pub_key, safe="")
81
+ vm_config["ipconfig0"] = "ip=dhcp"
82
+
83
+ # Boot order, guest agent, and CPU type (host passthrough exposes
84
+ # AVX/AVX2 which tools like Bun require)
85
+ vm_config["boot"] = "order=scsi0"
86
+ vm_config["agent"] = "enabled=1"
87
+ vm_config["cpu"] = "host"
88
+
89
+ output.detail("Configuring VM...")
90
+ self._api.configure_vm(node, newid, **vm_config)
91
+
92
+ # 4. Resize disk if requested
93
+ if disk is not None:
94
+ output.detail(f"Resizing disk to {disk}G...")
95
+ self._api.resize_disk(node, newid, "scsi0", f"{disk}G")
96
+
97
+ # 5. Start VM
98
+ output.detail("Starting VM...")
99
+ upid = self._api.start_vm(node, newid)
100
+ self._api.wait_for_task(node, upid)
101
+
102
+ # 6. Wait for guest agent and get VM IP
103
+ output.detail("Waiting for guest agent...")
104
+ ip = self._wait_for_guest_ip(node, newid)
105
+ output.detail(f"VM IP: {ip}")
106
+
107
+ # 7. Wait for cloud-init to finish (releases apt lock)
108
+ output.detail("Waiting for cloud-init...")
109
+ self._wait_for_cloud_init(node, newid)
110
+
111
+ # 8. Run bootstrap script via guest agent
112
+ bootstrap_complete = False
113
+ tailscale_ip: str | None = None
114
+ if tailscale_auth_key:
115
+ output.detail("Running bootstrap via guest agent...")
116
+ bootstrap = generate_bootstrap_script(
117
+ admin_username=admin_username,
118
+ ssh_public_key=ssh_pub_key,
119
+ provisioning_packages=PROVISIONING_PACKAGES,
120
+ tailscale_auth_key=tailscale_auth_key,
121
+ hostname=vm_hostname("proxmox", vm_name),
122
+ swap=config.vm.swap,
123
+ )
124
+ tailscale_ip = self._run_bootstrap_via_agent(node, newid, bootstrap)
125
+ bootstrap_complete = tailscale_ip is not None
126
+ if tailscale_ip:
127
+ output.detail(f"Tailscale IP: {tailscale_ip}")
128
+
129
+ host = tailscale_ip or ip
130
+ target = ExecTarget(
131
+ ssh=SSHTarget(
132
+ host=host,
133
+ user=admin_username,
134
+ identity_file=config.operator.ssh_private_key,
135
+ )
136
+ )
137
+
138
+ return ProvisionResult(
139
+ admin_exec_target=target,
140
+ proxmox_vmid=str(newid),
141
+ bootstrap_complete=bootstrap_complete,
142
+ tailscale_ip=tailscale_ip,
143
+ )
144
+
145
+ def start(self, vm: VMRow) -> None:
146
+ vmid = self._vmid(vm)
147
+ upid = self._api.start_vm(self._cfg.node, vmid)
148
+ self._api.wait_for_task(self._cfg.node, upid)
149
+
150
+ def stop(self, vm: VMRow) -> None:
151
+ vmid = self._vmid(vm)
152
+ upid = self._api.stop_vm(self._cfg.node, vmid)
153
+ self._api.wait_for_task(self._cfg.node, upid)
154
+
155
+ def delete(self, vm: VMRow) -> None:
156
+ vmid = self._vmid(vm)
157
+ node = self._cfg.node
158
+
159
+ # Stop if running
160
+ try:
161
+ status = self._api.vm_status(node, vmid)
162
+ if status.get("status") == "running":
163
+ upid = self._api.stop_vm(node, vmid)
164
+ self._api.wait_for_task(node, upid)
165
+ except ProxmoxAPIError:
166
+ pass # VM may already be gone
167
+
168
+ # Delete VM
169
+ try:
170
+ upid = self._api.delete_vm(node, vmid)
171
+ self._api.wait_for_task(node, upid)
172
+ except ProxmoxAPIError:
173
+ pass # best-effort
174
+
175
+ def status(self, vm: VMRow) -> VMStatus:
176
+ vmid = self._vmid(vm)
177
+ try:
178
+ result = self._api.vm_status(self._cfg.node, vmid)
179
+ except ProxmoxAPIError:
180
+ return VMStatus.UNKNOWN
181
+ pve_status = result.get("status", "")
182
+ if pve_status == "running":
183
+ return VMStatus.RUNNING
184
+ if pve_status == "stopped":
185
+ return VMStatus.STOPPED
186
+ return VMStatus.UNKNOWN
187
+
188
+ def admin_exec_target(self, vm: VMRow, *, config: object | None = None) -> ExecTarget:
189
+ raise NotImplementedError(
190
+ "Proxmox provisioning transport not yet implemented. "
191
+ "Requires QEMU guest agent exec integration. "
192
+ "See docs/sdd/2026-04-27-exec-target-cleanup/hla.md for details."
193
+ )
194
+
195
+ # -- Helpers ---------------------------------------------------------------
196
+
197
+ def _vmid(self, vm: VMRow) -> int:
198
+ if not vm.proxmox_vmid:
199
+ raise RuntimeError(f"VM '{vm.name}' has no proxmox_vmid")
200
+ return int(vm.proxmox_vmid)
201
+
202
+ def _wait_for_cloud_init(
203
+ self, node: str, vmid: int, *, timeout: int = 300
204
+ ) -> None:
205
+ """Wait for cloud-init to finish inside the VM."""
206
+ deadline = time.monotonic() + timeout
207
+ while time.monotonic() < deadline:
208
+ try:
209
+ result = self._api.guest_agent_exec_wait(
210
+ node, vmid, "/usr/bin/cloud-init", ["status", "--wait"],
211
+ timeout=60,
212
+ )
213
+ if result is not None and result.get("exitcode", -1) == 0:
214
+ return
215
+ except ProxmoxAPIError:
216
+ pass
217
+ time.sleep(5)
218
+ # Don't fail -- cloud-init may not be installed or may have already finished
219
+
220
+ def _wait_for_guest_ip(
221
+ self, node: str, vmid: int, *, timeout: int = 120
222
+ ) -> str:
223
+ """Poll the guest agent until it reports a non-loopback IPv4 address."""
224
+ deadline = time.monotonic() + timeout
225
+ while time.monotonic() < deadline:
226
+ try:
227
+ interfaces = self._api.guest_agent_network(node, vmid)
228
+ for iface in interfaces:
229
+ if iface.get("name") == "lo":
230
+ continue
231
+ for addr in iface.get("ip-addresses", []):
232
+ if addr.get("ip-address-type") == "ipv4":
233
+ ip = addr["ip-address"]
234
+ if not ip.startswith("127."):
235
+ return str(ip)
236
+ except ProxmoxAPIError:
237
+ pass # guest agent not ready yet
238
+ time.sleep(3)
239
+ raise RuntimeError(
240
+ f"Timed out waiting for guest agent IP on VMID {vmid}"
241
+ )
242
+
243
+ def _run_bootstrap_via_agent(
244
+ self, node: str, vmid: int, script: str
245
+ ) -> str | None:
246
+ """Write and run the bootstrap script via the guest agent.
247
+
248
+ Returns the Tailscale IP if bootstrap succeeds, None otherwise.
249
+ """
250
+ from agentworks.vms.bootstrap_script import parse_bootstrap_output
251
+
252
+ # Write script to VM via guest agent file-write
253
+ self._api.guest_agent_file_write(
254
+ node, vmid, "/tmp/agentworks-bootstrap.sh", script
255
+ )
256
+
257
+ # Run bootstrap (long-running -- installs packages, joins tailscale)
258
+ # bash is invoked explicitly so the script doesn't need +x
259
+ result = self._api.guest_agent_exec_wait(
260
+ node, vmid, "/bin/bash", ["/tmp/agentworks-bootstrap.sh"],
261
+ timeout=600,
262
+ )
263
+
264
+ if result is None:
265
+ output.warn("bootstrap timed out")
266
+ return None
267
+
268
+ exit_code = result.get("exitcode", -1)
269
+ stdout = result.get("out-data", "")
270
+ parsed = parse_bootstrap_output(stdout, exit_code)
271
+
272
+ if parsed.ok:
273
+ return parsed.tailscale_ip
274
+
275
+ stderr = result.get("err-data", "")
276
+ if stderr:
277
+ output.warn(f"Bootstrap stderr: {stderr[:500]}")
278
+
279
+ return None