#!/usr/bin/env python3 """win-ci VM 과의 통신 채널. 게스트(Windows)는 QEMU user networking 안에 있어서 호스트를 10.0.2.2 로 본다. 이 서버는 그 반대편이다. GET /ping 게스트가 네트워크 올라왔는지 확인용 GET /stage2.ps1 게스트가 실행할 설치 스크립트 (serve/ 안의 파일 그대로) GET / serve/ 안의 아무 파일 (러너 등록 토큰 등) POST /log 게스트 로그 -> guest.log 에 append POST /artifact/ 게스트 명령 출력 원본 -> results/ 에 파일로 저장 /artifact 가 필요한 이유: 게스트 검증 결과를 사람이 화면에서 읽고 옮겨 적으면 "고쳤다"를 증명할 기준선이 안 남는다. serial.log 는 VM 이 뜰 때마다 truncate 되고 로그 채널은 마지막 몇 줄만 흘린다. pytest 트레이스백처럼 전문이 필요한 출력은 반드시 이 엔드포인트로 호스트 파일에 떨어뜨린다. 127.0.0.1 만 바인딩한다. slirp 의 10.0.2.2 는 호스트 루프백으로 들어오므로 외부에 열 필요가 없다. """ import http.server import os import posixpath import socketserver import sys from datetime import datetime ROOT = "/home/claude/win-ci/serve" LOG = "/home/claude/win-ci/guest.log" RESULTS = "/home/claude/win-ci/results" PORT = 8099 # 로그 전문뿐 아니라 빌드된 포터블 exe(100MB+)도 이 통로로 회수한다. # 32MB 로 두면 배포할 exe 가 413 으로 튕긴다. MAX_ARTIFACT = 1024 * 1024 * 1024 CHUNK = 1024 * 1024 class Handler(http.server.SimpleHTTPRequestHandler): def __init__(self, *a, **kw): super().__init__(*a, directory=ROOT, **kw) def guess_type(self, path): # .ps1 은 charset 을 반드시 명시한다. 없으면 PowerShell 쪽에서 # ISO-8859-1 로 디코딩해 한글 주석이 깨지고, 깨진 바이트가 CP1252 의 # 스마트따옴표가 되어 파서가 스크립트를 통째로 삼킨다. if str(path).endswith(".ps1"): return "text/plain; charset=utf-8" return super().guess_type(path) def do_GET(self): if self.path.rstrip("/") == "/ping": body = b"pong\n" self.send_response(200) self.send_header("Content-Type", "text/plain") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) return super().do_GET() def do_POST(self): path = self.path.rstrip("/") if path == "/log": self._recv_log() elif path.startswith("/artifact/"): self._recv_artifact(path[len("/artifact/") :]) else: self.send_error(404) def _recv_log(self): n = int(self.headers.get("Content-Length") or 0) data = self.rfile.read(n).decode("utf-8", "replace").strip() with open(LOG, "a", encoding="utf-8") as f: f.write(f"{datetime.now():%Y-%m-%d %H:%M:%S} {data}\n") self.send_response(204) self.end_headers() def _recv_artifact(self, name): # 게스트가 주는 이름을 그대로 경로에 쓰면 안 된다. 파일명만 취한다. name = posixpath.basename(name.replace("\\", "/")) if not name or name.startswith("."): self.send_error(400, "bad artifact name") return n = int(self.headers.get("Content-Length") or 0) if n > MAX_ARTIFACT: self.send_error(413, "artifact too large") return os.makedirs(RESULTS, exist_ok=True) # 통째로 read() 하지 않는다. exe 는 100MB 가 넘어서 메모리에 다 올리면 # 봇 cgroup 의 메모리 상한을 건드린다. 조각으로 받아 바로 디스크에 쓴다. # # 그리고 임시 이름으로 받아 끝난 뒤에 옮긴다. 전송 중에 끊기면 반쪽짜리 # 파일이 정상 결과물처럼 남아 그대로 릴리스에 올라갈 수 있다. final = os.path.join(RESULTS, name) tmp = final + ".part" left = n with open(tmp, "wb") as f: while left > 0: block = self.rfile.read(min(CHUNK, left)) if not block: break f.write(block) left -= len(block) if left: os.remove(tmp) self.send_error(400, "artifact truncated") return os.replace(tmp, final) print(f"artifact {name} {n} bytes", flush=True) self.send_response(204) self.end_headers() def log_message(self, fmt, *args): # 접근 로그는 stdout 으로만. journald 가 받는다. sys.stdout.write(f"{self.address_string()} {fmt % args}\n") sys.stdout.flush() class Server(socketserver.ThreadingTCPServer): allow_reuse_address = True daemon_threads = True if __name__ == "__main__": os.makedirs(ROOT, exist_ok=True) os.makedirs(RESULTS, exist_ok=True) with Server(("127.0.0.1", PORT), Handler) as httpd: print(f"win-ci control server on 127.0.0.1:{PORT}, root={ROOT}", flush=True) httpd.serve_forever()