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,261 @@
|
|
|
1
|
+
"""Thin REST client for the Proxmox VE API.
|
|
2
|
+
|
|
3
|
+
Uses stdlib urllib.request -- no external dependencies. Authentication
|
|
4
|
+
is via PVEAPIToken (token ID + secret), not session cookies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import ssl
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ProxmoxAPIError(RuntimeError):
|
|
19
|
+
"""A Proxmox API call failed."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProxmoxAPI:
|
|
23
|
+
"""Minimal Proxmox VE REST client."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
api_url: str,
|
|
28
|
+
token_id: str,
|
|
29
|
+
token_secret: str,
|
|
30
|
+
verify_ssl: bool = True,
|
|
31
|
+
) -> None:
|
|
32
|
+
# Strip trailing slash for consistent URL building
|
|
33
|
+
self._base = api_url.rstrip("/") + "/api2/json"
|
|
34
|
+
self._auth = f"PVEAPIToken={token_id}={token_secret}"
|
|
35
|
+
self._ssl_ctx: ssl.SSLContext | None = None
|
|
36
|
+
if not verify_ssl:
|
|
37
|
+
self._ssl_ctx = ssl.create_default_context()
|
|
38
|
+
self._ssl_ctx.check_hostname = False
|
|
39
|
+
self._ssl_ctx.verify_mode = ssl.CERT_NONE
|
|
40
|
+
|
|
41
|
+
# -- Low-level request -----------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def _request(
|
|
44
|
+
self,
|
|
45
|
+
method: str,
|
|
46
|
+
path: str,
|
|
47
|
+
data: dict[str, Any] | None = None,
|
|
48
|
+
*,
|
|
49
|
+
json_body: bool = False,
|
|
50
|
+
) -> Any:
|
|
51
|
+
"""Send an API request and return the parsed JSON ``data`` field.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
json_body: If True, send data as JSON (required for guest agent
|
|
55
|
+
endpoints). Otherwise use form-urlencoded.
|
|
56
|
+
"""
|
|
57
|
+
url = f"{self._base}{path}"
|
|
58
|
+
|
|
59
|
+
body: bytes | None = None
|
|
60
|
+
content_type = "application/x-www-form-urlencoded"
|
|
61
|
+
if data is not None:
|
|
62
|
+
if json_body:
|
|
63
|
+
body = json.dumps(data).encode()
|
|
64
|
+
content_type = "application/json"
|
|
65
|
+
else:
|
|
66
|
+
body = urllib.parse.urlencode(data).encode()
|
|
67
|
+
|
|
68
|
+
req = urllib.request.Request(url, data=body, method=method)
|
|
69
|
+
req.add_header("Authorization", self._auth)
|
|
70
|
+
if body is not None:
|
|
71
|
+
req.add_header("Content-Type", content_type)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
with urllib.request.urlopen(req, context=self._ssl_ctx) as resp:
|
|
75
|
+
resp_body = resp.read().decode()
|
|
76
|
+
except urllib.error.HTTPError as e:
|
|
77
|
+
err_body = e.read().decode() if e.fp else ""
|
|
78
|
+
raise ProxmoxAPIError(
|
|
79
|
+
f"Proxmox API {method} {path} failed ({e.code}): {err_body}"
|
|
80
|
+
) from e
|
|
81
|
+
|
|
82
|
+
if not resp_body:
|
|
83
|
+
return None
|
|
84
|
+
parsed = json.loads(resp_body)
|
|
85
|
+
return parsed.get("data")
|
|
86
|
+
|
|
87
|
+
# -- Cluster ---------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def next_id(self) -> int:
|
|
90
|
+
"""Get the next available VMID."""
|
|
91
|
+
result = self._request("GET", "/cluster/nextid")
|
|
92
|
+
return int(result)
|
|
93
|
+
|
|
94
|
+
# -- VM operations ---------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def clone_vm(
|
|
97
|
+
self,
|
|
98
|
+
node: str,
|
|
99
|
+
template_vmid: int,
|
|
100
|
+
newid: int,
|
|
101
|
+
name: str,
|
|
102
|
+
*,
|
|
103
|
+
storage: str | None = None,
|
|
104
|
+
pool: str | None = None,
|
|
105
|
+
full: bool = True,
|
|
106
|
+
) -> str:
|
|
107
|
+
"""Clone a VM template. Returns the task UPID."""
|
|
108
|
+
params: dict[str, Any] = {
|
|
109
|
+
"newid": newid,
|
|
110
|
+
"name": name,
|
|
111
|
+
"full": int(full),
|
|
112
|
+
}
|
|
113
|
+
if storage:
|
|
114
|
+
params["storage"] = storage
|
|
115
|
+
if pool:
|
|
116
|
+
params["pool"] = pool
|
|
117
|
+
result = self._request(
|
|
118
|
+
"POST", f"/nodes/{node}/qemu/{template_vmid}/clone", params
|
|
119
|
+
)
|
|
120
|
+
return str(result)
|
|
121
|
+
|
|
122
|
+
def configure_vm(self, node: str, vmid: int, **params: Any) -> None:
|
|
123
|
+
"""Update VM configuration."""
|
|
124
|
+
self._request("PUT", f"/nodes/{node}/qemu/{vmid}/config", params)
|
|
125
|
+
|
|
126
|
+
def resize_disk(
|
|
127
|
+
self, node: str, vmid: int, disk: str, size: str
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Resize a VM disk (e.g. disk='scsi0', size='+20G')."""
|
|
130
|
+
self._request(
|
|
131
|
+
"PUT",
|
|
132
|
+
f"/nodes/{node}/qemu/{vmid}/resize",
|
|
133
|
+
{"disk": disk, "size": size},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def start_vm(self, node: str, vmid: int) -> str:
|
|
137
|
+
"""Start a VM. Returns the task UPID."""
|
|
138
|
+
result = self._request(
|
|
139
|
+
"POST", f"/nodes/{node}/qemu/{vmid}/status/start"
|
|
140
|
+
)
|
|
141
|
+
return str(result)
|
|
142
|
+
|
|
143
|
+
def stop_vm(self, node: str, vmid: int) -> str:
|
|
144
|
+
"""Stop a VM. Returns the task UPID."""
|
|
145
|
+
result = self._request(
|
|
146
|
+
"POST", f"/nodes/{node}/qemu/{vmid}/status/stop"
|
|
147
|
+
)
|
|
148
|
+
return str(result)
|
|
149
|
+
|
|
150
|
+
def delete_vm(self, node: str, vmid: int) -> str:
|
|
151
|
+
"""Delete a VM. Returns the task UPID."""
|
|
152
|
+
result = self._request(
|
|
153
|
+
"DELETE", f"/nodes/{node}/qemu/{vmid}"
|
|
154
|
+
)
|
|
155
|
+
return str(result)
|
|
156
|
+
|
|
157
|
+
def vm_status(self, node: str, vmid: int) -> dict[str, Any]:
|
|
158
|
+
"""Get current VM status."""
|
|
159
|
+
result = self._request(
|
|
160
|
+
"GET", f"/nodes/{node}/qemu/{vmid}/status/current"
|
|
161
|
+
)
|
|
162
|
+
return result # type: ignore[no-any-return]
|
|
163
|
+
|
|
164
|
+
# -- Tasks -----------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def wait_for_task(
|
|
167
|
+
self,
|
|
168
|
+
node: str,
|
|
169
|
+
upid: str,
|
|
170
|
+
*,
|
|
171
|
+
timeout: int = 300,
|
|
172
|
+
poll_interval: float = 2.0,
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Poll a task until it completes or times out."""
|
|
175
|
+
encoded_upid = urllib.parse.quote(upid, safe="")
|
|
176
|
+
deadline = time.monotonic() + timeout
|
|
177
|
+
while time.monotonic() < deadline:
|
|
178
|
+
result = self._request(
|
|
179
|
+
"GET", f"/nodes/{node}/tasks/{encoded_upid}/status"
|
|
180
|
+
)
|
|
181
|
+
if result and result.get("status") == "stopped":
|
|
182
|
+
if result.get("exitstatus") != "OK":
|
|
183
|
+
raise ProxmoxAPIError(
|
|
184
|
+
f"Task failed: {result.get('exitstatus')}"
|
|
185
|
+
)
|
|
186
|
+
return
|
|
187
|
+
time.sleep(poll_interval)
|
|
188
|
+
raise ProxmoxAPIError(f"Task timed out after {timeout}s: {upid}")
|
|
189
|
+
|
|
190
|
+
# -- Guest agent -----------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def guest_agent_network(
|
|
193
|
+
self, node: str, vmid: int
|
|
194
|
+
) -> list[dict[str, Any]]:
|
|
195
|
+
"""Get network interfaces from the QEMU guest agent."""
|
|
196
|
+
result = self._request(
|
|
197
|
+
"GET", f"/nodes/{node}/qemu/{vmid}/agent/network-get-interfaces"
|
|
198
|
+
)
|
|
199
|
+
if result and "result" in result:
|
|
200
|
+
data = result["result"]
|
|
201
|
+
if isinstance(data, list):
|
|
202
|
+
return data
|
|
203
|
+
raise ProxmoxAPIError(f"unexpected network-get-interfaces shape: {type(data).__name__}")
|
|
204
|
+
return []
|
|
205
|
+
|
|
206
|
+
def guest_agent_exec_wait(
|
|
207
|
+
self,
|
|
208
|
+
node: str,
|
|
209
|
+
vmid: int,
|
|
210
|
+
command: str,
|
|
211
|
+
args: list[str] | None = None,
|
|
212
|
+
*,
|
|
213
|
+
timeout: int = 60,
|
|
214
|
+
) -> dict[str, Any] | None:
|
|
215
|
+
"""Run a command via the guest agent and wait for completion.
|
|
216
|
+
|
|
217
|
+
Uses exec then polls exec-status until finished or timeout.
|
|
218
|
+
Returns the result dict with exitcode, out-data, err-data.
|
|
219
|
+
|
|
220
|
+
Proxmox 8 requires the command as a JSON array sent with
|
|
221
|
+
Content-Type: application/json.
|
|
222
|
+
"""
|
|
223
|
+
cmd_array = [command] + (args or [])
|
|
224
|
+
|
|
225
|
+
result = self._request(
|
|
226
|
+
"POST",
|
|
227
|
+
f"/nodes/{node}/qemu/{vmid}/agent/exec",
|
|
228
|
+
{"command": cmd_array},
|
|
229
|
+
json_body=True,
|
|
230
|
+
)
|
|
231
|
+
pid = result.get("pid") if result else None
|
|
232
|
+
if pid is None:
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
# Poll for completion
|
|
236
|
+
deadline = time.monotonic() + timeout
|
|
237
|
+
while time.monotonic() < deadline:
|
|
238
|
+
status = self._request(
|
|
239
|
+
"GET",
|
|
240
|
+
f"/nodes/{node}/qemu/{vmid}/agent/exec-status?pid={pid}",
|
|
241
|
+
)
|
|
242
|
+
if status and status.get("exited"):
|
|
243
|
+
return status # type: ignore[no-any-return]
|
|
244
|
+
time.sleep(2)
|
|
245
|
+
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
def guest_agent_file_write(
|
|
249
|
+
self, node: str, vmid: int, path: str, content: str
|
|
250
|
+
) -> None:
|
|
251
|
+
"""Write a file inside the VM via the guest agent.
|
|
252
|
+
|
|
253
|
+
Sends raw content and lets Proxmox handle base64 encoding
|
|
254
|
+
for the guest agent.
|
|
255
|
+
"""
|
|
256
|
+
self._request(
|
|
257
|
+
"POST",
|
|
258
|
+
f"/nodes/{node}/qemu/{vmid}/agent/file-write",
|
|
259
|
+
{"file": path, "content": content},
|
|
260
|
+
json_body=True,
|
|
261
|
+
)
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""WSL2 provisioner -- imports Debian distros on Windows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import platform
|
|
7
|
+
import subprocess
|
|
8
|
+
import urllib.request
|
|
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, WSL2Target
|
|
15
|
+
from agentworks.vms.base import ProvisionResult, VMProvisioner
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from agentworks.config import Config
|
|
19
|
+
from agentworks.db import VMRow
|
|
20
|
+
|
|
21
|
+
# Default install path for WSL2 distros
|
|
22
|
+
WSL_BASE_PATH = "%LOCALAPPDATA%\\agentworks\\wsl"
|
|
23
|
+
|
|
24
|
+
# Docker Hub OCI registry endpoints for the official Debian image
|
|
25
|
+
_DOCKER_AUTH_URL = "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/debian:pull"
|
|
26
|
+
_DOCKER_MANIFESTS_URL = "https://registry-1.docker.io/v2/library/debian/manifests/bookworm"
|
|
27
|
+
_DOCKER_BLOBS_URL = "https://registry-1.docker.io/v2/library/debian/blobs"
|
|
28
|
+
|
|
29
|
+
# Map Python's platform.machine() to OCI architecture names
|
|
30
|
+
_ARCH_MAP = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _oci_arch() -> str:
|
|
34
|
+
"""Return the OCI architecture name for the host machine."""
|
|
35
|
+
machine = platform.machine().lower()
|
|
36
|
+
arch = _ARCH_MAP.get(machine)
|
|
37
|
+
if arch is None:
|
|
38
|
+
raise RuntimeError(f"Unsupported architecture: {machine}")
|
|
39
|
+
return arch
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _StripAuthRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
43
|
+
"""Strip Authorization header when following redirects to a different host.
|
|
44
|
+
|
|
45
|
+
Docker Hub blob requests return a 302 to a CDN. The CDN rejects the
|
|
46
|
+
Bearer token with 400 Bad Request, so we must drop it on redirect.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def redirect_request(
|
|
50
|
+
self,
|
|
51
|
+
req: urllib.request.Request,
|
|
52
|
+
fp: object,
|
|
53
|
+
code: int,
|
|
54
|
+
msg: str,
|
|
55
|
+
headers: object,
|
|
56
|
+
newurl: str,
|
|
57
|
+
) -> urllib.request.Request | None:
|
|
58
|
+
new_req = super().redirect_request(req, fp, code, msg, headers, newurl) # type: ignore[arg-type]
|
|
59
|
+
if new_req is not None:
|
|
60
|
+
new_req.remove_header("Authorization")
|
|
61
|
+
return new_req
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_blob_opener = urllib.request.build_opener(_StripAuthRedirectHandler)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _wsl(args: list[str], *, check: bool = True, timeout: int = 300) -> str:
|
|
68
|
+
"""Run a wsl.exe command and return stdout."""
|
|
69
|
+
result = subprocess.run(
|
|
70
|
+
["wsl", *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout
|
|
71
|
+
)
|
|
72
|
+
if check and result.returncode != 0:
|
|
73
|
+
raise RuntimeError(f"wsl command failed: {result.stderr.strip()}")
|
|
74
|
+
return result.stdout
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _powershell(script: str, *, check: bool = True, timeout: int = 120) -> str:
|
|
78
|
+
"""Run a PowerShell command and return stdout."""
|
|
79
|
+
result = subprocess.run(
|
|
80
|
+
["powershell", "-NoProfile", "-Command", script],
|
|
81
|
+
capture_output=True,
|
|
82
|
+
text=True,
|
|
83
|
+
encoding="utf-8",
|
|
84
|
+
errors="replace",
|
|
85
|
+
timeout=timeout,
|
|
86
|
+
)
|
|
87
|
+
if check and result.returncode != 0:
|
|
88
|
+
raise RuntimeError(f"PowerShell failed: {result.stderr.strip()}")
|
|
89
|
+
return result.stdout
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _download_debian_rootfs(tarball_path: str) -> None:
|
|
93
|
+
"""Download the official Debian rootfs from Docker Hub OCI registry.
|
|
94
|
+
|
|
95
|
+
Pulls the rootfs layer from the official debian:bookworm image without
|
|
96
|
+
requiring Docker to be installed. The layer is a tar.gz that works
|
|
97
|
+
directly with ``wsl --import``.
|
|
98
|
+
"""
|
|
99
|
+
# 1. Get anonymous pull token
|
|
100
|
+
output.detail("Authenticating with Docker Hub...")
|
|
101
|
+
with urllib.request.urlopen(_DOCKER_AUTH_URL) as resp:
|
|
102
|
+
token = json.loads(resp.read())["token"]
|
|
103
|
+
|
|
104
|
+
# 2. Fetch image manifest to find the rootfs layer digest.
|
|
105
|
+
# debian:bookworm is multi-arch, so we first get the manifest list
|
|
106
|
+
# and resolve the platform-specific manifest for the host architecture.
|
|
107
|
+
output.detail("Fetching Debian bookworm image manifest...")
|
|
108
|
+
auth_header = {"Authorization": f"Bearer {token}"}
|
|
109
|
+
|
|
110
|
+
req = urllib.request.Request(
|
|
111
|
+
_DOCKER_MANIFESTS_URL,
|
|
112
|
+
headers={
|
|
113
|
+
**auth_header,
|
|
114
|
+
"Accept": (
|
|
115
|
+
"application/vnd.docker.distribution.manifest.list.v2+json, "
|
|
116
|
+
"application/vnd.docker.distribution.manifest.v2+json"
|
|
117
|
+
),
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
with urllib.request.urlopen(req) as resp:
|
|
121
|
+
manifest = json.loads(resp.read())
|
|
122
|
+
|
|
123
|
+
# If it's a manifest list, resolve the entry for the host architecture
|
|
124
|
+
if "manifests" in manifest:
|
|
125
|
+
arch = _oci_arch()
|
|
126
|
+
match = next(
|
|
127
|
+
(
|
|
128
|
+
m
|
|
129
|
+
for m in manifest["manifests"]
|
|
130
|
+
if m.get("platform", {}).get("architecture") == arch and m.get("platform", {}).get("os") == "linux"
|
|
131
|
+
),
|
|
132
|
+
None,
|
|
133
|
+
)
|
|
134
|
+
if match is None:
|
|
135
|
+
raise RuntimeError(f"No {arch}/linux manifest found for debian:bookworm")
|
|
136
|
+
platform_digest = match["digest"]
|
|
137
|
+
manifest_url = f"https://registry-1.docker.io/v2/library/debian/manifests/{platform_digest}"
|
|
138
|
+
req = urllib.request.Request(
|
|
139
|
+
manifest_url,
|
|
140
|
+
headers={
|
|
141
|
+
**auth_header,
|
|
142
|
+
"Accept": "application/vnd.docker.distribution.manifest.v2+json",
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
with urllib.request.urlopen(req) as resp:
|
|
146
|
+
manifest = json.loads(resp.read())
|
|
147
|
+
|
|
148
|
+
digest = manifest["layers"][0]["digest"]
|
|
149
|
+
total_bytes = manifest["layers"][0].get("size", 0)
|
|
150
|
+
|
|
151
|
+
# 3. Download the rootfs layer with progress
|
|
152
|
+
blob_url = f"{_DOCKER_BLOBS_URL}/{digest}"
|
|
153
|
+
req = urllib.request.Request(blob_url, headers=auth_header)
|
|
154
|
+
p = output.progress("Downloading Debian rootfs", total=total_bytes or None)
|
|
155
|
+
|
|
156
|
+
dest = Path(tarball_path)
|
|
157
|
+
with _blob_opener.open(req) as resp, dest.open("wb") as f:
|
|
158
|
+
downloaded = 0
|
|
159
|
+
chunk_size = 256 * 1024
|
|
160
|
+
last_update = 0
|
|
161
|
+
while True:
|
|
162
|
+
chunk = resp.read(chunk_size)
|
|
163
|
+
if not chunk:
|
|
164
|
+
break
|
|
165
|
+
f.write(chunk)
|
|
166
|
+
downloaded += len(chunk)
|
|
167
|
+
# Update every ~1MB to avoid flooding
|
|
168
|
+
if downloaded - last_update >= 1024 * 1024:
|
|
169
|
+
p.update(downloaded)
|
|
170
|
+
last_update = downloaded
|
|
171
|
+
|
|
172
|
+
p.done()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class WSL2Provisioner(VMProvisioner):
|
|
176
|
+
"""Provisions WSL2 Debian distributions on Windows."""
|
|
177
|
+
|
|
178
|
+
def create(
|
|
179
|
+
self,
|
|
180
|
+
vm_name: str,
|
|
181
|
+
config: Config,
|
|
182
|
+
*,
|
|
183
|
+
admin_username: str = "agentworks",
|
|
184
|
+
) -> ProvisionResult:
|
|
185
|
+
output.info(f"Provisioning WSL2 VM '{vm_name}'...")
|
|
186
|
+
|
|
187
|
+
install_path = f"{WSL_BASE_PATH}\\{vm_name}"
|
|
188
|
+
_powershell(f"New-Item -ItemType Directory -Force -Path '{install_path}'")
|
|
189
|
+
|
|
190
|
+
# Download Debian rootfs if not cached
|
|
191
|
+
cache_dir = "%LOCALAPPDATA%\\agentworks\\cache"
|
|
192
|
+
tarball = f"{cache_dir}\\debian-bookworm-{_oci_arch()}-rootfs.tar.gz"
|
|
193
|
+
_powershell(f"New-Item -ItemType Directory -Force -Path '{cache_dir}'")
|
|
194
|
+
|
|
195
|
+
check = _powershell(f"Test-Path '{tarball}'").strip()
|
|
196
|
+
if check.lower() != "true":
|
|
197
|
+
_download_debian_rootfs(tarball)
|
|
198
|
+
else:
|
|
199
|
+
output.detail("Using cached Debian rootfs.")
|
|
200
|
+
|
|
201
|
+
# Import and configure the distro
|
|
202
|
+
output.detail("Importing rootfs into WSL2...")
|
|
203
|
+
_wsl(["--import", vm_name, install_path, tarball])
|
|
204
|
+
|
|
205
|
+
# The Docker rootfs is minimal. Install packages to bring it up to
|
|
206
|
+
# parity with the Lima/Azure cloud images.
|
|
207
|
+
output.detail("Installing base packages...")
|
|
208
|
+
_wsl(
|
|
209
|
+
[
|
|
210
|
+
"--distribution",
|
|
211
|
+
vm_name,
|
|
212
|
+
"--user",
|
|
213
|
+
"root",
|
|
214
|
+
"--",
|
|
215
|
+
"bash",
|
|
216
|
+
"-c",
|
|
217
|
+
"DEBIAN_FRONTEND=noninteractive apt-get update -qq"
|
|
218
|
+
" && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq -o Dpkg::Options::=--force-confnew"
|
|
219
|
+
" bash bash-completion sudo passwd"
|
|
220
|
+
" openssh-server curl git ca-certificates"
|
|
221
|
+
" tmux tmuxinator"
|
|
222
|
+
" locales procps iproute2 iputils-ping"
|
|
223
|
+
" less vim-tiny man-db"
|
|
224
|
+
" > /dev/null",
|
|
225
|
+
]
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Configure swap file
|
|
229
|
+
if config.vm.swap > 0:
|
|
230
|
+
swap_mb = config.vm.swap * 1024
|
|
231
|
+
output.detail(f"Setting up {config.vm.swap} GiB swap file...")
|
|
232
|
+
_wsl(
|
|
233
|
+
[
|
|
234
|
+
"--distribution",
|
|
235
|
+
vm_name,
|
|
236
|
+
"--user",
|
|
237
|
+
"root",
|
|
238
|
+
"--",
|
|
239
|
+
"bash",
|
|
240
|
+
"-c",
|
|
241
|
+
f"fallocate -l {swap_mb}M /swapfile"
|
|
242
|
+
" && chmod 600 /swapfile"
|
|
243
|
+
" && mkswap /swapfile"
|
|
244
|
+
" && swapon /swapfile"
|
|
245
|
+
" && echo '/swapfile none swap sw 0 0' >> /etc/fstab",
|
|
246
|
+
]
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Create user account
|
|
250
|
+
output.detail(f"Creating user '{admin_username}'...")
|
|
251
|
+
_wsl(["--distribution", vm_name, "--user", "root", "--", "useradd", "-m", "-s", "/bin/bash", admin_username])
|
|
252
|
+
_wsl(["--distribution", vm_name, "--user", "root", "--", "usermod", "-aG", "sudo", admin_username])
|
|
253
|
+
import shlex
|
|
254
|
+
|
|
255
|
+
_wsl(
|
|
256
|
+
[
|
|
257
|
+
"--distribution",
|
|
258
|
+
vm_name,
|
|
259
|
+
"--user",
|
|
260
|
+
"root",
|
|
261
|
+
"--",
|
|
262
|
+
"bash",
|
|
263
|
+
"-c",
|
|
264
|
+
f"echo {shlex.quote(f'{admin_username} ALL=(ALL) NOPASSWD:ALL')}"
|
|
265
|
+
f" > /etc/sudoers.d/{shlex.quote(admin_username)}",
|
|
266
|
+
]
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Configure wsl.conf: default user + systemd
|
|
270
|
+
output.detail("Enabling systemd...")
|
|
271
|
+
_wsl(
|
|
272
|
+
[
|
|
273
|
+
"--distribution",
|
|
274
|
+
vm_name,
|
|
275
|
+
"--user",
|
|
276
|
+
"root",
|
|
277
|
+
"--",
|
|
278
|
+
"bash",
|
|
279
|
+
"-c",
|
|
280
|
+
f"printf '[user]\\ndefault={shlex.quote(admin_username)}"
|
|
281
|
+
f"\\n\\n[boot]\\nsystemd=true\\n' > /etc/wsl.conf",
|
|
282
|
+
]
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# Restart the distro so systemd takes effect
|
|
286
|
+
output.detail("Restarting distro...")
|
|
287
|
+
_wsl(["--terminate", vm_name])
|
|
288
|
+
# Run a command to trigger the distro to start with systemd
|
|
289
|
+
_wsl(["--distribution", vm_name, "--user", "root", "--", "bash", "-c", "echo ok"])
|
|
290
|
+
|
|
291
|
+
output.detail(f"WSL2 VM '{vm_name}' provisioned.")
|
|
292
|
+
return ProvisionResult(
|
|
293
|
+
admin_exec_target=ExecTarget(wsl2=WSL2Target(distro_name=vm_name, user=admin_username)),
|
|
294
|
+
wsl_distro_name=vm_name,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def start(self, vm: VMRow) -> None:
|
|
298
|
+
output.info(f"Starting WSL2 distro '{vm.name}'...")
|
|
299
|
+
_wsl(["--distribution", vm.name, "--", "echo", "started"])
|
|
300
|
+
output.info(f"WSL2 distro '{vm.name}' started")
|
|
301
|
+
|
|
302
|
+
def stop(self, vm: VMRow) -> None:
|
|
303
|
+
output.info(f"Terminating WSL2 distro '{vm.name}'...")
|
|
304
|
+
_wsl(["--terminate", vm.name])
|
|
305
|
+
output.info(f"WSL2 distro '{vm.name}' terminated")
|
|
306
|
+
|
|
307
|
+
def delete(self, vm: VMRow) -> None:
|
|
308
|
+
output.info(f"Unregistering WSL2 distro '{vm.name}'...")
|
|
309
|
+
_wsl(["--unregister", vm.name], check=False)
|
|
310
|
+
# Clean up install directory
|
|
311
|
+
install_path = f"{WSL_BASE_PATH}\\{vm.name}"
|
|
312
|
+
_powershell(
|
|
313
|
+
f"Remove-Item -Recurse -Force -Path '{install_path}' -ErrorAction SilentlyContinue",
|
|
314
|
+
check=False,
|
|
315
|
+
)
|
|
316
|
+
output.info(f"WSL2 distro '{vm.name}' deleted")
|
|
317
|
+
|
|
318
|
+
def admin_exec_target(self, vm: VMRow, *, config: object | None = None) -> ExecTarget:
|
|
319
|
+
return ExecTarget(wsl2=WSL2Target(distro_name=vm.name, user=vm.admin_username))
|
|
320
|
+
|
|
321
|
+
def status(self, vm: VMRow) -> VMStatus:
|
|
322
|
+
try:
|
|
323
|
+
output = _wsl(["--list", "--verbose"], check=False)
|
|
324
|
+
except RuntimeError:
|
|
325
|
+
return VMStatus.UNKNOWN
|
|
326
|
+
|
|
327
|
+
for line in output.strip().splitlines():
|
|
328
|
+
parts = line.split()
|
|
329
|
+
# WSL --list --verbose output: [*] NAME STATE VERSION
|
|
330
|
+
# Filter to find our distro
|
|
331
|
+
name_candidates = [p for p in parts if p == vm.name]
|
|
332
|
+
if not name_candidates:
|
|
333
|
+
continue
|
|
334
|
+
state_str = parts[-2].lower() if len(parts) >= 3 else ""
|
|
335
|
+
if state_str == "running":
|
|
336
|
+
return VMStatus.RUNNING
|
|
337
|
+
if state_str == "stopped":
|
|
338
|
+
return VMStatus.STOPPED
|
|
339
|
+
return VMStatus.UNKNOWN
|
|
340
|
+
return VMStatus.UNKNOWN
|