| 1 | #!/usr/bin/env python3 |
| 2 | |
| 3 | import argparse |
| 4 | import base64 |
| 5 | import fcntl |
| 6 | import hashlib |
| 7 | import json |
| 8 | import os |
| 9 | from pathlib import Path |
| 10 | import re |
| 11 | import shlex |
| 12 | import shutil |
| 13 | import socket |
| 14 | import subprocess |
| 15 | import tempfile |
| 16 | import time |
| 17 | import urllib.parse |
| 18 | import urllib.request |
| 19 | import uuid |
| 20 | |
| 21 | from lab_network import ensure_hub |
| 22 | |
| 23 | |
| 24 | from env import ROOT, setting |
| 25 | |
| 26 | |
| 27 | VM_HOME = Path(setting("ONE_VM_HOME") or ROOT / "lab-unset").expanduser() |
| 28 | LINUX_HOME = VM_HOME / "linux" |
| 29 | IMAGES = LINUX_HOME / "images" |
| 30 | INSTANCES = LINUX_HOME / "instances" |
| 31 | RUN = LINUX_HOME / "run" |
| 32 | BASE = IMAGES / "debian-13-genericcloud-arm64.qcow2" |
| 33 | BASE_MANIFEST = IMAGES / "debian-13-genericcloud-arm64.json" |
| 34 | IMAGE_URL = os.environ.get( |
| 35 | "LINUX_VM_IMAGE_URL", |
| 36 | "https://cloud.debian.org/images/cloud/trixie/latest/" |
| 37 | "debian-13-genericcloud-arm64.qcow2", |
| 38 | ) |
| 39 | FIRMWARE = Path(os.environ.get( |
| 40 | "LINUX_VM_FIRMWARE", "/opt/homebrew/share/qemu/edk2-aarch64-code.fd" |
| 41 | )) |
| 42 | LAB_ADDRESS = "192.168.77.1" |
| 43 | NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,22}[a-z0-9])?") |
| 44 | |
| 45 | |
| 46 | def require_vm_home(): |
| 47 | if len(VM_HOME.parts) > 2 and VM_HOME.parts[1] == "Volumes": |
| 48 | volume = Path("/Volumes") / VM_HOME.parts[2] |
| 49 | if not os.path.ismount(volume): |
| 50 | raise SystemExit("VM volume is not mounted: %s" % volume) |
| 51 | |
| 52 | |
| 53 | def executable(name): |
| 54 | path = shutil.which(name) or "/opt/homebrew/bin/" + name |
| 55 | if not Path(path).is_file(): |
| 56 | raise SystemExit("Missing executable: %s" % name) |
| 57 | return path |
| 58 | |
| 59 | |
| 60 | def atomic_json(path, value): |
| 61 | path.parent.mkdir(parents=True, exist_ok=True) |
| 62 | temporary = path.with_suffix(path.suffix + ".tmp") |
| 63 | temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n") |
| 64 | temporary.replace(path) |
| 65 | |
| 66 | |
| 67 | def runtime(name): |
| 68 | root = RUN / name |
| 69 | return root, root / "qmp.sock", root / "qemu.pid", root / "qemu.log" |
| 70 | |
| 71 | |
| 72 | def running(name): |
| 73 | try: |
| 74 | os.kill(int(runtime(name)[2].read_text()), 0) |
| 75 | return True |
| 76 | except (FileNotFoundError, ProcessLookupError, ValueError): |
| 77 | return False |
| 78 | |
| 79 | |
| 80 | def instance_path(name): |
| 81 | return INSTANCES / (name + ".json") |
| 82 | |
| 83 | |
| 84 | def validate_name(name): |
| 85 | if not NAME.fullmatch(name): |
| 86 | raise SystemExit("VM name must be 1-24 lowercase letters, digits, or hyphens") |
| 87 | |
| 88 | |
| 89 | def load_instance(name): |
| 90 | validate_name(name) |
| 91 | try: |
| 92 | return json.loads(instance_path(name).read_text()) |
| 93 | except FileNotFoundError: |
| 94 | raise SystemExit("Linux VM does not exist: %s" % name) |
| 95 | except (OSError, ValueError) as e: |
| 96 | raise SystemExit("Cannot read Linux VM %s: %s" % (name, e)) |
| 97 | |
| 98 | |
| 99 | def configs(): |
| 100 | if not INSTANCES.exists(): |
| 101 | return [] |
| 102 | return [(path.stem, json.loads(path.read_text())) |
| 103 | for path in sorted(INSTANCES.glob("*.json"))] |
| 104 | |
| 105 | |
| 106 | def available_port(field, start, end, requested=None): |
| 107 | used = {config[field] for _name, config in configs()} |
| 108 | candidates = [requested] if requested else range(start, end + 1) |
| 109 | for port in candidates: |
| 110 | if port is None or port < 1024 or port > 65535 or port in used: |
| 111 | continue |
| 112 | with socket.socket() as probe: |
| 113 | try: |
| 114 | probe.bind(("127.0.0.1", port)) |
| 115 | except OSError: |
| 116 | continue |
| 117 | return port |
| 118 | raise SystemExit("No unused %s is available" % field.replace("_", " ")) |
| 119 | |
| 120 | |
| 121 | def mac(prefix, name): |
| 122 | tail = uuid.uuid5(uuid.NAMESPACE_URL, prefix + name).bytes[-3:] |
| 123 | kind = 4 if prefix == "lab:" else 3 |
| 124 | return "52:54:%02x:%02x:%02x:%02x" % (kind, *tail) |
| 125 | |
| 126 | |
| 127 | def key_path(name): |
| 128 | return INSTANCES / (name + ".key") |
| 129 | |
| 130 | |
| 131 | def seed_path(name): |
| 132 | return INSTANCES / (name + "-seed.iso") |
| 133 | |
| 134 | |
| 135 | def overlay_path(name): |
| 136 | return INSTANCES / (name + ".qcow2") |
| 137 | |
| 138 | |
| 139 | DESKTOP_PACKAGES = [ |
| 140 | "xvfb", "openbox", "xdotool", "xclip", "maim", "x11-utils", "dbus-user-session", |
| 141 | "at-spi2-core", "python3-pyatspi", "libatk-adaptor", "mesa-vulkan-drivers", |
| 142 | "libgl1-mesa-dri", "libxkbcommon-x11-0", "libxcursor1", "libxi6", "libxrandr2", |
| 143 | "fonts-liberation", "fonts-crosextra-carlito", "build-essential", "pkg-config", |
| 144 | "rsync", "xterm", "zenity", |
| 145 | ] |
| 146 | # 1280 wide keeps screenshots under the model's downscale threshold, so image |
| 147 | # pixels stay click coordinates. |
| 148 | DESKTOP_UNITS = { |
| 149 | "agent-display.service": """[Unit] |
| 150 | Description=Agent X display |
| 151 | |
| 152 | [Service] |
| 153 | ExecStart=/usr/bin/Xvfb :0 -screen 0 1280x800x24 -nolisten tcp |
| 154 | |
| 155 | [Install] |
| 156 | WantedBy=default.target |
| 157 | """, |
| 158 | "agent-wm.service": """[Unit] |
| 159 | Description=Agent window manager |
| 160 | Requires=agent-display.service |
| 161 | After=agent-display.service |
| 162 | |
| 163 | [Service] |
| 164 | ExecStartPre=/bin/sh -c 'until xdpyinfo >/dev/null 2>&1; do sleep 0.1; done' |
| 165 | ExecStartPre=/usr/bin/busctl --user set-property org.a11y.Bus /org/a11y/bus org.a11y.Status IsEnabled b true |
| 166 | ExecStart=/usr/bin/openbox |
| 167 | |
| 168 | [Install] |
| 169 | WantedBy=default.target |
| 170 | """, |
| 171 | } |
| 172 | |
| 173 | |
| 174 | def make_seed(path, hostname, public_key, wan_mac, lab_mac, desktop=False): |
| 175 | samba = """[global] |
| 176 | workgroup = WORKGROUP |
| 177 | server role = standalone server |
| 178 | map to guest = Bad User |
| 179 | server min protocol = SMB2_02 |
| 180 | interfaces = lo wan0 lab0 |
| 181 | bind interfaces only = yes |
| 182 | |
| 183 | [agent] |
| 184 | path = /srv/agent |
| 185 | read only = no |
| 186 | guest ok = yes |
| 187 | force user = agent |
| 188 | create mask = 0666 |
| 189 | directory mask = 0777 |
| 190 | """ |
| 191 | dnsmasq = """port=0 |
| 192 | interface=lab0 |
| 193 | bind-interfaces |
| 194 | dhcp-range=192.168.77.10,192.168.77.250,255.255.255.0,12h |
| 195 | dhcp-option=3 |
| 196 | dhcp-option=6 |
| 197 | """ |
| 198 | user_data = """#cloud-config |
| 199 | hostname: {hostname} |
| 200 | manage_etc_hosts: true |
| 201 | users: |
| 202 | - name: agent |
| 203 | gecos: OneNote test agent |
| 204 | groups: [sudo] |
| 205 | shell: /bin/bash |
| 206 | sudo: ALL=(ALL) NOPASSWD:ALL |
| 207 | lock_passwd: true |
| 208 | ssh_authorized_keys: |
| 209 | - {public_key} |
| 210 | ssh_pwauth: false |
| 211 | disable_root: true |
| 212 | package_update: true |
| 213 | packages: [{packages}] |
| 214 | write_files: |
| 215 | - path: /etc/samba/smb.conf |
| 216 | permissions: '0644' |
| 217 | encoding: b64 |
| 218 | content: {samba} |
| 219 | - path: /etc/dnsmasq.d/onenote-lab.conf |
| 220 | permissions: '0644' |
| 221 | encoding: b64 |
| 222 | content: {dnsmasq} |
| 223 | {desktop_files}runcmd: |
| 224 | - [mkdir, -p, /srv/agent] |
| 225 | - [chown, agent:agent, /srv/agent] |
| 226 | - [systemctl, enable, --now, dnsmasq] |
| 227 | - [systemctl, enable, --now, smbd] |
| 228 | {desktop_commands}""".format( |
| 229 | hostname=hostname, |
| 230 | public_key=public_key, |
| 231 | packages=", ".join(["samba", "dnsmasq", "cifs-utils", "smbclient", "fio"] + |
| 232 | (DESKTOP_PACKAGES if desktop else [])), |
| 233 | samba=base64.b64encode(samba.encode()).decode(), |
| 234 | dnsmasq=base64.b64encode(dnsmasq.encode()).decode(), |
| 235 | # /etc/environment reaches SSH sessions; environment.d reaches user units. |
| 236 | desktop_files="".join( |
| 237 | " - path: %s\n append: true\n encoding: b64\n content: %s\n" |
| 238 | % (path, base64.b64encode(content.encode()).decode()) |
| 239 | for path, content in [("/etc/systemd/user/" + unit, text) |
| 240 | for unit, text in DESKTOP_UNITS.items()] + |
| 241 | [("/etc/environment", "DISPLAY=:0\n"), |
| 242 | ("/etc/environment.d/display.conf", "DISPLAY=:0\n")] |
| 243 | ) if desktop else "", |
| 244 | # The wait loop's SSH probes start the user manager before these units |
| 245 | # and dbus-user-session exist, so it must restart to pick them up. |
| 246 | desktop_commands=( |
| 247 | " - [systemctl, --global, enable, %s]\n" |
| 248 | " - [loginctl, enable-linger, agent]\n" |
| 249 | " - [sh, -c, 'systemctl restart user@$(id -u agent).service']\n" |
| 250 | % ", ".join(DESKTOP_UNITS) |
| 251 | ) if desktop else "", |
| 252 | ) |
| 253 | network = """version: 2 |
| 254 | ethernets: |
| 255 | wan0: |
| 256 | match: |
| 257 | macaddress: "{wan_mac}" |
| 258 | set-name: wan0 |
| 259 | dhcp4: true |
| 260 | lab0: |
| 261 | match: |
| 262 | macaddress: "{lab_mac}" |
| 263 | set-name: lab0 |
| 264 | addresses: [{lab_address}/24] |
| 265 | """.format(wan_mac=wan_mac, lab_mac=lab_mac, lab_address=LAB_ADDRESS) |
| 266 | path.parent.mkdir(parents=True, exist_ok=True) |
| 267 | with tempfile.TemporaryDirectory(prefix="one-linux-seed-") as temporary: |
| 268 | root = Path(temporary) |
| 269 | (root / "meta-data").write_text( |
| 270 | "instance-id: %s\nlocal-hostname: %s\n" % (hostname, hostname) |
| 271 | ) |
| 272 | (root / "user-data").write_text(user_data) |
| 273 | (root / "network-config").write_text(network) |
| 274 | subprocess.run([ |
| 275 | "hdiutil", "makehybrid", "-quiet", "-iso", "-joliet", |
| 276 | "-default-volume-name", "cidata", "-o", str(path), str(root), |
| 277 | ], check=True) |
| 278 | |
| 279 | |
| 280 | def fetch_base(): |
| 281 | require_vm_home() |
| 282 | if BASE.exists() and BASE_MANIFEST.exists(): |
| 283 | subprocess.run([executable("qemu-img"), "check", str(BASE)], check=True) |
| 284 | print(BASE_MANIFEST) |
| 285 | return |
| 286 | headers = {} |
| 287 | if os.environ.get("ONE_VM_AUTHORIZATION"): |
| 288 | headers["Authorization"] = os.environ["ONE_VM_AUTHORIZATION"] |
| 289 | expected = os.environ.get("LINUX_VM_SHA512", "").lower() |
| 290 | if not expected: |
| 291 | sums_url = urllib.parse.urljoin(IMAGE_URL, "SHA512SUMS") |
| 292 | with urllib.request.urlopen(urllib.request.Request(sums_url, headers=headers)) as response: |
| 293 | sums = response.read().decode() |
| 294 | filename = urllib.parse.urlparse(IMAGE_URL).path.rsplit("/", 1)[-1] |
| 295 | match = re.search(r"^([0-9a-f]{128})\s+\*?%s$" % re.escape(filename), |
| 296 | sums, re.MULTILINE) |
| 297 | if not match: |
| 298 | raise SystemExit("Image is absent from %s" % sums_url) |
| 299 | expected = match.group(1) |
| 300 | if not re.fullmatch(r"[0-9a-f]{128}", expected): |
| 301 | raise SystemExit("LINUX_VM_SHA512 must contain 128 hexadecimal characters") |
| 302 | IMAGES.mkdir(parents=True, exist_ok=True) |
| 303 | temporary = BASE.with_suffix(".download.qcow2") |
| 304 | digest = hashlib.sha512() |
| 305 | try: |
| 306 | with urllib.request.urlopen(urllib.request.Request(IMAGE_URL, headers=headers)) as response: |
| 307 | with temporary.open("wb") as output: |
| 308 | while chunk := response.read(8 * 1024 * 1024): |
| 309 | output.write(chunk) |
| 310 | digest.update(chunk) |
| 311 | if digest.hexdigest() != expected: |
| 312 | raise SystemExit("Downloaded Linux image failed SHA-512 verification") |
| 313 | subprocess.run([executable("qemu-img"), "check", str(temporary)], check=True) |
| 314 | temporary.replace(BASE) |
| 315 | BASE.chmod(0o444) |
| 316 | atomic_json(BASE_MANIFEST, {"file": BASE.name, "sha512": expected, |
| 317 | "url": IMAGE_URL}) |
| 318 | except Exception: |
| 319 | temporary.unlink(missing_ok=True) |
| 320 | raise |
| 321 | print(BASE_MANIFEST) |
| 322 | |
| 323 | |
| 324 | def create_instance(name, cpus=2, memory_mb=2048, disk_gb=16, |
| 325 | ssh_port=None, samba_port=None, desktop=False): |
| 326 | require_vm_home() |
| 327 | validate_name(name) |
| 328 | if not BASE.is_file() or not BASE_MANIFEST.is_file(): |
| 329 | raise SystemExit("No Linux base image. Run: ./linux_vm.py fetch") |
| 330 | if not 1 <= cpus <= 16: |
| 331 | raise SystemExit("CPU count must be between 1 and 16") |
| 332 | if not 512 <= memory_mb <= 65536: |
| 333 | raise SystemExit("Memory must be between 512 and 65536 MiB") |
| 334 | if not 8 <= disk_gb <= 1024: |
| 335 | raise SystemExit("Disk size must be between 8 and 1024 GiB") |
| 336 | LINUX_HOME.mkdir(parents=True, exist_ok=True) |
| 337 | with (LINUX_HOME / ".lock").open("a") as lock: |
| 338 | fcntl.flock(lock, fcntl.LOCK_EX) |
| 339 | if instance_path(name).exists(): |
| 340 | raise SystemExit("Linux VM already exists: %s" % name) |
| 341 | ssh_port = available_port("ssh_port", 22000, 22099, ssh_port) |
| 342 | samba_port = available_port("samba_port", 14450, 14549, samba_port) |
| 343 | hostname = "one-" + name |
| 344 | private = key_path(name) |
| 345 | public = private.with_suffix(".key.pub") |
| 346 | overlay = overlay_path(name) |
| 347 | seed = seed_path(name) |
| 348 | INSTANCES.mkdir(parents=True, exist_ok=True) |
| 349 | try: |
| 350 | subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", |
| 351 | "-C", hostname, "-f", str(private)], check=True) |
| 352 | private.chmod(0o600) |
| 353 | subprocess.run([ |
| 354 | executable("qemu-img"), "create", "-f", "qcow2", "-F", "qcow2", |
| 355 | "-b", str(BASE), str(overlay), |
| 356 | ], check=True) |
| 357 | subprocess.run([executable("qemu-img"), "resize", str(overlay), |
| 358 | "%dG" % disk_gb], check=True) |
| 359 | wan = mac("wan:", name) |
| 360 | lab = mac("lab:", name) |
| 361 | make_seed(seed, hostname, public.read_text().strip(), wan, lab, desktop) |
| 362 | seed.chmod(0o600) |
| 363 | config = {"cpus": cpus, "desktop": desktop, "disk_gb": disk_gb, |
| 364 | "hostname": hostname, |
| 365 | "lab_address": LAB_ADDRESS, "lab_mac": lab, |
| 366 | "memory_mb": memory_mb, "samba_port": samba_port, |
| 367 | "ssh_port": ssh_port, "wan_mac": wan} |
| 368 | atomic_json(instance_path(name), config) |
| 369 | instance_path(name).chmod(0o600) |
| 370 | except Exception: |
| 371 | for path in (private, public, overlay, seed, instance_path(name)): |
| 372 | path.unlink(missing_ok=True) |
| 373 | raise |
| 374 | print(json.dumps(dict(config, name=name), sort_keys=True)) |
| 375 | |
| 376 | |
| 377 | def launch(name): |
| 378 | require_vm_home() |
| 379 | config = load_instance(name) |
| 380 | if running(name): |
| 381 | raise SystemExit("Linux VM is already running: %s" % name) |
| 382 | active = [other for other, _config in configs() if other != name and running(other)] |
| 383 | if active: |
| 384 | raise SystemExit("Only one Samba VM may use %s: %s" % |
| 385 | (LAB_ADDRESS, ", ".join(active))) |
| 386 | if not FIRMWARE.is_file(): |
| 387 | raise SystemExit("AArch64 UEFI firmware not found: %s" % FIRMWARE) |
| 388 | root, qmp_socket, pid, log_path = runtime(name) |
| 389 | root.mkdir(parents=True, exist_ok=True) |
| 390 | qmp_socket.unlink(missing_ok=True) |
| 391 | lab_socket = ensure_hub(VM_HOME) |
| 392 | command = [ |
| 393 | executable("qemu-system-aarch64"), |
| 394 | "-name", "OneNote Linux Samba " + name, |
| 395 | "-machine", "virt,accel=hvf", "-cpu", "host", |
| 396 | "-smp", str(config["cpus"]), "-m", str(config["memory_mb"]), |
| 397 | "-bios", str(FIRMWARE), |
| 398 | "-drive", "if=none,file=%s,format=qcow2,id=root" % overlay_path(name), |
| 399 | "-device", "virtio-blk-pci,drive=root", |
| 400 | "-device", "virtio-scsi-pci,id=scsi", |
| 401 | "-drive", "if=none,file=%s,format=raw,media=cdrom,readonly=on,id=seed" % seed_path(name), |
| 402 | "-device", "scsi-cd,drive=seed", |
| 403 | "-netdev", ("user,id=wan,hostfwd=tcp:127.0.0.1:%d-:22," |
| 404 | "hostfwd=tcp:127.0.0.1:%d-:445" % |
| 405 | (config["ssh_port"], config["samba_port"])), |
| 406 | "-device", "virtio-net-pci,netdev=wan,mac=%s" % config["wan_mac"], |
| 407 | "-netdev", "vde,id=lab,sock=%s" % lab_socket, |
| 408 | "-device", "virtio-net-pci,netdev=lab,mac=%s" % config["lab_mac"], |
| 409 | "-uuid", str(uuid.uuid5(uuid.NAMESPACE_URL, "onenote-linux:" + name)), |
| 410 | "-display", "none", "-serial", "stdio", "-monitor", "none", |
| 411 | "-qmp", "unix:%s,server=on,wait=off" % qmp_socket, |
| 412 | "-pidfile", str(pid), |
| 413 | ] |
| 414 | with log_path.open("ab") as log: |
| 415 | subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=log, stderr=log, |
| 416 | start_new_session=True) |
| 417 | for _ in range(50): |
| 418 | if running(name): |
| 419 | print("Linux VM opened: %s" % name) |
| 420 | return |
| 421 | time.sleep(0.1) |
| 422 | raise SystemExit("Linux VM did not open. Check: %s" % log_path) |
| 423 | |
| 424 | |
| 425 | def ssh_argv(name, command=None): |
| 426 | config = load_instance(name) |
| 427 | known = INSTANCES / (name + ".known_hosts") |
| 428 | argv = ["ssh", "-p", str(config["ssh_port"]), "-i", str(key_path(name)), |
| 429 | "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes", |
| 430 | "-o", "StrictHostKeyChecking=accept-new", |
| 431 | "-o", 'UserKnownHostsFile="%s"' % known, |
| 432 | "-o", "ConnectTimeout=5", "agent@127.0.0.1"] |
| 433 | if command is not None: |
| 434 | argv.append(command) |
| 435 | return argv |
| 436 | |
| 437 | |
| 438 | def run_ssh(name, command, timeout=60): |
| 439 | return subprocess.run(ssh_argv(name, command), capture_output=True, text=True, |
| 440 | timeout=timeout) |
| 441 | |
| 442 | |
| 443 | def wait_instance(name, timeout): |
| 444 | deadline = time.monotonic() + timeout |
| 445 | while time.monotonic() < deadline: |
| 446 | try: |
| 447 | if run_ssh(name, "true", timeout=8).returncode == 0: |
| 448 | break |
| 449 | except subprocess.TimeoutExpired: |
| 450 | pass |
| 451 | time.sleep(1) |
| 452 | else: |
| 453 | raise SystemExit("SSH did not become ready within %d seconds: %s" % (timeout, name)) |
| 454 | remaining = max(1, int(deadline - time.monotonic())) |
| 455 | validation = ("sudo cloud-init status --wait && " |
| 456 | "command -v smbd dnsmasq mount.cifs smbclient fio >/dev/null && " |
| 457 | "systemctl is-active --quiet smbd dnsmasq") |
| 458 | if load_instance(name).get("desktop"): |
| 459 | validation += (" && timeout 60 sh -c 'until systemctl --user is-active --quiet " |
| 460 | "agent-wm; do sleep 0.2; done'") |
| 461 | try: |
| 462 | result = run_ssh(name, validation, timeout=remaining) |
| 463 | except subprocess.TimeoutExpired: |
| 464 | raise SystemExit("Cloud-init did not finish within %d seconds: %s" % (timeout, name)) |
| 465 | if result.returncode: |
| 466 | raise SystemExit((result.stdout + result.stderr).strip() or |
| 467 | "Linux bootstrap validation failed: %s" % name) |
| 468 | config = load_instance(name) |
| 469 | print("Ready: %s ssh=127.0.0.1:%d smb=127.0.0.1:%d windows=//%s/agent" % |
| 470 | (name, config["ssh_port"], config["samba_port"], LAB_ADDRESS)) |
| 471 | |
| 472 | |
| 473 | def qmp(name, command): |
| 474 | with socket.socket(socket.AF_UNIX) as client: |
| 475 | client.settimeout(5) |
| 476 | client.connect(str(runtime(name)[1])) |
| 477 | stream = client.makefile("rwb", buffering=0) |
| 478 | stream.readline() |
| 479 | stream.write(b'{"execute":"qmp_capabilities"}\n') |
| 480 | while "return" not in json.loads(stream.readline()): |
| 481 | pass |
| 482 | stream.write((json.dumps({"execute": command}) + "\n").encode()) |
| 483 | while True: |
| 484 | response = json.loads(stream.readline()) |
| 485 | if "return" in response: |
| 486 | return response["return"] |
| 487 | if "error" in response: |
| 488 | raise SystemExit(response["error"]["desc"]) |
| 489 | |
| 490 | |
| 491 | def shutdown(name, timeout): |
| 492 | if not running(name): |
| 493 | raise SystemExit("Linux VM is not running: %s" % name) |
| 494 | try: |
| 495 | run_ssh(name, "sudo poweroff", timeout=10) |
| 496 | except (subprocess.TimeoutExpired, OSError): |
| 497 | qmp(name, "system_powerdown") |
| 498 | deadline = time.monotonic() + timeout |
| 499 | while time.monotonic() < deadline: |
| 500 | if not running(name): |
| 501 | print("Linux VM stopped: %s" % name) |
| 502 | return |
| 503 | time.sleep(1) |
| 504 | raise SystemExit("Linux VM did not stop within %d seconds: %s" % (timeout, name)) |
| 505 | |
| 506 | |
| 507 | def delete_instance(name): |
| 508 | require_vm_home() |
| 509 | load_instance(name) |
| 510 | if running(name): |
| 511 | raise SystemExit("Shut down Linux before deleting: %s" % name) |
| 512 | for path in (overlay_path(name), seed_path(name), key_path(name), |
| 513 | key_path(name).with_suffix(".key.pub"), |
| 514 | INSTANCES / (name + ".known_hosts"), instance_path(name)): |
| 515 | path.unlink(missing_ok=True) |
| 516 | shutil.rmtree(runtime(name)[0], ignore_errors=True) |
| 517 | print("Deleted Linux VM: %s" % name) |
| 518 | |
| 519 | |
| 520 | def list_instances(name=None): |
| 521 | if name: |
| 522 | load_instance(name) |
| 523 | print("running" if running(name) else "stopped") |
| 524 | return |
| 525 | rows = configs() |
| 526 | if not rows: |
| 527 | print("No Linux VMs.") |
| 528 | return |
| 529 | for vm_name, config in rows: |
| 530 | state = "running" if running(vm_name) else "stopped" |
| 531 | print("%-24s %-24s %-7s ssh:%d smb:%d" % |
| 532 | (vm_name, config["hostname"], state, config["ssh_port"], |
| 533 | config["samba_port"])) |
| 534 | |
| 535 | |
| 536 | def main(): |
| 537 | parser = argparse.ArgumentParser(description="Run SSH-only OneNote Linux Samba VMs") |
| 538 | commands = parser.add_subparsers(dest="command", required=True) |
| 539 | commands.add_parser("fetch") |
| 540 | status = commands.add_parser("status") |
| 541 | status.add_argument("name", nargs="?") |
| 542 | up = commands.add_parser("up") |
| 543 | up.add_argument("name") |
| 544 | up.add_argument("--cpus", type=int, default=2) |
| 545 | up.add_argument("--memory", type=int, default=2048, dest="memory_mb") |
| 546 | up.add_argument("--disk", type=int, default=16, dest="disk_gb") |
| 547 | up.add_argument("--ssh-port", type=int) |
| 548 | up.add_argument("--samba-port", type=int) |
| 549 | up.add_argument("--desktop", action="store_true") |
| 550 | up.add_argument("--wait", action="store_true") |
| 551 | up.add_argument("--timeout", type=int, default=600) |
| 552 | ssh = commands.add_parser("ssh") |
| 553 | ssh.add_argument("name") |
| 554 | ssh.add_argument("remote_command", nargs=argparse.REMAINDER) |
| 555 | down = commands.add_parser("down") |
| 556 | down.add_argument("name") |
| 557 | down.add_argument("--timeout", type=int, default=60) |
| 558 | down.add_argument("--preserve-machine", action="store_true") |
| 559 | args = parser.parse_args() |
| 560 | if args.command == "fetch": |
| 561 | fetch_base() |
| 562 | elif args.command == "up": |
| 563 | if not instance_path(args.name).exists(): |
| 564 | create_instance(args.name, args.cpus, args.memory_mb, args.disk_gb, |
| 565 | args.ssh_port, args.samba_port, args.desktop) |
| 566 | if running(args.name): |
| 567 | print("Linux VM already running: %s" % args.name) |
| 568 | else: |
| 569 | launch(args.name) |
| 570 | if args.wait: |
| 571 | wait_instance(args.name, args.timeout) |
| 572 | elif args.command == "ssh": |
| 573 | command = args.remote_command |
| 574 | if command[:1] == ["--"]: |
| 575 | command = command[1:] |
| 576 | remote = command[0] if len(command) == 1 else shlex.join(command) |
| 577 | os.execvp("ssh", ssh_argv(args.name, remote if command else None)) |
| 578 | elif args.command == "down": |
| 579 | if not instance_path(args.name).exists(): |
| 580 | print("Linux VM absent: %s" % args.name) |
| 581 | else: |
| 582 | load_instance(args.name) |
| 583 | if running(args.name): |
| 584 | shutdown(args.name, args.timeout) |
| 585 | if args.preserve_machine: |
| 586 | print("Preserved Linux VM: %s" % args.name) |
| 587 | else: |
| 588 | delete_instance(args.name) |
| 589 | else: |
| 590 | if args.name and not instance_path(args.name).exists(): |
| 591 | print("absent") |
| 592 | else: |
| 593 | list_instances(args.name) |
| 594 | |
| 595 | |
| 596 | if __name__ == "__main__": |
| 597 | main() |