feat: consolidate VM automation in Python daemon
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Windows-only QEMU and FreeDOS TCP-agent automation."""
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Command line client for the Windows-only ferro-vm daemon."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from multiprocessing.connection import Client
|
||||
from pathlib import Path
|
||||
|
||||
from .daemon import PIPE, ROOT
|
||||
|
||||
|
||||
def rpc(payload: dict[str, object], start_daemon: bool = False) -> object:
|
||||
try:
|
||||
conn = Client(PIPE, family="AF_PIPE")
|
||||
except (FileNotFoundError, OSError):
|
||||
if not start_daemon:
|
||||
raise RuntimeError("ferro-vm daemon is not running; run `uv run ferro-vm start`")
|
||||
flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
subprocess.Popen([sys.executable, "-m", "ferrolang_vm.daemon"], cwd=ROOT, creationflags=flags, close_fds=True)
|
||||
deadline = time.monotonic() + 5
|
||||
while True:
|
||||
try:
|
||||
conn = Client(PIPE, family="AF_PIPE")
|
||||
break
|
||||
except (FileNotFoundError, OSError):
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError("ferro-vm daemon did not create its control pipe")
|
||||
time.sleep(.1)
|
||||
with conn:
|
||||
conn.send(payload)
|
||||
response = conn.recv()
|
||||
if not response["ok"]:
|
||||
raise RuntimeError(response["error"])
|
||||
return response["result"]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Windows-only QEMU/FreeDOS automation")
|
||||
commands = parser.add_subparsers(dest="op", required=True)
|
||||
for name in ("start", "stop", "status", "ping", "screenshot", "ocr"):
|
||||
commands.add_parser(name)
|
||||
reset = commands.add_parser("reset")
|
||||
reset.add_argument("--timeout", type=int, default=45)
|
||||
execute = commands.add_parser("exec")
|
||||
execute.add_argument("command")
|
||||
put = commands.add_parser("put")
|
||||
put.add_argument("source", type=Path)
|
||||
put.add_argument("destination")
|
||||
get = commands.add_parser("get")
|
||||
get.add_argument("source")
|
||||
get.add_argument("destination", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
if args.op == "ocr":
|
||||
result = rpc({"op": "screenshot"})
|
||||
import logging
|
||||
logging.disable(logging.INFO)
|
||||
from rapidocr import RapidOCR
|
||||
recognized = RapidOCR()(result["path"])
|
||||
print("\n".join(recognized.txts or ()))
|
||||
return 0
|
||||
if args.op == "reset":
|
||||
rpc({"op": "stop"}, start_daemon=True)
|
||||
time.sleep(.5)
|
||||
rpc({"op": "start"}, start_daemon=True)
|
||||
# FreeDOS displays its default boot menu before FDAUTO.BAT starts
|
||||
# TCPAGENT. This is input, not a readiness delay.
|
||||
time.sleep(2)
|
||||
rpc({"op": "monitor", "command": "sendkey ret"})
|
||||
deadline = time.monotonic() + args.timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
if str(rpc({"op": "ping"})["response"]).startswith("OK 504F4E47"):
|
||||
print(json.dumps({"reset": True, "agent": "PONG"}))
|
||||
return 0
|
||||
except RuntimeError:
|
||||
time.sleep(.5)
|
||||
raise RuntimeError("TCPAGENT did not become ready")
|
||||
payload: dict[str, object] = {"op": args.op}
|
||||
if args.op == "exec": payload["command"] = args.command
|
||||
if args.op == "put":
|
||||
payload["source"] = str(args.source.resolve())
|
||||
payload["destination"] = args.destination
|
||||
if args.op == "get":
|
||||
payload["source"] = args.source
|
||||
payload["destination"] = str(args.destination.resolve())
|
||||
print(json.dumps(rpc(payload, start_daemon=args.op == "start"), ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except RuntimeError as exc:
|
||||
print(f"ferro-vm: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Long-lived Windows host for the FreeDOS TCP agent.
|
||||
|
||||
The only automation TCP listener is 127.0.0.1:5558, used exclusively by
|
||||
TCPAGENT.EXE. Local commands use a Windows named pipe.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import select
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from multiprocessing.connection import Listener
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
QEMU = ROOT / ".qemu"
|
||||
PIPE = r"\\.\pipe\ferrolang-vm"
|
||||
AGENT_ADDRESS = ("127.0.0.1", 5558)
|
||||
MONITOR_ADDRESS = ("127.0.0.1", 4444)
|
||||
LOG_PATH = QEMU / "ferro-vm.log"
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
QEMU.mkdir(exist_ok=True)
|
||||
handler = logging.FileHandler(LOG_PATH, encoding="utf-8")
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s.%(msecs)03dZ %(levelname)-7s %(message)s", "%Y-%m-%dT%H:%M:%S"))
|
||||
logging.basicConfig(level=logging.INFO, handlers=[handler])
|
||||
logging.Formatter.converter = time.gmtime
|
||||
|
||||
|
||||
def log_event(level: int, event: str, **fields: object) -> None:
|
||||
suffix = " ".join(f"{key}={json.dumps(value, ensure_ascii=False)}" for key, value in fields.items())
|
||||
logging.log(level, "%s%s", event, f" {suffix}" if suffix else "")
|
||||
|
||||
|
||||
class Host:
|
||||
def __init__(self) -> None:
|
||||
self.agent: socket.socket | None = None
|
||||
self.agent_lock = threading.Lock()
|
||||
self.agent_ready = threading.Event()
|
||||
self.qemu: subprocess.Popen[bytes] | None = None
|
||||
|
||||
def accept_agents(self) -> None:
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind(AGENT_ADDRESS)
|
||||
server.listen(1)
|
||||
log_event(logging.INFO, "agent listener ready", address="127.0.0.1:5558")
|
||||
while True:
|
||||
sock, peer = server.accept()
|
||||
sock.settimeout(30)
|
||||
with self.agent_lock:
|
||||
if self.agent is not None:
|
||||
sock.close()
|
||||
log_event(logging.WARNING, "agent rejected", peer=str(peer), reason="already connected")
|
||||
continue
|
||||
self.agent = sock
|
||||
self.agent_ready.set()
|
||||
try:
|
||||
banner = self._read_line(sock).decode("ascii", "replace")
|
||||
log_event(logging.INFO, "agent connected", peer=f"{peer[0]}:{peer[1]}", banner=banner)
|
||||
# Request() owns protocol reads. Between requests, peek only
|
||||
# for EOF so TCPAGENT can reconnect without being rejected.
|
||||
while self.agent is sock:
|
||||
if self.agent_lock.acquire(blocking=False):
|
||||
try:
|
||||
readable, _, _ = select.select([sock], [], [], .25)
|
||||
if readable and not sock.recv(1, socket.MSG_PEEK):
|
||||
break
|
||||
finally:
|
||||
self.agent_lock.release()
|
||||
else:
|
||||
time.sleep(.05)
|
||||
finally:
|
||||
with self.agent_lock:
|
||||
if self.agent is sock:
|
||||
self.agent = None
|
||||
self.agent_ready.clear()
|
||||
sock.close()
|
||||
log_event(logging.INFO, "agent disconnected", peer=f"{peer[0]}:{peer[1]}")
|
||||
|
||||
@staticmethod
|
||||
def _read_line(sock: socket.socket) -> bytes:
|
||||
out = bytearray()
|
||||
while not out.endswith(b"\n"):
|
||||
part = sock.recv(1)
|
||||
if not part:
|
||||
raise ConnectionError("TCP agent closed connection")
|
||||
out.extend(part)
|
||||
return bytes(out).rstrip(b"\r\n")
|
||||
|
||||
def request(self, command: str, payload: bytes = b"") -> str:
|
||||
with self.agent_lock:
|
||||
if self.agent is None:
|
||||
raise RuntimeError("TCPAGENT is not connected")
|
||||
sock = self.agent
|
||||
started = time.monotonic()
|
||||
try:
|
||||
sock.sendall(command.encode("ascii") + b"\n" + payload)
|
||||
response = self._read_line(sock).decode("ascii", "replace")
|
||||
except OSError as exc:
|
||||
if self.agent is sock:
|
||||
self.agent = None
|
||||
self.agent_ready.clear()
|
||||
raise RuntimeError(f"TCPAGENT request failed: {exc}") from exc
|
||||
log_event(logging.INFO, "agent request", command=command.split(" ", 1)[0], response=response[:200], elapsed_ms=round((time.monotonic()-started)*1000))
|
||||
if response.startswith("ERR "):
|
||||
raise RuntimeError(bytes.fromhex(response[4:]).decode("utf-8", "replace"))
|
||||
return response
|
||||
|
||||
def ping(self) -> dict[str, object]:
|
||||
return {"response": self.request("PING")}
|
||||
|
||||
def exec(self, command: str) -> dict[str, object]:
|
||||
log_event(logging.INFO, "exec start", command=command)
|
||||
response = self.request("EXEC " + command.encode("ascii", "replace").hex().upper())
|
||||
fields = response.split(" ", 2)
|
||||
code = int(fields[1]) if len(fields) > 1 else -1
|
||||
output = bytes.fromhex(fields[2]).decode("cp437", "replace") if len(fields) > 2 else ""
|
||||
for line in output.splitlines():
|
||||
log_event(logging.INFO, "dos output", line=line)
|
||||
log_event(logging.INFO, "exec finish", exit=code)
|
||||
return {"exit": code, "output": output}
|
||||
|
||||
def put(self, source: str, destination: str) -> dict[str, object]:
|
||||
data = Path(source).read_bytes()
|
||||
encoded = destination.encode("ascii").hex().upper()
|
||||
self.request(f"PUT {encoded} {len(data)}", data)
|
||||
stat = self.request(f"HASH {encoded}")
|
||||
log_event(logging.INFO, "put", path=destination, bytes=len(data), stat=stat)
|
||||
return {"path": destination, "bytes": len(data), "stat": stat}
|
||||
|
||||
def get(self, source: str, destination: str) -> dict[str, object]:
|
||||
encoded = source.encode("ascii").hex().upper()
|
||||
with self.agent_lock:
|
||||
if self.agent is None:
|
||||
raise RuntimeError("TCPAGENT is not connected")
|
||||
sock = self.agent
|
||||
sock.sendall(f"GET {encoded}\n".encode("ascii"))
|
||||
header = self._read_line(sock).decode("ascii", "strict").split()
|
||||
if len(header) != 2 or header[0] != "DATA":
|
||||
raise RuntimeError("GET failed: " + " ".join(header))
|
||||
remaining = int(header[1])
|
||||
chunks: list[bytes] = []
|
||||
while remaining:
|
||||
chunk = sock.recv(min(65536, remaining))
|
||||
if not chunk:
|
||||
raise RuntimeError("TCPAGENT closed during GET")
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
target = Path(destination)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = b"".join(chunks)
|
||||
target.write_bytes(data)
|
||||
log_event(logging.INFO, "get", path=source, bytes=len(data), destination=str(target))
|
||||
return {"path": source, "destination": str(target), "bytes": len(data)}
|
||||
|
||||
@staticmethod
|
||||
def monitor(command: str) -> str:
|
||||
with socket.create_connection(MONITOR_ADDRESS, timeout=3) as sock:
|
||||
sock.settimeout(1)
|
||||
time.sleep(.1)
|
||||
try:
|
||||
sock.recv(4096)
|
||||
except TimeoutError:
|
||||
pass
|
||||
sock.sendall(command.encode("ascii") + b"\n")
|
||||
time.sleep(.2)
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
try:
|
||||
chunk = sock.recv(4096)
|
||||
except TimeoutError:
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks).decode("ascii", "replace").strip()
|
||||
|
||||
def start(self) -> dict[str, object]:
|
||||
if self.qemu is not None and self.qemu.poll() is None:
|
||||
return {"started": False, "reason": "already running"}
|
||||
try:
|
||||
self.monitor("info status")
|
||||
return {"started": False, "reason": "already running (external)"}
|
||||
except OSError:
|
||||
pass
|
||||
executable = shutil.which("qemu-system-i386.exe")
|
||||
disk = QEMU / "freedos.qcow2"
|
||||
if not executable:
|
||||
raise RuntimeError("qemu-system-i386.exe is not on PATH")
|
||||
if not disk.exists():
|
||||
raise RuntimeError(f"missing {disk}; run .qemu/setup.ps1 and .qemu/install.ps1")
|
||||
self.qemu = subprocess.Popen([executable, "-machine", "pc,accel=whpx,kernel-irqchip=off,usb=on", "-smp", "1", "-m", "64", "-drive", f"file={disk},format=qcow2,if=ide,index=0,media=disk", "-nic", "user,model=ne2k_isa", "-monitor", "tcp:127.0.0.1:4444,server=on,wait=off", "-boot", "order=c", "-display", "default"], cwd=QEMU)
|
||||
log_event(logging.INFO, "qemu started", pid=self.qemu.pid)
|
||||
return {"started": True, "pid": self.qemu.pid}
|
||||
|
||||
def stop(self) -> dict[str, object]:
|
||||
try:
|
||||
self.monitor("quit")
|
||||
log_event(logging.INFO, "qemu stop requested")
|
||||
except OSError:
|
||||
pass
|
||||
return {"stopped": True}
|
||||
|
||||
def screenshot(self) -> dict[str, object]:
|
||||
ppm, png = QEMU / "qemu-screen.ppm", QEMU / "qemu-screen.png"
|
||||
self.monitor("screendump " + str(ppm).replace("\\", "/"))
|
||||
ffmpeg = shutil.which("ffmpeg.exe")
|
||||
if not ppm.exists() or not ffmpeg:
|
||||
raise RuntimeError("screenshot failed or ffmpeg.exe is not on PATH")
|
||||
subprocess.run([ffmpeg, "-y", "-loglevel", "error", "-i", str(ppm), str(png)], check=True)
|
||||
ppm.unlink(missing_ok=True)
|
||||
log_event(logging.INFO, "screenshot", path=str(png))
|
||||
return {"path": str(png)}
|
||||
|
||||
def dispatch(self, request: dict[str, object]) -> object:
|
||||
op = request["op"]
|
||||
if op == "status":
|
||||
try:
|
||||
self.monitor("info status")
|
||||
running = True
|
||||
except OSError:
|
||||
running = False
|
||||
return {"agent_connected": self.agent_ready.is_set(), "qemu_running": running, "log": str(LOG_PATH)}
|
||||
if op == "start": return self.start()
|
||||
if op == "stop": return self.stop()
|
||||
if op == "ping": return self.ping()
|
||||
if op == "exec": return self.exec(str(request["command"]))
|
||||
if op == "put": return self.put(str(request["source"]), str(request["destination"]))
|
||||
if op == "get": return self.get(str(request["source"]), str(request["destination"]))
|
||||
if op == "screenshot": return self.screenshot()
|
||||
if op == "monitor": return {"output": self.monitor(str(request["command"]))}
|
||||
raise ValueError(f"unknown operation: {op}")
|
||||
|
||||
|
||||
def serve_pipe(host: Host) -> None:
|
||||
listener = Listener(PIPE, family="AF_PIPE")
|
||||
log_event(logging.INFO, "control pipe ready", pipe=PIPE)
|
||||
while True:
|
||||
conn = listener.accept()
|
||||
try:
|
||||
request = conn.recv()
|
||||
try:
|
||||
conn.send({"ok": True, "result": host.dispatch(request)})
|
||||
except Exception as exc:
|
||||
log_event(logging.ERROR, "control failed", error=str(exc))
|
||||
conn.send({"ok": False, "error": str(exc)})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if os.name != "nt":
|
||||
raise SystemExit("ferro-vm currently supports Windows only")
|
||||
configure_logging()
|
||||
host = Host()
|
||||
threading.Thread(target=host.accept_agents, daemon=True).start()
|
||||
serve_pipe(host)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user