Refactor VM lifecycle management and enhance validation
- 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.
This commit is contained in:
+491
-40
@@ -3,22 +3,54 @@
|
||||
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 VmSpec
|
||||
from .domain import PortRule, VmRecord, VmSpec
|
||||
from .errors import UvmError, ValidationError
|
||||
from .firecracker.config import vcpu_count
|
||||
from .validation import parse_cpu, parse_password, parse_ram, parse_username
|
||||
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",
|
||||
@@ -36,7 +68,7 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
||||
default=8000,
|
||||
help="server bind port used with --serve; defaults to 8000",
|
||||
)
|
||||
subcommands = parser.add_subparsers(dest="command")
|
||||
subcommands = parser.add_subparsers(dest="command", parser_class=_CommandParser)
|
||||
|
||||
install = subcommands.add_parser(
|
||||
"install",
|
||||
@@ -53,7 +85,8 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
||||
help="redownload Firecracker, kernel, and rootfs even when local assets already exist",
|
||||
)
|
||||
|
||||
create = subcommands.add_parser("create", help="create and boot a microVM")
|
||||
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),
|
||||
@@ -65,6 +98,7 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
||||
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",
|
||||
@@ -74,36 +108,133 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
|
||||
default=application.settings.default_ssh_user,
|
||||
help="existing guest account to configure; defaults to root",
|
||||
)
|
||||
create.add_argument(
|
||||
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")
|
||||
|
||||
subcommands.add_parser("list", help="list VMs")
|
||||
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")
|
||||
|
||||
ssh = subcommands.add_parser("ssh", help="SSH into a VM")
|
||||
ssh.add_argument("vm", help="VM ID or guest IP")
|
||||
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",
|
||||
)
|
||||
|
||||
stop = subcommands.add_parser("stop", help="stop a VM")
|
||||
stop.add_argument("vm")
|
||||
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")
|
||||
|
||||
destroy = subcommands.add_parser("destroy", help="stop and remove a VM")
|
||||
destroy.add_argument("vm")
|
||||
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()
|
||||
application = build_application(emit=lambda message: print(message, file=sys.stderr))
|
||||
parser = build_parser(application)
|
||||
args = parser.parse_args(argv)
|
||||
if args.serve:
|
||||
@@ -113,9 +244,15 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
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
|
||||
@@ -146,54 +283,125 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
|
||||
|
||||
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(args.password),
|
||||
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;"
|
||||
f"NOTE: requested {vm.cpu} CPU. Firecracker uses {vcpu_count(vm.cpu)} vCPU; "
|
||||
"fractional CPU enforcement is not yet applied.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(" fractional CPU enforcement is not yet applied by this MVP.")
|
||||
print()
|
||||
print(f"VM created: {vm.id}")
|
||||
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" SSH: ssh {vm.username}@{vm.guest_ip}")
|
||||
print()
|
||||
if vm.password == "root":
|
||||
print("WARNING: the guest is using the default password 'root'. Change it promptly.")
|
||||
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 = application.lifecycle.list_vms()
|
||||
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} {'IP':<16} {'USER':<16} {'CPU':<7} "
|
||||
f"{'RAM':<8} {'STATUS':<10} PID"
|
||||
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.guest_ip:<16} {vm.username:<16} {vm.cpu:<7} "
|
||||
f"{vm.ram_mib:<8} {listed.observed_status:<10} {pid}"
|
||||
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"]
|
||||
command = ["ssh", "-p", str(args.port)]
|
||||
if args.key:
|
||||
command.extend(("-i", args.key))
|
||||
if args.insecure_host_key:
|
||||
@@ -214,23 +422,266 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
)
|
||||
)
|
||||
command.append(f"{args.user or vm.username}@{vm.guest_ip}")
|
||||
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 == "stop":
|
||||
vm = application.lifecycle.stop(args.vm)
|
||||
print(f"Stopped {vm.id}")
|
||||
if args.command == "logs":
|
||||
_show_logs(application.lifecycle.log_path(args.vm), args.tail, follow=args.follow)
|
||||
return 0
|
||||
|
||||
if args.command == "destroy":
|
||||
vm = application.lifecycle.destroy(args.vm)
|
||||
print(f"Destroyed {vm.id}")
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user