Files
wehub-resource-sync 91e75e620b
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:19 +08:00

674 lines
21 KiB
Python

"""Image management commands for CUA CLI.
Handles both cloud images (push/pull to CUA cloud) and local images
(create, clone, shell, info).
"""
import argparse
from datetime import datetime
from pathlib import Path
from typing import Any
import aiohttp
from cua_cli.api.client import CloudAPIClient, calculate_file_hash, format_bytes
from cua_cli.utils.async_utils import run_async
from cua_cli.utils.output import (
print_error,
print_info,
print_json,
print_success,
print_table,
)
# Default local image storage
LOCAL_IMAGES_DIR = Path.home() / ".local" / "share" / "cua" / "images"
# Default part size for multi-part upload: 100MB
DEFAULT_PART_SIZE = 100 * 1024 * 1024
def register_parser(subparsers: argparse._SubParsersAction) -> None:
"""Register the image command and subcommands."""
for cmd_name in ("image", "img"):
img_parser = subparsers.add_parser(
cmd_name,
help="Image management commands",
description="Manage VM images (cloud and local)",
)
img_subparsers = img_parser.add_subparsers(
dest="image_command",
help="Image command",
)
# list command
list_parser = img_subparsers.add_parser(
"list",
aliases=["ls"],
help="List images",
)
list_parser.add_argument(
"--json",
action="store_true",
help="Output as JSON",
)
list_parser.add_argument(
"--local",
action="store_true",
help="List local images only",
)
list_parser.add_argument(
"--cloud",
action="store_true",
help="List cloud images only (default)",
)
list_parser.add_argument(
"--platform",
type=str,
help="Filter local images by platform",
)
list_parser.add_argument(
"--format",
choices=["table", "json"],
default="table",
help="Output format for local images",
)
# push command
push_parser = img_subparsers.add_parser(
"push",
help="Push image to cloud storage",
)
push_parser.add_argument(
"name",
help="Image name",
)
push_parser.add_argument(
"--file",
"-f",
type=str,
help="Path to image file (default: ~/.local/share/cua/images/<name>/data.img)",
)
push_parser.add_argument(
"--tag",
type=str,
default="latest",
help="Image tag (default: latest)",
)
push_parser.add_argument(
"--type",
type=str,
default="qcow2",
choices=["qcow2", "raw", "vmdk"],
help="Image type (default: qcow2)",
)
# pull command
pull_parser = img_subparsers.add_parser(
"pull",
help="Pull image from cloud storage",
)
pull_parser.add_argument(
"name",
help="Image name",
)
pull_parser.add_argument(
"--tag",
type=str,
default="latest",
help="Image tag (default: latest)",
)
pull_parser.add_argument(
"--output",
"-o",
type=str,
help="Output file path",
)
# delete command
delete_parser = img_subparsers.add_parser(
"delete",
help="Delete an image",
)
delete_parser.add_argument(
"name",
help="Image name",
)
delete_parser.add_argument(
"--tag",
type=str,
default="latest",
help="Image tag (default: latest, for cloud images)",
)
delete_parser.add_argument(
"--force",
action="store_true",
help="Skip confirmation",
)
delete_parser.add_argument(
"--local",
action="store_true",
help="Delete a local image instead of cloud",
)
# create command (local)
create_parser = img_subparsers.add_parser(
"create",
help="Create a local image from platform",
)
create_parser.add_argument(
"platform",
help="Platform name (e.g., linux-docker, windows-qemu)",
)
create_parser.add_argument("--name", help="Image name (default: same as platform)")
create_parser.add_argument("--iso", help="Path to ISO file (for QEMU platforms)")
create_parser.add_argument(
"--download-iso",
action="store_true",
dest="download_iso",
help="Download Windows 11 ISO (~6GB)",
)
create_parser.add_argument(
"--docker-image", dest="docker_image", help="Override Docker image"
)
create_parser.add_argument(
"--distro",
default="ubuntu",
choices=["ubuntu", "fedora"],
help="Linux distribution",
)
create_parser.add_argument(
"--version",
default="14",
help="OS version (e.g., 14 for Android, sonoma for macOS)",
)
create_parser.add_argument("--disk", default="64G", help="Disk size (default: 64G)")
create_parser.add_argument("--memory", default="8G", help="Memory (default: 8G)")
create_parser.add_argument("--cpus", default="8", help="CPU cores (default: 8)")
create_parser.add_argument(
"--winarena-apps",
action="store_true",
dest="winarena_apps",
help="Install WinArena benchmark apps (Chrome, LibreOffice, VLC, etc.)",
)
create_parser.add_argument("--detach", "-d", action="store_true", help="Run in background")
create_parser.add_argument("--force", action="store_true", help="Force recreation")
create_parser.add_argument(
"--skip-pull",
action="store_true",
dest="skip_pull",
help="Don't pull Docker image",
)
create_parser.add_argument(
"--no-kvm",
action="store_true",
dest="no_kvm",
help="Disable KVM acceleration",
)
create_parser.add_argument(
"--vnc-port",
dest="vnc_port",
help="VNC port (default: auto-allocate from 8006)",
)
create_parser.add_argument(
"--api-port",
dest="api_port",
help="API port (default: auto-allocate from 5000)",
)
# info command (local)
info_parser = img_subparsers.add_parser(
"info",
help="Show local image details",
)
info_parser.add_argument("name", help="Image name")
# clone command (local)
clone_parser = img_subparsers.add_parser(
"clone",
help="Clone a local image",
)
clone_parser.add_argument("source", help="Source image name")
clone_parser.add_argument("target", help="Target image name")
clone_parser.add_argument("--force", action="store_true", help="Overwrite if target exists")
# shell command (local)
shell_parser = img_subparsers.add_parser(
"shell",
help="Interactive shell into image (uses overlay by default)",
)
shell_parser.add_argument("name", help="Image name")
shell_parser.add_argument(
"--writable",
action="store_true",
help="Modify golden image directly (dangerous!)",
)
shell_parser.add_argument("--detach", "-d", action="store_true", help="Run in background")
shell_parser.add_argument(
"--vnc-port",
dest="vnc_port",
help="VNC port (default: auto-allocate from 8006)",
)
shell_parser.add_argument(
"--api-port",
dest="api_port",
help="API port (default: auto-allocate from 5000)",
)
shell_parser.add_argument("--memory", default="8G", help="Memory (default: 8G)")
shell_parser.add_argument("--cpus", default="8", help="CPU cores (default: 8)")
shell_parser.add_argument(
"--no-kvm",
action="store_true",
dest="no_kvm",
help="Disable KVM acceleration",
)
def execute(args: argparse.Namespace) -> int:
"""Execute image command based on subcommand."""
from cua_cli.commands import local_image
cmd = getattr(args, "image_command", None)
if cmd in ("list", "ls"):
return cmd_list(args)
elif cmd == "push":
return cmd_push(args)
elif cmd == "pull":
return cmd_pull(args)
elif cmd == "delete":
return cmd_delete(args)
elif cmd == "create":
return local_image.cmd_create(args)
elif cmd == "info":
return local_image.cmd_info(args)
elif cmd == "clone":
return local_image.cmd_clone(args)
elif cmd == "shell":
return local_image.cmd_shell(args)
else:
print_error("Usage: cua image <command>")
print_info("Commands: list, push, pull, delete, create, info, clone, shell")
return 1
def cmd_list(args: argparse.Namespace) -> int:
"""List images."""
show_local = getattr(args, "local", False)
show_cloud = getattr(args, "cloud", False)
# If --local is set, delegate to local_image list
if show_local and not show_cloud:
from cua_cli.commands import local_image
return local_image.cmd_local_list(args)
# Default to cloud if neither specified, or if --cloud is set
if not show_local:
show_cloud = True
all_images = []
if show_cloud:
cloud_images = run_async(_list_cloud_images())
all_images.extend(cloud_images)
if show_local:
local_images = _list_local_images()
all_images.extend(local_images)
if getattr(args, "json", False):
print_json(all_images)
return 0
if not all_images:
print_info("No images found.")
return 0
columns = [
("name", "NAME"),
("type", "TYPE"),
("tag", "TAG"),
("size", "SIZE"),
("status", "STATUS"),
("created", "CREATED"),
]
print_table(all_images, columns)
return 0
async def _list_cloud_images() -> list[dict[str, Any]]:
"""List cloud images."""
try:
client = CloudAPIClient()
images = await client.list_images()
result = []
for img in images:
versions = img.get("versions", [])
if versions:
for ver in versions:
result.append(
{
"name": img.get("name", ""),
"type": img.get("image_type", ""),
"tag": ver.get("tag", ""),
"size": format_bytes(ver.get("size_bytes", 0)),
"status": ver.get("status", ""),
"created": _format_date(ver.get("created_at", "")),
"source": "cloud",
}
)
else:
result.append(
{
"name": img.get("name", ""),
"type": img.get("image_type", ""),
"tag": "-",
"size": "-",
"status": "-",
"created": _format_date(img.get("created_at", "")),
"source": "cloud",
}
)
return result
except Exception as e:
print_error(f"Failed to list cloud images: {e}")
return []
def _list_local_images() -> list[dict[str, Any]]:
"""List local images."""
result = []
if not LOCAL_IMAGES_DIR.exists():
return result
for image_dir in LOCAL_IMAGES_DIR.iterdir():
if not image_dir.is_dir():
continue
name = image_dir.name
data_file = image_dir / "data.img"
if data_file.exists():
size = format_bytes(data_file.stat().st_size)
created = datetime.fromtimestamp(data_file.stat().st_mtime).strftime("%Y-%m-%d")
else:
size = "-"
created = "-"
result.append(
{
"name": name,
"type": "local",
"tag": "latest",
"size": size,
"status": "ready" if data_file.exists() else "incomplete",
"created": created,
"source": "local",
}
)
return result
def _format_date(date_str: str) -> str:
"""Format a date string."""
if not date_str:
return "-"
try:
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
return dt.strftime("%Y-%m-%d")
except Exception:
return date_str[:10] if len(date_str) >= 10 else date_str
def cmd_push(args: argparse.Namespace) -> int:
"""Push an image to cloud storage."""
name = args.name
tag = args.tag
image_type = args.type
# Determine file path
if args.file:
file_path = Path(args.file)
else:
# Look in local images directory
file_path = LOCAL_IMAGES_DIR / name / "data.img"
if not file_path.exists():
if args.file:
print_error(f"File not found: {file_path}")
else:
print_error(f"Image not found: {name}")
print_info(f"Looked in: {file_path}")
print_info("Use --file to specify a custom path")
return 1
size_bytes = file_path.stat().st_size
print_info(f"Pushing {file_path} ({format_bytes(size_bytes)})")
return run_async(_push_image(name, tag, image_type, file_path, size_bytes))
async def _push_image(
name: str, tag: str, image_type: str, file_path: Path, size_bytes: int
) -> int:
"""Push an image using multi-part upload."""
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
# Calculate checksum
print_info("Calculating checksum...")
checksum = calculate_file_hash(file_path)
print_info(f"Checksum: {checksum}")
client = CloudAPIClient()
# Initiate upload
print_info("Initiating upload...")
status, data = await client.initiate_upload(
name=name,
tag=tag,
image_type=image_type,
size_bytes=size_bytes,
checksum_sha256=checksum,
)
if status == 401:
print_error("Unauthorized. Run 'cua auth login' again.")
return 1
if status == 409:
print_error(f"Image version already exists: {name}:{tag}")
return 1
if status not in (200, 201):
print_error(f"Failed to initiate upload: {data}")
return 1
upload_id = data.get("upload_id")
part_size = data.get("part_size", DEFAULT_PART_SIZE)
total_parts = data.get("total_parts", 1)
print_info(f"Upload session: {upload_id}")
print_info(f"Parts: {total_parts} x {format_bytes(part_size)}")
# Read file into memory (for simplicity - could stream for very large files)
file_data = file_path.read_bytes()
completed_parts = []
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
) as progress:
task = progress.add_task("Uploading...", total=total_parts)
for part_num in range(1, total_parts + 1):
# Get signed URL
status, url_data = await client.get_upload_part_url(name, upload_id, part_num)
if status != 200:
print_error(f"Failed to get upload URL for part {part_num}")
await client.abort_upload(name, upload_id)
return 1
upload_url = url_data.get("upload_url")
# Calculate part range
start = (part_num - 1) * part_size
end = min(start + part_size, size_bytes)
part_data = file_data[start:end]
# Upload part
async with aiohttp.ClientSession() as session:
async with session.put(
upload_url,
data=part_data,
headers={"Content-Type": "application/octet-stream"},
) as resp:
if resp.status not in (200, 201):
print_error(f"Failed to upload part {part_num}: {resp.status}")
await client.abort_upload(name, upload_id)
return 1
etag = resp.headers.get("ETag", "")
completed_parts.append({"part_number": part_num, "etag": etag})
progress.update(task, advance=1)
# Complete upload
print_info("Completing upload...")
status, result = await client.complete_upload(name, upload_id, completed_parts)
if status not in (200, 201):
print_error(f"Failed to complete upload: {result}")
return 1
print_success(f"Push complete: {name}:{tag}")
if isinstance(result, dict):
print_info(f"Version ID: {result.get('version_id', 'N/A')}")
print_info(f"Status: {result.get('status', 'N/A')}")
return 0
def cmd_pull(args: argparse.Namespace) -> int:
"""Pull an image from cloud storage."""
name = args.name
tag = args.tag
output_path = args.output or f"{name}-{tag}.qcow2"
print_info(f"Pulling {name}:{tag}...")
return run_async(_pull_image(name, tag, Path(output_path)))
async def _pull_image(name: str, tag: str, output_path: Path) -> int:
"""Pull an image from cloud storage."""
from rich.progress import (
BarColumn,
DownloadColumn,
Progress,
SpinnerColumn,
TextColumn,
)
client = CloudAPIClient()
# Get download URL
status, data = await client.get_download_url(name, tag)
if status == 401:
print_error("Unauthorized. Run 'cua auth login' again.")
return 1
if status == 404:
print_error(f"Image not found: {name}:{tag}")
return 1
if status != 200:
print_error(f"Failed to get download URL: {data}")
return 1
download_url = data.get("download_url")
size_bytes = data.get("size_bytes", 0)
expected_checksum = data.get("checksum_sha256", "")
print_info(f"Size: {format_bytes(size_bytes)}")
print_info(f"Downloading to {output_path}...")
# Download file
async with aiohttp.ClientSession() as session:
async with session.get(download_url) as resp:
if resp.status != 200:
print_error(f"Download failed: {resp.status}")
return 1
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
DownloadColumn(),
) as progress:
task = progress.add_task("Downloading...", total=size_bytes)
with open(output_path, "wb") as f:
async for chunk in resp.content.iter_chunked(8192):
f.write(chunk)
progress.update(task, advance=len(chunk))
# Verify checksum
if expected_checksum:
print_info("Verifying checksum...")
downloaded_checksum = calculate_file_hash(output_path)
if downloaded_checksum != expected_checksum:
print_error("Checksum mismatch! Download may be corrupted.")
print_error(f"Expected: {expected_checksum}")
print_error(f"Got: {downloaded_checksum}")
return 1
print_success(f"Checksum verified: {downloaded_checksum}")
print_success(f"Pull complete: {output_path}")
return 0
def cmd_delete(args: argparse.Namespace) -> int:
"""Delete an image."""
# If --local flag, delegate to local_image delete
if getattr(args, "local", False):
from cua_cli.commands import local_image
return local_image.cmd_local_delete(args)
# Cloud delete
name = args.name
tag = args.tag
if not args.force:
print_info(f"This will delete {name}:{tag}. Use --force to confirm.")
return 1
print_info(f"Deleting {name}:{tag}...")
return run_async(_delete_image(name, tag))
async def _delete_image(name: str, tag: str) -> int:
"""Delete an image from cloud storage."""
client = CloudAPIClient()
status, data = await client.delete_image(name, tag)
if status == 401:
print_error("Unauthorized. Run 'cua auth login' again.")
return 1
if status == 404:
print_error(f"Image not found: {name}:{tag}")
return 1
if status not in (200, 202, 204):
print_error(f"Delete failed: {data}")
return 1
print_success(f"Deleted: {name}:{tag}")
return 0