- Introduced a new method `installed_kernel` in `ImageStore` to handle kernel verification and checksum validation. - Updated `Installer` to include additional packages: `nftables` and `conntrack`. - Refined `LifecycleService` to improve error handling and state management during VM creation, starting, stopping, and deletion. - Enhanced port management with new methods for setting and syncing ports, including validation for port rules. - Improved error handling in `StateStore` to raise specific `NotFoundError` and `ConflictError` exceptions. - Updated network management to ensure proper TAP device handling and firewall integration. - Enhanced API responses in `vms.py` to include policy status for better client-side handling. - Added validation for VM names and ports in `validation.py` to enforce stricter rules and prevent conflicts.
702 lines
26 KiB
Python
702 lines
26 KiB
Python
"""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_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(
|
|
"--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="existing guest account to configure; 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")
|
|
|
|
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),
|
|
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" 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 == "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} {'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} {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,
|
|
"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
|