"""The sole public interface of this project: the local uvm CLI.""" from __future__ import annotations import argparse import codecs import json import os import sys import time from collections.abc import Sequence from ipaddress import IPv4Address from pathlib import Path from .app import Application, build_application from .domain import PortRule, VmRecord, VmSpec from .errors import UvmError, ValidationError from .firecracker.config import vcpu_count from .validation import ( parse_cpu, parse_name, parse_password, parse_ports, parse_ram, parse_storage, parse_timeout, parse_username, ) class _CommandParser(argparse.ArgumentParser): def __init__(self, *args, ssh_command: bool = False, **kwargs) -> None: super().__init__(*args, allow_abbrev=False, **kwargs) self._ssh_command = ssh_command def parse_known_args(self, args=None, namespace=None): if not self._ssh_command: return super().parse_known_args(args, namespace) values = list(sys.argv[1:] if args is None else args) remote_command = [] if "--" in values: separator = values.index("--") values, remote_command = values[:separator], values[separator + 1 :] # REMAINDER would swallow SSH options placed after the VM identifier. parsed, unknown = super().parse_known_args(values, namespace) parsed.remote_command = remote_command return parsed, unknown def build_parser(application: Application) -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog=application.settings.app_name, description="Tiny Firecracker microVM CLI for Ubuntu.", allow_abbrev=False, ) parser.add_argument( "--serve", action="store_true", help="launch the FastAPI management server instead of running a CLI command", ) parser.add_argument( "--host", default="127.0.0.1", help="server bind address used with --serve; defaults to 127.0.0.1", ) parser.add_argument( "--port", type=_port_number, default=8000, help="server bind port used with --serve; defaults to 8000", ) subcommands = parser.add_subparsers(dest="command", parser_class=_CommandParser) install = subcommands.add_parser( "install", description=( "Set UVM_FIRECRACKER_SHA256, UVM_KERNEL_SHA256, and UVM_ROOTFS_SHA256 " "to trusted digests. UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 is an explicit " "development-only opt-out." ), help="install dependencies and assets; SHA-256 values are required by default", ) install.add_argument( "--force", action="store_true", help="redownload Firecracker, kernel, and rootfs even when local assets already exist", ) create = subcommands.add_parser("create", help="create and boot a microVM detached") create.add_argument("--name", help="unique local VM name") create.add_argument( "--cpu", default=str(application.settings.default_vcpu), help="CPU capacity, e.g. 1, 2, 0.5 (fractional CPU is advisory)", ) create.add_argument( "--ram", default=str(application.settings.default_ram_mib), help="RAM, e.g. 512, 1G, 512M", ) create.add_argument( "--storage", default=f"{application.settings.default_storage_mib}M", help="private disk capacity, e.g. 30G, 50G, 1T; minimum 30 GiB (bare numbers use GiB)", ) create.add_argument( "--guest-ip", "--host-ip", dest="host_ip", help="guest IP to assign; otherwise uvm allocates one", ) create.add_argument( "--username", default=application.settings.default_ssh_user, help="guest login account; created if missing (defaults to root)", ) credentials = create.add_mutually_exclusive_group() credentials.add_argument( "--password", default="root", help="guest login password; defaults to root", ) credentials.add_argument( "--password-stdin", action="store_true", help="read the guest password from one line of stdin", ) port_help = ( "private guest PORT[:TYPE], e.g. 22,80:HTTP,443:HTTPS; " "repeat or separate with commas (TYPE: HTTP, HTTPS, TCP; default: TCP); " "no host-public ports, forwarding, or proxy" ) create.add_argument("--ports", action="append", default=[], help=port_help) create.add_argument( "--no-default-ports", action="store_true", help="do not add the default 22:TCP rule" ) create.add_argument("--json", action="store_true") resize = subcommands.add_parser("resize", help="stop, resize CPU/RAM/storage, and boot the retained VM") resize.add_argument("vm", help="VM ID, name, or guest IP") resize.add_argument("--cpu", help="new CPU capacity; omitted values are retained") resize.add_argument("--ram", help="new RAM, e.g. 512M or 4G") resize.add_argument("--storage", help="new private disk capacity, e.g. 30G or 50G; minimum 30 GiB") resize.add_argument("--timeout", help="seconds to wait after SIGTERM before SIGKILL (normally 5s)") resize.add_argument("--json", action="store_true") listing = subcommands.add_parser("list", help="list VMs") listing.add_argument("--status", action="append", default=[], help="observed status (repeatable)") output = listing.add_mutually_exclusive_group() output.add_argument("--json", action="store_true") output.add_argument("-q", "--quiet", action="store_true", help="print only VM IDs") inspect = subcommands.add_parser("inspect", help="inspect one VM") inspect.add_argument("vm", help="VM ID, name, or guest IP") inspect.add_argument("--json", action="store_true") for action in ("start", "stop", "restart", "delete"): command = subcommands.add_parser( action, aliases=["destroy"] if action == "delete" else [], help=f"{action} selected VMs", ) targets = command.add_mutually_exclusive_group(required=True) targets.add_argument( "vms", nargs="*", default=[], metavar="VM", help="VM IDs, names, or guest IPs" ) targets.add_argument("--all", dest="all_vms", action="store_true", help="select all VMs") command.add_argument("--yes", "-y", action="store_true", help="skip confirmation") command.add_argument("--dry-run", action="store_true", help="show targets without changes") command.add_argument("--json", action="store_true") if action != "start": command.add_argument( "--timeout", help=( "seconds to wait after SIGTERM before SIGKILL " "(defaults to the configured timeout, normally 5s); " "does not coordinate guest shutdown" ), ) ssh = subcommands.add_parser( "ssh", ssh_command=True, help="SSH into a VM", usage="%(prog)s [options] VM [-- COMMAND ...]", epilog="Append a remote command after a literal --, e.g. ssh VM -- uname -a.", ) ssh.add_argument("vm", help="VM ID, name, or guest IP") ssh.add_argument("--user", help="override the username stored for the VM") ssh.add_argument("--key") ssh.add_argument("--port", type=_port_number, default=22, help="guest SSH port (default: 22)") ssh.add_argument( "--insecure-host-key", action="store_true", help="disable SSH host-key verification for this connection", ) logs = subcommands.add_parser("logs", help="read a VM's local Firecracker log") logs.add_argument("vm", help="VM ID, name, or guest IP") logs.add_argument("--tail", type=_line_count, default=100, metavar="N") logs.add_argument("-f", "--follow", action="store_true") ports = subcommands.add_parser( "ports", help="manage enforced private-IP guest ports (not public ports)", description=( "Manage private-IP TCP allowlists. HTTP/HTTPS are TCP labels, not proxies " "or TLS configuration. No host-public ports or NAT rules are created." ), ) port_commands = ports.add_subparsers(dest="ports_command", required=True) for action in ("list", "open", "close", "set", "sync"): command = port_commands.add_parser(action, help=f"{action} private-IP port rules") command.add_argument("--json", action="store_true") if action in {"list", "sync"}: targets = command.add_mutually_exclusive_group(required=True) targets.add_argument( "vms" if action == "sync" else "vm", nargs="*" if action == "sync" else "?", default=[] if action == "sync" else None, metavar="VM", help="VM ID, name, or guest IP", ) targets.add_argument("--all", dest="all_vms", action="store_true") else: command.add_argument("vm", help="VM ID, name, or guest IP") if action == "open": command.add_argument("--ports", action="append", required=True, help=port_help) else: selection = command.add_mutually_exclusive_group(required=True) selection.add_argument("--ports", action="append", default=[], help=port_help) if action == "close": selection.add_argument( "--all", dest="all_ports", action="store_true", help="close all ports of this VM" ) else: selection.add_argument("--none", action="store_true", help="deny all inbound ports") if action != "list": command.add_argument("--dry-run", action="store_true", help="show changes without applying") if action in {"close", "set", "sync"}: command.add_argument("--yes", "-y", action="store_true", help="skip confirmation") doctor = subcommands.add_parser("doctor", help="check local host readiness and policy health") doctor.add_argument("--json", action="store_true") return parser def main(argv: Sequence[str] | None = None) -> int: try: application = build_application(emit=lambda message: print(message, file=sys.stderr)) parser = build_parser(application) args = parser.parse_args(argv) if args.serve: if args.command is not None: parser.error("--serve cannot be combined with a CLI command") return _run_server(application, args) if args.command is None: parser.error("a command is required unless --serve is used") return _run_command(application, args) except ValidationError as error: print(f"uvm: error: {error}", file=sys.stderr) return 2 except UvmError as error: print(f"uvm: error: {error}", file=sys.stderr) return error.exit_code except OSError as error: print(f"uvm: error: {error}", file=sys.stderr) return 1 except KeyboardInterrupt: print("uvm: interrupted", file=sys.stderr) return 130 def _run_server(application: Application, args: argparse.Namespace) -> int: from .server import run_server run_server(application, host=args.host, port=args.port) return 0 def _run_command(application: Application, args: argparse.Namespace) -> int: if args.command == "install": firecracker, assets = application.installer.install(force_assets=args.force) application.state_store.initialize() print() print("uvm installed.") print(f" Firecracker: {firecracker}") print(f" Kernel: {assets.kernel}") print(f" Rootfs: {assets.rootfs}") print() print("Next:") if application.settings.allow_unverified_downloads: print(" Keep UVM_ALLOW_UNVERIFIED_DOWNLOADS=1 for later create commands.") print(" Run your UVM command with: create --cpu 1 --ram 512") return 0 if args.command == "create": guest_ip = _parse_guest_ip(args.host_ip) ports = parse_ports(args.ports) if not args.no_default_ports and not any(rule.port == 22 for rule in ports): ports = tuple(sorted((*ports, PortRule(22)), key=lambda rule: rule.port)) password = args.password if args.password_stdin: password = sys.stdin.readline(131).removesuffix("\n").removesuffix("\r") vm = application.lifecycle.create( VmSpec( cpu=parse_cpu(args.cpu), ram_mib=parse_ram(args.ram), storage_mib=parse_storage(args.storage), guest_ip=guest_ip, username=parse_username(args.username), password=parse_password(password), name=parse_name(args.name) if args.name is not None else None, ports=ports, ) ) if vm.cpu < 1.0: print( f"NOTE: requested {vm.cpu} CPU. Firecracker uses {vcpu_count(vm.cpu)} vCPU; " "fractional CPU enforcement is not yet applied.", file=sys.stderr, ) if password == "root": print( "WARNING: the guest is using the default password 'root'. Change it promptly.", file=sys.stderr, ) if args.json: _print_json(_vm_output( vm, policy_status="unmanaged" if vm.ports is None else "enforced" )) else: print(f"VM created: {vm.id}") if vm.name is not None: print(f" Name: {vm.name}") print(f" IP: {vm.guest_ip}") print(f" RAM: {vm.ram_mib} MiB") print(f" Storage: {vm.storage_mib} MiB") print(f" CPU: {vm.cpu}") print(f" TAP: {vm.tap}") print(f" Username: {vm.username}") print(f" Private ports: {_format_ports(vm.ports)}") if any(rule.port == 22 for rule in vm.ports or ()): print(f" SSH: ssh {vm.username}@{vm.guest_ip}") return 0 if args.command == "resize": if args.cpu is None and args.ram is None and args.storage is None: raise ValidationError("resize requires at least one of --cpu, --ram, or --storage") vm = application.lifecycle.resize( args.vm, cpu=parse_cpu(args.cpu) if args.cpu is not None else None, ram_mib=parse_ram(args.ram) if args.ram is not None else None, storage_mib=parse_storage(args.storage) if args.storage is not None else None, timeout=parse_timeout(args.timeout) if args.timeout is not None else None, ) if args.json: _print_json(_vm_output(vm, policy_status="enforced")) else: print(f"VM resized and started: {vm.id}") print(f" CPU: {vm.cpu}") print(f" RAM: {vm.ram_mib} MiB") print(f" Storage: {vm.storage_mib} MiB") print(f" IP: {vm.guest_ip}") print(f" Username: {vm.username}") return 0 if args.command == "list": vms = [ listed for listed in application.lifecycle.list_vms() if not args.status or listed.observed_status in args.status ] if args.json: _print_json([ _vm_output(listed.vm, listed.observed_status, listed.policy_status) for listed in vms ]) return 0 if args.quiet: for listed in vms: print(listed.vm.id) return 0 if not vms: print("No VMs.") return 0 print( f"{'ID':<37} {'NAME':<20} {'IP':<16} {'USER':<16} {'CPU':<7} " f"{'RAM':<8} {'DISK(MiB)':<12} {'STATUS':<12} {'POLICY':<12} PID" ) for listed in vms: vm = listed.vm pid = vm.pid if vm.pid is not None else "" print( f"{vm.id:<37} {vm.name or '-':<20} {vm.guest_ip:<16} {vm.username:<16} {vm.cpu:<7} " f"{vm.ram_mib:<8} {vm.storage_mib or '-':<12} {listed.observed_status:<12} {listed.policy_status:<12} {pid}" ) return 0 if args.command == "inspect": listed = application.lifecycle.inspect(args.vm) data = _vm_output(listed.vm, listed.observed_status, listed.policy_status) if args.json: _print_json(data) else: for key, value in data.items(): if key == "ports": value = _format_ports(listed.vm.ports) print(f"{key}: {value if value is not None else '-'}") return 0 if args.command in {"start", "stop", "restart", "delete", "destroy"} or ( args.command == "ports" and args.ports_command == "sync" ): action = "ports-sync" if args.command == "ports" else args.command if action == "destroy": action = "delete" timeout = None if action in {"stop", "restart", "delete"}: timeout = parse_timeout( args.timeout if args.timeout is not None else str(application.settings.terminate_timeout_s) ) targets = application.lifecycle.resolve_targets(args.vms, all_vms=args.all_vms) vm_ids = tuple(vm.id for vm in targets) if args.dry_run: _print_preview(action, targets, as_json=args.json) return 0 if not targets: return _print_results([], as_json=args.json) if action == "delete" or args.all_vms: if not _confirm(f"{action} VMs: {', '.join(vm_ids)}?", yes=args.yes): if args.json: _print_json([]) return 0 results = application.lifecycle.run_many(action, vm_ids, timeout=timeout) return _print_results(results, as_json=args.json) if args.command == "ssh": user = parse_username(args.user) if args.user is not None else None vm = application.lifecycle.find_for_ssh(args.vm) command = ["ssh", "-p", str(args.port)] if args.key: command.extend(("-i", args.key)) if args.insecure_host_key: command.extend( ( "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", ) ) else: command.extend( ( "-o", f"HostKeyAlias=uvm-{vm.id}", "-o", "StrictHostKeyChecking=accept-new", ) ) command.append(f"{user or vm.username}@{vm.guest_ip}") command.extend(args.remote_command) os.execvp(command[0], command) return 0 if args.command == "logs": _show_logs(application.lifecycle.log_path(args.vm), args.tail, follow=args.follow) return 0 if args.command == "ports": mode = args.ports_command if mode == "list": listed_vms = ( application.lifecycle.list_vms() if args.all_vms else [application.lifecycle.inspect(args.vm)] ) if args.json: _print_json([ _vm_output(listed.vm, listed.observed_status, listed.policy_status) for listed in listed_vms ]) elif not listed_vms: print("No VMs.") else: print(f"{'ID':<37} {'NAME':<20} {'IP':<16} {'POLICY':<12} PRIVATE-IP PORTS") for listed in listed_vms: vm = listed.vm print( f"{vm.id:<37} {vm.name or '-':<20} {vm.guest_ip:<16} " f"{listed.policy_status:<12} {_format_ports(vm.ports)}" ) return 0 requested = parse_ports(args.ports) listed = application.lifecycle.inspect(args.vm) vm = listed.vm vm_id = vm.id before = vm.ports if mode in {"open", "close"} and before is None: raise UvmError( f"{vm_id} has an unmanaged port policy; use ports set on a stopped VM first" ) service_mode = mode if mode == "close" and args.all_ports: service_mode = "set" requested = () if service_mode == "set": after = requested elif service_mode == "open": merged = {rule.port: rule for rule in before} for rule in requested: merged.setdefault(rule.port, rule) after = tuple(merged[port] for port in sorted(merged)) else: removed = {rule.port for rule in requested} after = tuple(rule for rule in before if rule.port not in removed) if args.dry_run: _print_preview( f"ports-{mode}", [vm], as_json=args.json, changes={"ports": {"before": _ports_output(before), "after": _ports_output(after)}}, ) return 0 if ( before == after == () and vm.operation is None and (vm.status, listed.observed_status, listed.policy_status) in { ("running", "running", "enforced"), ("stopped", "stopped", "inactive"), } ): if args.json: _print_json(_vm_output(vm, listed.observed_status, listed.policy_status)) else: print(f"{vm_id}: no port changes.") return 0 removes_ssh = ( any(rule.port == 22 for rule in before or ()) and not any(rule.port == 22 for rule in after) ) if service_mode == "set" or removes_ssh: if not _confirm( f"{mode} private-IP ports for {vm_id}: " f"{_format_ports(before)} -> {_format_ports(after)}?", yes=args.yes, ): if args.json: _print_json([]) return 0 updated = application.lifecycle.set_ports(vm_id, requested, mode=service_mode) if args.json: _print_json(_vm_output( updated, policy_status="enforced" if updated.status == "running" else "inactive" )) else: print(f"{updated.id} private-IP ports: {_format_ports(updated.ports)}") return 0 if args.command == "doctor": checks = [ {"name": check["name"], "ok": check["ok"], "message": check["message"]} for check in application.lifecycle.diagnostics() ] if args.json: _print_json(checks) else: for check in checks: print(f"{'OK' if check['ok'] else 'FAIL'} {check['name']}: {check['message']}") return 0 if all(check["ok"] for check in checks) else 1 raise UvmError(f"unsupported command: {args.command}") def _vm_output( vm: VmRecord, observed_status: str | None = None, policy_status: str = "unknown" ) -> dict: # This is an output whitelist, not the persisted record (which contains credentials). return { "id": vm.id, "name": vm.name, "cpu": vm.cpu, "ram_mib": vm.ram_mib, "storage_mib": vm.storage_mib, "pending_resize": vm.pending_resize.to_dict() if vm.pending_resize is not None else None, "guest_ip": vm.guest_ip, "gateway": vm.gateway, "tap": vm.tap, "mac": vm.mac, "username": vm.username, "status": vm.status, "observed_status": observed_status if observed_status is not None else vm.status, "policy_status": policy_status, "ports": _ports_output(vm.ports), "pid": vm.pid, "disk": vm.disk, "disk_ready": vm.disk_ready, "socket": vm.socket, "config": vm.config, "log": vm.log, "operation": vm.operation, "boot_id": vm.boot_id, "created_at": vm.created_at, "updated_at": vm.updated_at, "last_error": vm.last_error, } def _ports_output(ports: tuple[PortRule, ...] | None) -> list[dict] | None: return None if ports is None else [rule.to_dict() for rule in ports] def _format_ports(ports: tuple[PortRule, ...] | None) -> str: if ports is None: return "unmanaged" return ",".join(f"{rule.port}:{rule.protocol}" for rule in ports) or "(none)" def _print_json(value: object) -> None: print(json.dumps(value, indent=2)) def _print_preview( action: str, targets: Sequence[VmRecord], *, as_json: bool, changes: dict | None = None ) -> None: if as_json: data = {"dry_run": True, "action": action, "targets": [_vm_output(vm) for vm in targets]} if changes is not None: data["changes"] = changes _print_json(data) else: print(f"Dry run: {action}") if not targets: print("No VMs.") for vm in targets: print(f" {vm.id} {vm.name or '-'} {vm.guest_ip}") if changes is not None: for field, change in changes.items(): print(f" {field}: {json.dumps(change['before'])} -> {json.dumps(change['after'])}") def _confirm(message: str, *, yes: bool) -> bool: if yes: return True if not sys.stdin.isatty(): raise UvmError("confirmation requires a TTY; use --yes or preview with --dry-run") print(f"{message} [y/N] ", end="", file=sys.stderr, flush=True) if sys.stdin.readline().strip().lower() in {"y", "yes"}: return True print("Cancelled.", file=sys.stderr) return False def _print_results(results: Sequence, *, as_json: bool) -> int: if as_json: _print_json([ {"id": result.id, "action": result.action, "outcome": result.outcome, "error": result.error} for result in results ]) elif not results: print("No VMs.") else: for result in results: detail = f": {result.error}" if result.error else "" print(f"{result.id} {result.action}: {result.outcome}{detail}") if any(result.outcome == "interrupted" for result in results): return 130 return 1 if any(result.outcome == "failed" for result in results) else 0 def _show_logs(path: Path, tail: int, *, follow: bool) -> None: with path.open("rb") as stream: end = stream.seek(0, os.SEEK_END) position = end remaining = tail # Locate the starting line backwards without loading or retaining the whole log. while position > 0 and remaining: size = min(position, 65536) position -= size stream.seek(position) block = stream.read(size) if position + size == end and block.endswith(b"\n"): block = block[:-1] newlines = block.count(b"\n") if newlines >= remaining: offset = len(block) for _ in range(remaining): offset = block.rfind(b"\n", 0, offset) position += offset + 1 break remaining -= newlines stream.seek(position) decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") while stream.tell() < end: block = stream.read(min(65536, end - stream.tell())) if not block: break sys.stdout.write(decoder.decode(block)) if not follow: sys.stdout.write(decoder.decode(b"", final=True)) return sys.stdout.flush() while True: block = stream.read(65536) if block: sys.stdout.write(decoder.decode(block)) sys.stdout.flush() else: if os.fstat(stream.fileno()).st_size < stream.tell(): stream.seek(0) decoder.reset() time.sleep(0.2) def _line_count(value: str) -> int: try: count = int(value) except ValueError as error: raise argparse.ArgumentTypeError("tail must be a nonnegative integer") from error if count < 0: raise argparse.ArgumentTypeError("tail must be a nonnegative integer") return count def _parse_guest_ip(value: str | None) -> IPv4Address | None: if value is None: return None try: return IPv4Address(value) except ValueError as error: raise ValidationError(f"invalid guest IP: {value}") from error def _port_number(value: str) -> int: try: port = int(value) except ValueError as error: raise argparse.ArgumentTypeError("port must be an integer") from error if not 1 <= port <= 65535: raise argparse.ArgumentTypeError("port must be between 1 and 65535") return port