Ctrl K
ComunidadGuías…

scripts/ci-qa-vm-pause.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Real KVM pause/resume QA in a private network namespace and temporary API.

Run as root on a disposable Linux/KVM test host. Installed Firecrab services,
images, network namespaces, and workload units are preserved.
"""
import argparse
import base64
import hashlib
import json
import os
from pathlib import Path
import re
import select
import shutil
import socket
import struct
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
import uuid


def run(*args):
    return subprocess.check_output(args, text=True, stderr=subprocess.STDOUT).strip()


def until(check, seconds=60):
    deadline = time.monotonic() + seconds
    while time.monotonic() < deadline:
        result = check()
        if result:
            return result
        time.sleep(0.1)
    raise AssertionError("timed out waiting for QA condition")


class Console:
    def __init__(self, port, vm):
        self.socket = socket.create_connection(("127.0.0.1", port), timeout=5)
        key = base64.b64encode(os.urandom(16)).decode()
        self.socket.sendall((f"GET /ws/vms/{vm}/console HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode())
        header = b""
        while not header.endswith(b"\r\n\r\n"):
            header += self.socket.recv(1)
        assert header.startswith(b"HTTP/1.1 101"), header
        accept = base64.b64encode(hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest())
        assert accept in header
        self.text = ""

    def send(self, text):
        data = text.encode()
        mask = os.urandom(4)
        size = bytes([0x80 | len(data)]) if len(data) < 126 else bytes([0x80 | 126]) + struct.pack("!H", len(data))
        self.socket.sendall(b"\x81" + size + mask + bytes(value ^ mask[i % 4] for i, value in enumerate(data)))

    def exact(self, count):
        data = b""
        while len(data) < count:
            chunk = self.socket.recv(count - len(data))
            if not chunk:
                raise EOFError("console closed")
            data += chunk
        return data

    def read(self, seconds=0.2):
        deadline = time.monotonic() + seconds
        while time.monotonic() < deadline:
            if not select.select([self.socket], [], [], max(0, deadline - time.monotonic()))[0]:
                break
            first, size = self.exact(2)
            size &= 127
            if size == 126:
                size = struct.unpack("!H", self.exact(2))[0]
            elif size == 127:
                size = struct.unpack("!Q", self.exact(8))[0]
            data = self.exact(size)
            if first & 15 in (1, 2):
                self.text += data.decode(errors="replace")
            elif first & 15 == 8:
                raise EOFError("console closed")
        return re.findall(r"FC442:([a-f0-9-]+):(\d+):([a-f0-9-]+):(\d+)", self.text)

    def sample(self):
        values = self.read()
        return values[-1] if values else None


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--api-binary", type=Path, required=True)
    parser.add_argument("--helper-binary", type=Path, required=True)
    parser.add_argument("--image-root", type=Path, required=True)
    parser.add_argument("--template", required=True, help="Installed image with a serial root shell and sshd")
    parser.add_argument("--report", type=Path, required=True)
    parser.add_argument("--inside", type=Path, help=argparse.SUPPRESS)
    args = parser.parse_args()
    assert os.geteuid() == 0 and Path("/dev/kvm").is_char_device(), "requires root and KVM"
    if args.inside:
        exercise(args)
        return
    name = "fc442-" + uuid.uuid4().hex[:10]
    root = Path(tempfile.mkdtemp(prefix="q", dir="/opt"))
    vm_id = None
    namespace_created = False
    dropin = None
    report = args.report.resolve()
    before = run("systemctl", "list-units", "--type=service", "--no-legend", "firecrab-vm-*")
    try:
        for source, target in [(args.api_binary, "firecrab-api"), (args.helper_binary, "firecrab-helper")]:
            shutil.copy2(source, root / target)
        (root / "run").mkdir()
        images = root / "images"
        images.mkdir()
        entries = json.loads((args.image_root / ".templates.json").read_text())
        entry = next(item for item in entries if item["alias"] == args.template)
        for field in ("kernel", "initrd", "rootfs"):
            if entry.get(field):
                relative = Path(entry[field])
                assert not relative.is_absolute() and ".." not in relative.parts
                destination = images / relative
                destination.parent.mkdir(parents=True, exist_ok=True)
                shutil.copy2(args.image_root / relative, destination)
        (images / ".templates.json").write_text(json.dumps([entry]))
        run("ip", "netns", "add", name)
        namespace_created = True
        run("ip", "-n", name, "link", "set", "lo", "up")
        # A private dummy uplink gives the helper an uplink identity without host traffic.
        run("ip", "-n", name, "link", "add", "qa-uplink", "type", "dummy")
        run("ip", "-n", name, "addr", "add", "192.0.2.1/24", "dev", "qa-uplink")
        run("ip", "-n", name, "link", "set", "qa-uplink", "up")
        run("ip", "-n", name, "route", "add", "default", "dev", "qa-uplink")
        command = ["ip", "netns", "exec", name, "python3", str(Path(__file__).resolve()), "--api-binary", str(root / "firecrab-api"), "--helper-binary", str(root / "firecrab-helper"), "--image-root", str(images), "--template", args.template, "--report", str(report), "--inside", str(root)]
        subprocess.run(command, check=True)
    finally:
        marker = root / "vm-id"
        if marker.exists():
            vm_id = uuid.UUID(marker.read_text())
            unit = f"firecrab-vm-{vm_id.hex}.service"
            subprocess.run(["systemctl", "stop", unit], check=False, capture_output=True)
            subprocess.run(["systemctl", "reset-failed", unit], check=False, capture_output=True)
            dropin = Path("/run/systemd/system") / (unit + ".d")
            shutil.rmtree(dropin, ignore_errors=True)
            subprocess.run(["systemctl", "daemon-reload"], check=False, capture_output=True)
        if namespace_created:
            pids = subprocess.run(["ip", "netns", "pids", name], text=True, capture_output=True).stdout.split()
            for pid in pids:
                try:
                    os.kill(int(pid), 15)
                except ProcessLookupError:
                    pass
            time.sleep(0.2)
            subprocess.run(["ip", "netns", "delete", name], check=False, capture_output=True)
        storage_marker = root / "storage-path"
        if storage_marker.exists():
            shutil.rmtree(storage_marker.read_text(), ignore_errors=True)
        shutil.rmtree(root)
        after = run("systemctl", "list-units", "--type=service", "--no-legend", "firecrab-vm-*")
        assert before == after, "pre-existing VM units changed during isolated QA"


def exercise(args):
    root = args.inside
    os.chdir(root)
    storage = Path(tempfile.mkdtemp(prefix="q", dir="/tmp"))
    (root / "storage-path").write_text(str(storage))
    env = {**os.environ, "FIRECRAB_STORAGE_ROOTS": f"qa={storage}", "FIRECRAB_NET_HELPER_SOCK": str(root / "helper.sock"), "FIRECRAB_IMAGE_ROOT": str(args.image_root), "FIRECRAB_BIND_ADDR": "127.0.0.1:19442"}
    helper_log = (root / "helper.log").open("w")
    api_log = (root / "api.log").open("w")
    # The guest readiness probe resolves example.com. Answer it locally so
    # the isolated test needs neither an upstream DNS server nor Internet.
    (root / "hosts").write_text(Path("/etc/hosts").read_text() + "\n192.0.2.42 example.com\n")
    # Keep DHCP runtime files and the DNS fixture private as well as network state.
    helper = subprocess.Popen(["unshare", "--mount", "--propagation", "private", "sh", "-c", 'set -eu; mount --bind "$1/run" /run/firecrab; mount --bind "$1/hosts" /etc/hosts; exec "$1/firecrab-helper"', "qa", str(root)], env=env, stdout=helper_log, stderr=subprocess.STDOUT)
    until(lambda: (root / "helper.sock").exists())
    api = None
    opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
    port = 19442
    def request(path, method="GET", body=None, timeout=10, raw=False):
        data = json.dumps(body).encode() if body is not None else (b"" if method == "POST" else None)
        with opener.open(urllib.request.Request(f"http://127.0.0.1:{port}" + path, data=data, method=method, headers={"Content-Type": "application/json"}), timeout=timeout) as response:
            payload = response.read()
            return payload if raw else (json.loads(payload) if payload else None)
    def start_api():
        process = subprocess.Popen([str(args.api_binary)], env=env, stdout=api_log, stderr=subprocess.STDOUT)
        def ready():
            assert process.poll() is None, (root / "api.log").read_text()
            try:
                return request("/api/vms") is not None
            except (OSError, urllib.error.URLError):
                return False
        until(ready)
        return process
    evidence = {"architecture": run("uname", "-m"), "firecracker": run("firecracker", "--version"), "checks": []}
    def passed(check):
        evidence["checks"].append(check)
        print("PASS " + check, flush=True)
    try:
        api = start_api()
        network = request("/api/micro-networks", "POST", {"name":"qa-pause", "subnetCidr":"172.218.42.0/24", "internetEnabled":False, "uplink":"qa-uplink"})
        vm = request("/api/vms", "POST", {"name":"qa-pause", "template":args.template, "cpu":1, "ram":128, "diskGb":1, "storageRoot":"qa", "microNetworkId":network["id"]})
        vm_id = vm["id"]
        (root / "vm-id").write_text(vm_id)
        unit = "firecrab-vm-" + uuid.UUID(vm_id).hex + ".service"
        dropin = Path("/run/systemd/system") / (unit + ".d")
        dropin.mkdir()
        ns = run("ip", "netns", "identify", str(os.getpid()))
        (dropin / "qa.conf").write_text(f"[Service]\nNetworkNamespacePath=/run/netns/{ns}\n")
        run("systemctl", "daemon-reload")
        request(f"/api/vms/{vm_id}/start", "POST")
        print("WAIT guest boot and local DNS readiness", flush=True)
        def running():
            result = request(f"/api/vms/{vm_id}")
            assert result["state"] != "error", request(f"/api/vms/{vm_id}/log")
            return result if result["state"] == "running" else None
        vm = until(running, 180)
        pid = run("systemctl", "show", "-p", "MainPID", "--value", unit)
        children = Path(f"/proc/{pid}/task/{pid}/children").read_text().strip()
        original_identity = (vm["ipv4"], vm["mac"], children)
        def http():
            try:
                with opener.open(f'http://{vm["ipv4"]}:80/', timeout=3) as response:
                    return response.status == 200
            except (OSError, urllib.error.URLError):
                return False
        console = Console(port, vm_id)
        console.send("\n")
        def prompt():
            console.read()
            if "login:" in console.text and "# " not in console.text:
                console.send("root\n")
            return "# " in console.text
        until(prompt, 30)
        console.send("B=/etc/firecrab/busybox; $B mkdir -p /tmp/fc442-www; printf ok >/tmp/fc442-www/index.html; $B httpd -p 80 -h /tmp/fc442-www >/dev/null 2>&1 || true\n")
        until(http)
        passed("guest HTTP before pause")
        key = root / "operator.key"
        key.write_bytes(request(f"/api/vms/{vm_id}/ssh-key", raw=True))
        key.chmod(0o600)
        hosts = root / "known_hosts"
        hosts.write_text(vm["ipv4"] + " " + request(f"/api/vms/{vm_id}/ssh-host-key")["publicKey"].strip() + "\n")
        console.send("/etc/firecrab/services.d/sshd >/tmp/fc442-sshd.log 2>&1 &\n")
        def ssh():
            result = subprocess.run(["ssh", "-F", "/dev/null", "-i", str(key), "-o", "IdentitiesOnly=yes", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", f"UserKnownHostsFile={hosts}", "-o", "ConnectTimeout=3", "root@" + vm["ipv4"], "/etc/firecrab/busybox cat /proc/sys/kernel/random/boot_id"], text=True, capture_output=True, timeout=10)
            return result.stdout.strip() if result.returncode == 0 else None
        ssh_boot = until(ssh)
        passed("new authenticated SSH connection before pause")
        console.send("B=/etc/firecrab/busybox; token=$($B cat /proc/sys/kernel/random/uuid); boot=$($B cat /proc/sys/kernel/random/boot_id); n=0; while :; do printf 'FC442:%s:%s:%s:%s\\n' \"$token\" \"$$\" \"$boot\" \"$n\"; n=$((n+1)); $B sleep 1; done\n")
        sample = until(console.sample)
        for cycle in range(3):
            assert request(f"/api/vms/{vm_id}/pause", "POST")["state"] == "paused"
            frozen = console.read(0.5)[-1]
            assert console.read(2)[-1] == frozen
            assert not http()
            assert request("/api/micromanager/activity")["busy"]
            passed(f"cycle {cycle + 1}: counter frozen and Sleepy inhibited")
            if cycle == 1:
                console.socket.close()
                api.terminate()
                api.wait(timeout=10)
                api = start_api()
                assert request(f"/api/vms/{vm_id}")["state"] == "paused"
                console = Console(port, vm_id)
                assert console.read(1)[-1] == frozen
                passed("API restart retained Paused without guest progress")
            resumed = request(f"/api/vms/{vm_id}/resume", "POST")
            assert resumed["state"] == "running"
            assert (resumed["ipv4"], resumed["mac"], Path(f"/proc/{pid}/task/{pid}/children").read_text().strip()) == original_identity
            later = until(lambda: (value if (value := console.sample()) and int(value[3]) > int(frozen[3]) else None))
            assert later[:3] == sample[:3], (sample, later)
            assert http()
            assert ssh() == ssh_boot == later[2]
            passed(f"cycle {cycle + 1}: RAM token, guest PID, boot ID, VMM PID, IP/MAC, HTTP, and new SSH preserved")
        assert request(f"/api/vms/{vm_id}/pause", "POST")["state"] == "paused"
        assert request(f"/api/vms/{vm_id}/stop", "POST")["state"] == "stopped"
        passed("Stop from Paused")
        request(f"/api/vms/{vm_id}", "DELETE")
        request(f'/api/micro-networks/{network["id"]}', "DELETE")
        passed("VM and network cleanup")
        args.report.write_text(json.dumps(evidence, indent=2) + "\n")
    except BaseException:
        print((root / "api.log").read_text(), flush=True)
        print((root / "helper.log").read_text(), flush=True)
        raise
    finally:
        if api:
            api.terminate()
            api.wait(timeout=10)
        helper.terminate()
        helper.wait(timeout=10)


if __name__ == "__main__":
    main()