portacode 1.3.32__py3-none-any.whl → 1.4.11.dev5__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.
Potentially problematic release.
This version of portacode might be problematic. Click here for more details.
- portacode/_version.py +2 -2
- portacode/cli.py +158 -14
- portacode/connection/client.py +127 -8
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +370 -4
- portacode/connection/handlers/__init__.py +16 -1
- portacode/connection/handlers/diff_handlers.py +603 -0
- portacode/connection/handlers/file_handlers.py +674 -17
- portacode/connection/handlers/project_aware_file_handlers.py +11 -0
- portacode/connection/handlers/project_state/file_system_watcher.py +31 -61
- portacode/connection/handlers/project_state/git_manager.py +139 -572
- portacode/connection/handlers/project_state/handlers.py +28 -14
- portacode/connection/handlers/project_state/manager.py +226 -101
- portacode/connection/handlers/proxmox_infra.py +790 -0
- portacode/connection/handlers/session.py +465 -84
- portacode/connection/handlers/system_handlers.py +181 -8
- portacode/connection/handlers/tab_factory.py +1 -47
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/terminal.py +55 -10
- portacode/keypair.py +63 -1
- portacode/link_capture/__init__.py +38 -0
- portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
- portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
- portacode/link_capture/bin/elinks +3 -0
- portacode/link_capture/bin/gio-open +3 -0
- portacode/link_capture/bin/gnome-open +3 -0
- portacode/link_capture/bin/gvfs-open +3 -0
- portacode/link_capture/bin/kde-open +3 -0
- portacode/link_capture/bin/kfmclient +3 -0
- portacode/link_capture/bin/link_capture_exec.sh +11 -0
- portacode/link_capture/bin/link_capture_wrapper.py +75 -0
- portacode/link_capture/bin/links +3 -0
- portacode/link_capture/bin/links2 +3 -0
- portacode/link_capture/bin/lynx +3 -0
- portacode/link_capture/bin/mate-open +3 -0
- portacode/link_capture/bin/netsurf +3 -0
- portacode/link_capture/bin/sensible-browser +3 -0
- portacode/link_capture/bin/w3m +3 -0
- portacode/link_capture/bin/x-www-browser +3 -0
- portacode/link_capture/bin/xdg-open +3 -0
- portacode/pairing.py +103 -0
- portacode/static/js/utils/ntp-clock.js +170 -79
- portacode/utils/diff_apply.py +456 -0
- portacode/utils/diff_renderer.py +371 -0
- portacode/utils/ntp_clock.py +45 -131
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/METADATA +71 -3
- portacode-1.4.11.dev5.dist-info/RECORD +97 -0
- test_modules/test_device_online.py +1 -1
- test_modules/test_login_flow.py +8 -4
- test_modules/test_play_store_screenshots.py +294 -0
- testing_framework/.env.example +4 -1
- testing_framework/core/playwright_manager.py +63 -9
- portacode-1.3.32.dist-info/RECORD +0 -70
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/WHEEL +0 -0
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/entry_points.txt +0 -0
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
"""Proxmox infrastructure configuration handler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import stat
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
import platformdirs
|
|
18
|
+
|
|
19
|
+
from .base import SyncHandler
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
CONFIG_DIR = Path(platformdirs.user_config_dir("portacode"))
|
|
24
|
+
CONFIG_PATH = CONFIG_DIR / "proxmox_infra.json"
|
|
25
|
+
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
26
|
+
NET_SETUP_SCRIPT = REPO_ROOT / "proxmox_management" / "net_setup.py"
|
|
27
|
+
CONTAINERS_DIR = CONFIG_DIR / "containers"
|
|
28
|
+
MANAGED_MARKER = "portacode-managed:true"
|
|
29
|
+
|
|
30
|
+
DEFAULT_HOST = "localhost"
|
|
31
|
+
DEFAULT_NODE_NAME = os.uname().nodename.split(".", 1)[0]
|
|
32
|
+
DEFAULT_BRIDGE = "vmbr1"
|
|
33
|
+
SUBNET_CIDR = "10.10.0.1/24"
|
|
34
|
+
BRIDGE_IP = SUBNET_CIDR.split("/", 1)[0]
|
|
35
|
+
DHCP_START = "10.10.0.100"
|
|
36
|
+
DHCP_END = "10.10.0.200"
|
|
37
|
+
DNS_SERVER = "1.1.1.1"
|
|
38
|
+
IFACES_PATH = Path("/etc/network/interfaces")
|
|
39
|
+
SYSCTL_PATH = Path("/etc/sysctl.d/99-portacode-forward.conf")
|
|
40
|
+
UNIT_DIR = Path("/etc/systemd/system")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _call_subprocess(cmd: List[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
|
44
|
+
env = os.environ.copy()
|
|
45
|
+
env.setdefault("DEBIAN_FRONTEND", "noninteractive")
|
|
46
|
+
return subprocess.run(cmd, env=env, text=True, capture_output=True, **kwargs)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _ensure_proxmoxer() -> Any:
|
|
50
|
+
try:
|
|
51
|
+
from proxmoxer import ProxmoxAPI # noqa: F401
|
|
52
|
+
except ModuleNotFoundError as exc:
|
|
53
|
+
python = sys.executable
|
|
54
|
+
logger.info("Proxmoxer missing; installing via pip")
|
|
55
|
+
try:
|
|
56
|
+
_call_subprocess([python, "-m", "pip", "install", "proxmoxer"], check=True)
|
|
57
|
+
except subprocess.CalledProcessError as pip_exc:
|
|
58
|
+
msg = pip_exc.stderr or pip_exc.stdout or str(pip_exc)
|
|
59
|
+
raise RuntimeError(f"Failed to install proxmoxer: {msg}") from pip_exc
|
|
60
|
+
from proxmoxer import ProxmoxAPI # noqa: F401
|
|
61
|
+
from proxmoxer import ProxmoxAPI
|
|
62
|
+
return ProxmoxAPI
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse_token(token_identifier: str) -> Tuple[str, str]:
|
|
66
|
+
identifier = token_identifier.strip()
|
|
67
|
+
if "!" not in identifier or "@" not in identifier:
|
|
68
|
+
raise ValueError("Expected API token in the form user@realm!tokenid")
|
|
69
|
+
user_part, token_name = identifier.split("!", 1)
|
|
70
|
+
user = user_part.strip()
|
|
71
|
+
token_name = token_name.strip()
|
|
72
|
+
if "@" not in user:
|
|
73
|
+
raise ValueError("API token missing user realm (user@realm)")
|
|
74
|
+
if not token_name:
|
|
75
|
+
raise ValueError("Token identifier missing token name")
|
|
76
|
+
return user, token_name
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _save_config(data: Dict[str, Any]) -> None:
|
|
80
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
tmp_path = CONFIG_PATH.with_suffix(".tmp")
|
|
82
|
+
tmp_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
83
|
+
os.replace(tmp_path, CONFIG_PATH)
|
|
84
|
+
os.chmod(CONFIG_PATH, stat.S_IRUSR | stat.S_IWUSR)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _load_config() -> Dict[str, Any]:
|
|
88
|
+
if not CONFIG_PATH.exists():
|
|
89
|
+
return {}
|
|
90
|
+
try:
|
|
91
|
+
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
92
|
+
except json.JSONDecodeError as exc:
|
|
93
|
+
logger.warning("Failed to parse Proxmox infra config: %s", exc)
|
|
94
|
+
return {}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _pick_node(client: Any) -> str:
|
|
98
|
+
nodes = client.nodes().get()
|
|
99
|
+
for node in nodes:
|
|
100
|
+
if node.get("node") == DEFAULT_NODE_NAME:
|
|
101
|
+
return DEFAULT_NODE_NAME
|
|
102
|
+
return nodes[0].get("node") if nodes else DEFAULT_NODE_NAME
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _list_templates(client: Any, node: str, storages: Iterable[Dict[str, Any]]) -> List[str]:
|
|
106
|
+
templates: List[str] = []
|
|
107
|
+
for storage in storages:
|
|
108
|
+
storage_name = storage.get("storage")
|
|
109
|
+
if not storage_name:
|
|
110
|
+
continue
|
|
111
|
+
try:
|
|
112
|
+
items = client.nodes(node).storage(storage_name).content.get()
|
|
113
|
+
except Exception:
|
|
114
|
+
continue
|
|
115
|
+
for item in items:
|
|
116
|
+
if item.get("content") == "vztmpl" and item.get("volid"):
|
|
117
|
+
templates.append(item["volid"])
|
|
118
|
+
return templates
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _pick_storage(storages: Iterable[Dict[str, Any]]) -> str:
|
|
122
|
+
candidates = [s for s in storages if "rootdir" in s.get("content", "") and s.get("avail", 0) > 0]
|
|
123
|
+
if not candidates:
|
|
124
|
+
candidates = [s for s in storages if "rootdir" in s.get("content", "")]
|
|
125
|
+
if not candidates:
|
|
126
|
+
return ""
|
|
127
|
+
candidates.sort(key=lambda entry: entry.get("avail", 0), reverse=True)
|
|
128
|
+
return candidates[0].get("storage", "")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _write_bridge_config(bridge: str) -> None:
|
|
132
|
+
begin = f"# Portacode INFRA BEGIN {bridge}"
|
|
133
|
+
end = f"# Portacode INFRA END {bridge}"
|
|
134
|
+
current = IFACES_PATH.read_text(encoding="utf-8") if IFACES_PATH.exists() else ""
|
|
135
|
+
if begin in current:
|
|
136
|
+
return
|
|
137
|
+
block = f"""
|
|
138
|
+
{begin}
|
|
139
|
+
auto {bridge}
|
|
140
|
+
iface {bridge} inet static
|
|
141
|
+
address {SUBNET_CIDR}
|
|
142
|
+
bridge-ports none
|
|
143
|
+
bridge-stp off
|
|
144
|
+
bridge-fd 0
|
|
145
|
+
{end}
|
|
146
|
+
|
|
147
|
+
"""
|
|
148
|
+
mode = "a" if IFACES_PATH.exists() else "w"
|
|
149
|
+
with open(IFACES_PATH, mode, encoding="utf-8") as fh:
|
|
150
|
+
if current and not current.endswith("\n"):
|
|
151
|
+
fh.write("\n")
|
|
152
|
+
fh.write(block)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _ensure_sysctl() -> None:
|
|
156
|
+
SYSCTL_PATH.write_text("net.ipv4.ip_forward=1\n", encoding="utf-8")
|
|
157
|
+
_call_subprocess(["/sbin/sysctl", "-w", "net.ipv4.ip_forward=1"], check=True)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _write_units(bridge: str) -> None:
|
|
161
|
+
nat_name = f"portacode-{bridge}-nat.service"
|
|
162
|
+
dns_name = f"portacode-{bridge}-dnsmasq.service"
|
|
163
|
+
nat = UNIT_DIR / nat_name
|
|
164
|
+
dns = UNIT_DIR / dns_name
|
|
165
|
+
nat.write_text(f"""[Unit]
|
|
166
|
+
Description=Portacode NAT for {bridge}
|
|
167
|
+
After=network-online.target
|
|
168
|
+
Wants=network-online.target
|
|
169
|
+
|
|
170
|
+
[Service]
|
|
171
|
+
Type=oneshot
|
|
172
|
+
RemainAfterExit=yes
|
|
173
|
+
ExecStart=/usr/sbin/iptables -t nat -A POSTROUTING -s {BRIDGE_IP}/24 -o vmbr0 -j MASQUERADE
|
|
174
|
+
ExecStart=/usr/sbin/iptables -A FORWARD -i {bridge} -o vmbr0 -j ACCEPT
|
|
175
|
+
ExecStart=/usr/sbin/iptables -A FORWARD -i vmbr0 -o {bridge} -m state --state RELATED,ESTABLISHED -j ACCEPT
|
|
176
|
+
ExecStop=/usr/sbin/iptables -t nat -D POSTROUTING -s {BRIDGE_IP}/24 -o vmbr0 -j MASQUERADE
|
|
177
|
+
ExecStop=/usr/sbin/iptables -D FORWARD -i {bridge} -o vmbr0 -j ACCEPT
|
|
178
|
+
ExecStop=/usr/sbin/iptables -D FORWARD -i vmbr0 -o {bridge} -m state --state RELATED,ESTABLISHED -j ACCEPT
|
|
179
|
+
|
|
180
|
+
[Install]
|
|
181
|
+
WantedBy=multi-user.target
|
|
182
|
+
""", encoding="utf-8")
|
|
183
|
+
dns.write_text(f"""[Unit]
|
|
184
|
+
Description=Portacode dnsmasq for {bridge}
|
|
185
|
+
After=network-online.target
|
|
186
|
+
Wants=network-online.target
|
|
187
|
+
|
|
188
|
+
[Service]
|
|
189
|
+
Type=simple
|
|
190
|
+
ExecStart=/usr/sbin/dnsmasq --keep-in-foreground --interface={bridge} --bind-interfaces --listen-address={BRIDGE_IP} \
|
|
191
|
+
--port=0 --dhcp-range={DHCP_START},{DHCP_END},12h \
|
|
192
|
+
--dhcp-option=option:router,{BRIDGE_IP} \
|
|
193
|
+
--dhcp-option=option:dns-server,{DNS_SERVER} \
|
|
194
|
+
--conf-file=/dev/null --pid-file=/run/portacode_dnsmasq.pid --dhcp-leasefile=/var/lib/misc/portacode_dnsmasq.leases
|
|
195
|
+
Restart=always
|
|
196
|
+
|
|
197
|
+
[Install]
|
|
198
|
+
WantedBy=multi-user.target
|
|
199
|
+
""", encoding="utf-8")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _ensure_bridge(bridge: str = DEFAULT_BRIDGE) -> Dict[str, Any]:
|
|
203
|
+
if os.geteuid() != 0:
|
|
204
|
+
raise PermissionError("Bridge setup requires root privileges")
|
|
205
|
+
if not shutil.which("dnsmasq"):
|
|
206
|
+
apt = shutil.which("apt-get")
|
|
207
|
+
if not apt:
|
|
208
|
+
raise RuntimeError("dnsmasq is missing and apt-get unavailable to install it")
|
|
209
|
+
_call_subprocess([apt, "update"], check=True)
|
|
210
|
+
_call_subprocess([apt, "install", "-y", "dnsmasq"], check=True)
|
|
211
|
+
_write_bridge_config(bridge)
|
|
212
|
+
_ensure_sysctl()
|
|
213
|
+
_write_units(bridge)
|
|
214
|
+
_call_subprocess(["/bin/systemctl", "daemon-reload"], check=True)
|
|
215
|
+
nat_service = f"portacode-{bridge}-nat.service"
|
|
216
|
+
dns_service = f"portacode-{bridge}-dnsmasq.service"
|
|
217
|
+
_call_subprocess(["/bin/systemctl", "enable", "--now", nat_service, dns_service], check=True)
|
|
218
|
+
_call_subprocess(["/sbin/ifup", bridge], check=False)
|
|
219
|
+
return {"applied": True, "bridge": bridge, "message": f"Bridge {bridge} configured"}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _verify_connectivity(timeout: float = 5.0) -> bool:
|
|
223
|
+
try:
|
|
224
|
+
_call_subprocess(["/bin/ping", "-c", "2", "1.1.1.1"], check=True, timeout=timeout)
|
|
225
|
+
return True
|
|
226
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _revert_bridge() -> None:
|
|
231
|
+
try:
|
|
232
|
+
if NET_SETUP_SCRIPT.exists():
|
|
233
|
+
_call_subprocess([sys.executable, str(NET_SETUP_SCRIPT), "revert"], check=True)
|
|
234
|
+
except Exception as exc:
|
|
235
|
+
logger.warning("Proxmox bridge revert failed: %s", exc)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _ensure_containers_dir() -> None:
|
|
239
|
+
CONTAINERS_DIR.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _format_rootfs(storage: str, disk_gib: int, storage_type: str) -> str:
|
|
243
|
+
if storage_type in ("lvm", "lvmthin"):
|
|
244
|
+
return f"{storage}:{disk_gib}"
|
|
245
|
+
return f"{storage}:{disk_gib}G"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _get_storage_type(storages: Iterable[Dict[str, Any]], storage_name: str) -> str:
|
|
249
|
+
for entry in storages:
|
|
250
|
+
if entry.get("storage") == storage_name:
|
|
251
|
+
return entry.get("type", "")
|
|
252
|
+
return ""
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _validate_positive_int(value: Any, default: int) -> int:
|
|
256
|
+
try:
|
|
257
|
+
candidate = int(value)
|
|
258
|
+
if candidate > 0:
|
|
259
|
+
return candidate
|
|
260
|
+
except Exception:
|
|
261
|
+
pass
|
|
262
|
+
return default
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _wait_for_task(proxmox: Any, node: str, upid: str) -> Tuple[Dict[str, Any], float]:
|
|
266
|
+
start = time.time()
|
|
267
|
+
while True:
|
|
268
|
+
status = proxmox.nodes(node).tasks(upid).status.get()
|
|
269
|
+
if status.get("status") == "stopped":
|
|
270
|
+
return status, time.time() - start
|
|
271
|
+
time.sleep(1)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _list_running_managed(proxmox: Any, node: str) -> List[Tuple[str, Dict[str, Any]]]:
|
|
275
|
+
entries = []
|
|
276
|
+
for ct in proxmox.nodes(node).lxc.get():
|
|
277
|
+
if ct.get("status") != "running":
|
|
278
|
+
continue
|
|
279
|
+
vmid = str(ct.get("vmid"))
|
|
280
|
+
cfg = proxmox.nodes(node).lxc(vmid).config.get()
|
|
281
|
+
if cfg and MANAGED_MARKER in (cfg.get("description") or ""):
|
|
282
|
+
entries.append((vmid, cfg))
|
|
283
|
+
return entries
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _start_container(proxmox: Any, node: str, vmid: int) -> Tuple[Dict[str, Any], float]:
|
|
287
|
+
status = proxmox.nodes(node).lxc(vmid).status.current.get()
|
|
288
|
+
if status.get("status") == "running":
|
|
289
|
+
uptime = status.get("uptime", 0)
|
|
290
|
+
logger.info("Container %s already running (%ss)", vmid, uptime)
|
|
291
|
+
return status, 0.0
|
|
292
|
+
|
|
293
|
+
node_status = proxmox.nodes(node).status.get()
|
|
294
|
+
mem_total_mb = int(node_status.get("memory", {}).get("total", 0) // (1024**2))
|
|
295
|
+
cores_total = int(node_status.get("cpuinfo", {}).get("cores", 0))
|
|
296
|
+
|
|
297
|
+
running = _list_running_managed(proxmox, node)
|
|
298
|
+
used_mem_mb = sum(int(cfg.get("memory", 0)) for _, cfg in running)
|
|
299
|
+
used_cores = sum(int(cfg.get("cores", 0)) for _, cfg in running)
|
|
300
|
+
|
|
301
|
+
target_cfg = proxmox.nodes(node).lxc(vmid).config.get()
|
|
302
|
+
target_mem_mb = int(target_cfg.get("memory", 0))
|
|
303
|
+
target_cores = int(target_cfg.get("cores", 0))
|
|
304
|
+
|
|
305
|
+
if mem_total_mb and used_mem_mb + target_mem_mb > mem_total_mb:
|
|
306
|
+
raise RuntimeError("Not enough RAM to start this container safely.")
|
|
307
|
+
if cores_total and used_cores + target_cores > cores_total:
|
|
308
|
+
raise RuntimeError("Not enough CPU cores to start this container safely.")
|
|
309
|
+
|
|
310
|
+
upid = proxmox.nodes(node).lxc(vmid).status.start.post()
|
|
311
|
+
return _wait_for_task(proxmox, node, upid)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _write_container_record(vmid: int, payload: Dict[str, Any]) -> None:
|
|
315
|
+
_ensure_containers_dir()
|
|
316
|
+
path = CONTAINERS_DIR / f"ct-{vmid}.json"
|
|
317
|
+
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _build_container_payload(message: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
|
|
321
|
+
templates = config.get("templates") or []
|
|
322
|
+
default_template = templates[0] if templates else ""
|
|
323
|
+
template = message.get("template") or default_template
|
|
324
|
+
if not template:
|
|
325
|
+
raise ValueError("Container template is required.")
|
|
326
|
+
|
|
327
|
+
bridge = config.get("network", {}).get("bridge", DEFAULT_BRIDGE)
|
|
328
|
+
hostname = (message.get("hostname") or "").strip()
|
|
329
|
+
disk_gib = _validate_positive_int(message.get("disk_gib") or message.get("disk"), 32)
|
|
330
|
+
ram_mib = _validate_positive_int(message.get("ram_mib") or message.get("ram"), 2048)
|
|
331
|
+
cpus = _validate_positive_int(message.get("cpus"), 1)
|
|
332
|
+
storage = message.get("storage") or config.get("default_storage") or ""
|
|
333
|
+
if not storage:
|
|
334
|
+
raise ValueError("Storage pool could not be determined.")
|
|
335
|
+
|
|
336
|
+
user = (message.get("username") or "svcuser").strip() or "svcuser"
|
|
337
|
+
password = message.get("password") or ""
|
|
338
|
+
ssh_key = (message.get("ssh_key") or "").strip()
|
|
339
|
+
|
|
340
|
+
payload = {
|
|
341
|
+
"template": template,
|
|
342
|
+
"storage": storage,
|
|
343
|
+
"disk_gib": disk_gib,
|
|
344
|
+
"ram_mib": ram_mib,
|
|
345
|
+
"cpus": cpus,
|
|
346
|
+
"hostname": hostname,
|
|
347
|
+
"net0": f"name=eth0,bridge={bridge},ip=dhcp",
|
|
348
|
+
"unprivileged": 1,
|
|
349
|
+
"swap_mb": 0,
|
|
350
|
+
"username": user,
|
|
351
|
+
"password": password,
|
|
352
|
+
"ssh_public_key": ssh_key,
|
|
353
|
+
"description": MANAGED_MARKER,
|
|
354
|
+
}
|
|
355
|
+
return payload
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _connect_proxmox(config: Dict[str, Any]) -> Any:
|
|
359
|
+
ProxmoxAPI = _ensure_proxmoxer()
|
|
360
|
+
return ProxmoxAPI(
|
|
361
|
+
config.get("host", DEFAULT_HOST),
|
|
362
|
+
user=config.get("user"),
|
|
363
|
+
token_name=config.get("token_name"),
|
|
364
|
+
token_value=config.get("token_value"),
|
|
365
|
+
verify_ssl=config.get("verify_ssl", False),
|
|
366
|
+
timeout=60,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _run_pct(vmid: int, cmd: str) -> Dict[str, Any]:
|
|
371
|
+
full = ["pct", "exec", str(vmid), "--", "bash", "-lc", cmd]
|
|
372
|
+
start = time.time()
|
|
373
|
+
proc = subprocess.run(full, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
374
|
+
return {
|
|
375
|
+
"cmd": cmd,
|
|
376
|
+
"returncode": proc.returncode,
|
|
377
|
+
"stdout": proc.stdout.strip(),
|
|
378
|
+
"stderr": proc.stderr.strip(),
|
|
379
|
+
"elapsed_s": round(time.time() - start, 2),
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _run_pct_check(vmid: int, cmd: str) -> Dict[str, Any]:
|
|
384
|
+
res = _run_pct(vmid, cmd)
|
|
385
|
+
if res["returncode"] != 0:
|
|
386
|
+
raise RuntimeError(res.get("stderr") or res.get("stdout") or "command failed")
|
|
387
|
+
return res
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -> Dict[str, Any]:
|
|
391
|
+
cmd = ["pct", "exec", str(vmid), "--", "bash", "-lc", f"su - {user} -c 'portacode connect'"]
|
|
392
|
+
proc = subprocess.Popen(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
393
|
+
start = time.time()
|
|
394
|
+
|
|
395
|
+
data_dir_cmd = f"su - {user} -c 'echo -n ${{XDG_DATA_HOME:-$HOME/.local/share}}'"
|
|
396
|
+
data_dir = _run_pct_check(vmid, data_dir_cmd)["stdout"].strip()
|
|
397
|
+
key_dir = f"{data_dir}/portacode/keys"
|
|
398
|
+
pub_path = f"{key_dir}/id_portacode.pub"
|
|
399
|
+
priv_path = f"{key_dir}/id_portacode"
|
|
400
|
+
|
|
401
|
+
def file_size(path: str) -> Optional[int]:
|
|
402
|
+
stat_cmd = f"su - {user} -c 'test -s {path} && stat -c %s {path}'"
|
|
403
|
+
res = _run_pct(vmid, stat_cmd)
|
|
404
|
+
if res["returncode"] != 0:
|
|
405
|
+
return None
|
|
406
|
+
try:
|
|
407
|
+
return int(res["stdout"].strip())
|
|
408
|
+
except ValueError:
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
last_pub = last_priv = None
|
|
412
|
+
stable = 0
|
|
413
|
+
while time.time() - start < timeout_s:
|
|
414
|
+
if proc.poll() is not None:
|
|
415
|
+
out, err = proc.communicate(timeout=1)
|
|
416
|
+
return {
|
|
417
|
+
"ok": False,
|
|
418
|
+
"error": "portacode connect exited before keys were created",
|
|
419
|
+
"stdout": (out or "").strip(),
|
|
420
|
+
"stderr": (err or "").strip(),
|
|
421
|
+
}
|
|
422
|
+
pub_size = file_size(pub_path)
|
|
423
|
+
priv_size = file_size(priv_path)
|
|
424
|
+
if pub_size and priv_size:
|
|
425
|
+
if pub_size == last_pub and priv_size == last_priv:
|
|
426
|
+
stable += 1
|
|
427
|
+
else:
|
|
428
|
+
stable = 0
|
|
429
|
+
last_pub, last_priv = pub_size, priv_size
|
|
430
|
+
if stable >= 1:
|
|
431
|
+
break
|
|
432
|
+
time.sleep(1)
|
|
433
|
+
|
|
434
|
+
if stable < 1:
|
|
435
|
+
proc.terminate()
|
|
436
|
+
try:
|
|
437
|
+
proc.wait(timeout=3)
|
|
438
|
+
except subprocess.TimeoutExpired:
|
|
439
|
+
proc.kill()
|
|
440
|
+
out, err = proc.communicate(timeout=1)
|
|
441
|
+
return {
|
|
442
|
+
"ok": False,
|
|
443
|
+
"error": "timed out waiting for portacode key files",
|
|
444
|
+
"stdout": (out or "").strip(),
|
|
445
|
+
"stderr": (err or "").strip(),
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
proc.terminate()
|
|
449
|
+
try:
|
|
450
|
+
proc.wait(timeout=3)
|
|
451
|
+
except subprocess.TimeoutExpired:
|
|
452
|
+
proc.kill()
|
|
453
|
+
|
|
454
|
+
key_res = _run_pct(vmid, f"su - {user} -c 'cat {pub_path}'")
|
|
455
|
+
return {
|
|
456
|
+
"ok": True,
|
|
457
|
+
"public_key": key_res["stdout"].strip(),
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _summarize_error(res: Dict[str, Any]) -> str:
|
|
462
|
+
text = f"{res.get('stdout','')}\n{res.get('stderr','')}"
|
|
463
|
+
if "No space left on device" in text:
|
|
464
|
+
return "Disk full inside container; increase rootfs or clean apt cache."
|
|
465
|
+
if "Unable to acquire the dpkg frontend lock" in text or "lock-frontend" in text:
|
|
466
|
+
return "Another apt/dpkg process is running; retry after it finishes."
|
|
467
|
+
if "Temporary failure resolving" in text or "Could not resolve" in text:
|
|
468
|
+
return "DNS/network resolution failed inside container."
|
|
469
|
+
if "Failed to fetch" in text:
|
|
470
|
+
return "Package repo fetch failed; check network and apt sources."
|
|
471
|
+
return "Command failed; see stdout/stderr for details."
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _run_setup_steps(vmid: int, steps: List[Dict[str, Any]], user: str) -> Tuple[List[Dict[str, Any]], bool]:
|
|
475
|
+
results: List[Dict[str, Any]] = []
|
|
476
|
+
for step in steps:
|
|
477
|
+
if step.get("type") == "portacode_connect":
|
|
478
|
+
res = _portacode_connect_and_read_key(vmid, user, timeout_s=step.get("timeout_s", 10))
|
|
479
|
+
res["name"] = step["name"]
|
|
480
|
+
results.append(res)
|
|
481
|
+
if not res.get("ok"):
|
|
482
|
+
return results, False
|
|
483
|
+
continue
|
|
484
|
+
|
|
485
|
+
attempts = 0
|
|
486
|
+
while True:
|
|
487
|
+
attempts += 1
|
|
488
|
+
res = _run_pct(vmid, step["cmd"])
|
|
489
|
+
res["name"] = step["name"]
|
|
490
|
+
res["attempt"] = attempts
|
|
491
|
+
if res["returncode"] != 0:
|
|
492
|
+
res["error_summary"] = _summarize_error(res)
|
|
493
|
+
results.append(res)
|
|
494
|
+
if res["returncode"] == 0:
|
|
495
|
+
break
|
|
496
|
+
retry_on = step.get("retry_on", [])
|
|
497
|
+
if attempts >= step.get("retries", 0) + 1:
|
|
498
|
+
return results, False
|
|
499
|
+
if any(tok in (res.get("stderr", "") + res.get("stdout", "")) for tok in retry_on):
|
|
500
|
+
time.sleep(step.get("retry_delay_s", 3))
|
|
501
|
+
continue
|
|
502
|
+
return results, False
|
|
503
|
+
return results, True
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _bootstrap_portacode(vmid: int, user: str, password: str, ssh_key: str) -> Tuple[str, List[Dict[str, Any]]]:
|
|
507
|
+
steps = [
|
|
508
|
+
{
|
|
509
|
+
"name": "apt_update",
|
|
510
|
+
"cmd": "apt-get update -y",
|
|
511
|
+
"retries": 4,
|
|
512
|
+
"retry_delay_s": 5,
|
|
513
|
+
"retry_on": [
|
|
514
|
+
"Temporary failure resolving",
|
|
515
|
+
"Could not resolve",
|
|
516
|
+
"Failed to fetch",
|
|
517
|
+
],
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
"name": "install_deps",
|
|
521
|
+
"cmd": "apt-get install -y python3 python3-pip sudo --fix-missing",
|
|
522
|
+
"retries": 5,
|
|
523
|
+
"retry_delay_s": 5,
|
|
524
|
+
"retry_on": [
|
|
525
|
+
"lock-frontend",
|
|
526
|
+
"Unable to acquire the dpkg frontend lock",
|
|
527
|
+
"Temporary failure resolving",
|
|
528
|
+
"Could not resolve",
|
|
529
|
+
"Failed to fetch",
|
|
530
|
+
],
|
|
531
|
+
},
|
|
532
|
+
{"name": "user_exists", "cmd": f"id -u {user} >/dev/null 2>&1 || adduser --disabled-password --gecos '' {user}", "retries": 0},
|
|
533
|
+
{"name": "add_sudo", "cmd": f"usermod -aG sudo {user}", "retries": 0},
|
|
534
|
+
]
|
|
535
|
+
if password:
|
|
536
|
+
steps.append({"name": "set_password", "cmd": f"echo '{user}:{password}' | chpasswd", "retries": 0})
|
|
537
|
+
if ssh_key:
|
|
538
|
+
steps.append({
|
|
539
|
+
"name": "add_ssh_key",
|
|
540
|
+
"cmd": f"install -d -m 700 /home/{user}/.ssh && echo '{ssh_key}' >> /home/{user}/.ssh/authorized_keys && chown -R {user}:{user} /home/{user}/.ssh",
|
|
541
|
+
"retries": 0,
|
|
542
|
+
})
|
|
543
|
+
steps.extend([
|
|
544
|
+
{"name": "pip_upgrade", "cmd": "python3 -m pip install --upgrade pip", "retries": 0},
|
|
545
|
+
{"name": "install_portacode", "cmd": "python3 -m pip install --upgrade portacode", "retries": 0},
|
|
546
|
+
{"name": "portacode_connect", "type": "portacode_connect", "timeout_s": 30},
|
|
547
|
+
])
|
|
548
|
+
|
|
549
|
+
results, ok = _run_setup_steps(vmid, steps, user)
|
|
550
|
+
if not ok:
|
|
551
|
+
raise RuntimeError("Portacode bootstrap steps failed.")
|
|
552
|
+
key_step = next((entry for entry in results if entry.get("name") == "portacode_connect"), None)
|
|
553
|
+
public_key = key_step.get("public_key") if key_step else None
|
|
554
|
+
if not public_key:
|
|
555
|
+
raise RuntimeError("Portacode connect did not return a public key.")
|
|
556
|
+
return public_key, results
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
560
|
+
network = config.get("network", {})
|
|
561
|
+
base_network = {
|
|
562
|
+
"applied": network.get("applied", False),
|
|
563
|
+
"message": network.get("message"),
|
|
564
|
+
"bridge": network.get("bridge", DEFAULT_BRIDGE),
|
|
565
|
+
}
|
|
566
|
+
if not config:
|
|
567
|
+
return {"configured": False, "network": base_network}
|
|
568
|
+
return {
|
|
569
|
+
"configured": True,
|
|
570
|
+
"host": config.get("host"),
|
|
571
|
+
"node": config.get("node"),
|
|
572
|
+
"user": config.get("user"),
|
|
573
|
+
"token_name": config.get("token_name"),
|
|
574
|
+
"default_storage": config.get("default_storage"),
|
|
575
|
+
"templates": config.get("templates") or [],
|
|
576
|
+
"last_verified": config.get("last_verified"),
|
|
577
|
+
"network": base_network,
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl: bool = False) -> Dict[str, Any]:
|
|
582
|
+
ProxmoxAPI = _ensure_proxmoxer()
|
|
583
|
+
user, token_name = _parse_token(token_identifier)
|
|
584
|
+
client = ProxmoxAPI(
|
|
585
|
+
DEFAULT_HOST,
|
|
586
|
+
user=user,
|
|
587
|
+
token_name=token_name,
|
|
588
|
+
token_value=token_value,
|
|
589
|
+
verify_ssl=verify_ssl,
|
|
590
|
+
timeout=30,
|
|
591
|
+
)
|
|
592
|
+
node = _pick_node(client)
|
|
593
|
+
status = client.nodes(node).status.get()
|
|
594
|
+
storages = client.nodes(node).storage.get()
|
|
595
|
+
default_storage = _pick_storage(storages)
|
|
596
|
+
templates = _list_templates(client, node, storages)
|
|
597
|
+
network: Dict[str, Any] = {}
|
|
598
|
+
try:
|
|
599
|
+
network = _ensure_bridge()
|
|
600
|
+
# Wait for network convergence before validating connectivity
|
|
601
|
+
time.sleep(2)
|
|
602
|
+
if _verify_connectivity():
|
|
603
|
+
network["health"] = "healthy"
|
|
604
|
+
else:
|
|
605
|
+
network = {"applied": False, "bridge": DEFAULT_BRIDGE, "message": "Connectivity check failed; bridge reverted"}
|
|
606
|
+
_revert_bridge()
|
|
607
|
+
except PermissionError as exc:
|
|
608
|
+
network = {"applied": False, "message": str(exc), "bridge": DEFAULT_BRIDGE}
|
|
609
|
+
logger.warning("Bridge setup skipped: %s", exc)
|
|
610
|
+
except Exception as exc: # pragma: no cover - best effort
|
|
611
|
+
network = {"applied": False, "message": str(exc), "bridge": DEFAULT_BRIDGE}
|
|
612
|
+
logger.warning("Bridge setup failed: %s", exc)
|
|
613
|
+
config = {
|
|
614
|
+
"host": DEFAULT_HOST,
|
|
615
|
+
"node": node,
|
|
616
|
+
"user": user,
|
|
617
|
+
"token_name": token_name,
|
|
618
|
+
"token_value": token_value,
|
|
619
|
+
"verify_ssl": verify_ssl,
|
|
620
|
+
"default_storage": default_storage,
|
|
621
|
+
"templates": templates,
|
|
622
|
+
"last_verified": datetime.utcnow().isoformat() + "Z",
|
|
623
|
+
"network": network,
|
|
624
|
+
"node_status": status,
|
|
625
|
+
}
|
|
626
|
+
_save_config(config)
|
|
627
|
+
snapshot = build_snapshot(config)
|
|
628
|
+
snapshot["node_status"] = status
|
|
629
|
+
return snapshot
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def get_infra_snapshot() -> Dict[str, Any]:
|
|
633
|
+
config = _load_config()
|
|
634
|
+
snapshot = build_snapshot(config)
|
|
635
|
+
if config.get("node_status"):
|
|
636
|
+
snapshot["node_status"] = config["node_status"]
|
|
637
|
+
return snapshot
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def revert_infrastructure() -> Dict[str, Any]:
|
|
641
|
+
_revert_bridge()
|
|
642
|
+
if CONFIG_PATH.exists():
|
|
643
|
+
CONFIG_PATH.unlink()
|
|
644
|
+
snapshot = build_snapshot({})
|
|
645
|
+
snapshot["network"] = snapshot.get("network", {})
|
|
646
|
+
snapshot["network"]["applied"] = False
|
|
647
|
+
snapshot["network"]["message"] = "Reverted to previous network state"
|
|
648
|
+
snapshot["network"]["bridge"] = DEFAULT_BRIDGE
|
|
649
|
+
return snapshot
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _allocate_vmid(proxmox: Any) -> int:
|
|
653
|
+
return int(proxmox.cluster.nextid.get())
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) -> Tuple[int, float]:
|
|
657
|
+
from proxmoxer.core import ResourceException
|
|
658
|
+
|
|
659
|
+
storage_type = _get_storage_type(proxmox.nodes(node).storage.get(), payload["storage"])
|
|
660
|
+
rootfs = _format_rootfs(payload["storage"], payload["disk_gib"], storage_type)
|
|
661
|
+
vmid = _allocate_vmid(proxmox)
|
|
662
|
+
if not payload.get("hostname"):
|
|
663
|
+
payload["hostname"] = f"ct{vmid}"
|
|
664
|
+
try:
|
|
665
|
+
upid = proxmox.nodes(node).lxc.create(
|
|
666
|
+
vmid=vmid,
|
|
667
|
+
hostname=payload["hostname"],
|
|
668
|
+
ostemplate=payload["template"],
|
|
669
|
+
rootfs=rootfs,
|
|
670
|
+
memory=int(payload["ram_mib"]),
|
|
671
|
+
swap=int(payload.get("swap_mb", 0)),
|
|
672
|
+
cores=int(payload.get("cpus", 1)),
|
|
673
|
+
cpuunits=int(payload.get("cpuunits", 256)),
|
|
674
|
+
net0=payload["net0"],
|
|
675
|
+
unprivileged=int(payload.get("unprivileged", 1)),
|
|
676
|
+
description=payload.get("description", MANAGED_MARKER),
|
|
677
|
+
password=payload.get("password") or None,
|
|
678
|
+
ssh_public_keys=payload.get("ssh_public_key") or None,
|
|
679
|
+
)
|
|
680
|
+
status, elapsed = _wait_for_task(proxmox, node, upid)
|
|
681
|
+
return vmid, elapsed
|
|
682
|
+
except ResourceException as exc:
|
|
683
|
+
raise RuntimeError(f"Failed to create container: {exc}") from exc
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
class CreateProxmoxContainerHandler(SyncHandler):
|
|
687
|
+
"""Provision a new managed LXC container via the Proxmox API."""
|
|
688
|
+
|
|
689
|
+
@property
|
|
690
|
+
def command_name(self) -> str:
|
|
691
|
+
return "create_proxmox_container"
|
|
692
|
+
|
|
693
|
+
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
694
|
+
logger.info("create_proxmox_container command received")
|
|
695
|
+
if os.geteuid() != 0:
|
|
696
|
+
logger.error("container creation rejected: not running as root")
|
|
697
|
+
raise PermissionError("Container creation requires root privileges.")
|
|
698
|
+
|
|
699
|
+
config = _load_config()
|
|
700
|
+
if not config or not config.get("token_value"):
|
|
701
|
+
logger.error("container creation rejected: infra not configured")
|
|
702
|
+
raise ValueError("Proxmox infrastructure is not configured.")
|
|
703
|
+
if not config.get("network", {}).get("applied"):
|
|
704
|
+
logger.error("container creation rejected: network bridge not applied")
|
|
705
|
+
raise RuntimeError("Proxmox bridge setup must be applied before creating containers.")
|
|
706
|
+
|
|
707
|
+
proxmox = _connect_proxmox(config)
|
|
708
|
+
node = config.get("node") or DEFAULT_NODE_NAME
|
|
709
|
+
payload = _build_container_payload(message, config)
|
|
710
|
+
payload["cpuunits"] = max(int(payload["cpus"] * 1024), 10)
|
|
711
|
+
payload["memory"] = int(payload["ram_mib"])
|
|
712
|
+
payload["node"] = node
|
|
713
|
+
logger.debug(
|
|
714
|
+
"Provisioning container node=%s template=%s ram=%s cpu=%s storage=%s",
|
|
715
|
+
node,
|
|
716
|
+
payload["template"],
|
|
717
|
+
payload["ram_mib"],
|
|
718
|
+
payload["cpus"],
|
|
719
|
+
payload["storage"],
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
try:
|
|
723
|
+
vmid, _ = _instantiate_container(proxmox, node, payload)
|
|
724
|
+
except Exception as exc:
|
|
725
|
+
logger.exception("container instantiation failed")
|
|
726
|
+
raise
|
|
727
|
+
|
|
728
|
+
payload["vmid"] = vmid
|
|
729
|
+
payload["created_at"] = datetime.utcnow().isoformat() + "Z"
|
|
730
|
+
_write_container_record(vmid, payload)
|
|
731
|
+
|
|
732
|
+
try:
|
|
733
|
+
_start_container(proxmox, node, vmid)
|
|
734
|
+
public_key, steps = _bootstrap_portacode(vmid, payload["username"], payload["password"], payload["ssh_public_key"])
|
|
735
|
+
except Exception:
|
|
736
|
+
logger.exception("failed to start/setup container %s", vmid)
|
|
737
|
+
raise
|
|
738
|
+
|
|
739
|
+
return {
|
|
740
|
+
"event": "proxmox_container_created",
|
|
741
|
+
"success": True,
|
|
742
|
+
"message": f"Container {vmid} is ready and Portacode key captured.",
|
|
743
|
+
"ctid": str(vmid),
|
|
744
|
+
"public_key": public_key,
|
|
745
|
+
"container": {
|
|
746
|
+
"vmid": vmid,
|
|
747
|
+
"hostname": payload["hostname"],
|
|
748
|
+
"template": payload["template"],
|
|
749
|
+
"storage": payload["storage"],
|
|
750
|
+
"disk_gib": payload["disk_gib"],
|
|
751
|
+
"ram_mib": payload["ram_mib"],
|
|
752
|
+
"cpus": payload["cpus"],
|
|
753
|
+
},
|
|
754
|
+
"setup_steps": steps,
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
class ConfigureProxmoxInfraHandler(SyncHandler):
|
|
759
|
+
@property
|
|
760
|
+
def command_name(self) -> str:
|
|
761
|
+
return "setup_proxmox_infra"
|
|
762
|
+
|
|
763
|
+
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
764
|
+
token_identifier = message.get("token_identifier")
|
|
765
|
+
token_value = message.get("token_value")
|
|
766
|
+
verify_ssl = bool(message.get("verify_ssl"))
|
|
767
|
+
if not token_identifier or not token_value:
|
|
768
|
+
raise ValueError("token_identifier and token_value are required")
|
|
769
|
+
snapshot = configure_infrastructure(token_identifier, token_value, verify_ssl=verify_ssl)
|
|
770
|
+
return {
|
|
771
|
+
"event": "proxmox_infra_configured",
|
|
772
|
+
"success": True,
|
|
773
|
+
"message": "Proxmox infrastructure configured",
|
|
774
|
+
"infra": snapshot,
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
class RevertProxmoxInfraHandler(SyncHandler):
|
|
779
|
+
@property
|
|
780
|
+
def command_name(self) -> str:
|
|
781
|
+
return "revert_proxmox_infra"
|
|
782
|
+
|
|
783
|
+
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
784
|
+
snapshot = revert_infrastructure()
|
|
785
|
+
return {
|
|
786
|
+
"event": "proxmox_infra_reverted",
|
|
787
|
+
"success": True,
|
|
788
|
+
"message": "Proxmox infrastructure configuration reverted",
|
|
789
|
+
"infra": snapshot,
|
|
790
|
+
}
|