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
|
@@ -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
|