Каталог официальных cloud-образов Ubuntu/Debian (x86_64 и aarch64) со скачиванием в фоне, страница /cloud-images, создание VM из cloud-образа с настройкой пользователя, пароля и SSH-ключа через cloud-init seed, клонирование VM через virt-clone и флаг шаблона с блокировкой запуска. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PKUoZwHMCHnytfswFVRUHw
163 строки
5.8 KiB
Python
163 строки
5.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Каталог cloud-образов и генерация cloud-init seed для быстрых VM."""
|
|
import json
|
|
import re
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
CLOUD_IMAGES_DIR = Path("/var/lib/virtuality/cloud-images")
|
|
|
|
# Официальные cloud-образы. Для каждого дистрибутива — обе архитектуры,
|
|
# чтобы Raspberry/Orange Pi качали arm64 без ручного выбора URL.
|
|
CATALOG: list[dict[str, str]] = [
|
|
{
|
|
"key": "ubuntu-24.04-x86_64",
|
|
"label": "Ubuntu Server 24.04 LTS",
|
|
"arch": "x86_64",
|
|
"filename": "ubuntu-24.04-x86_64.qcow2",
|
|
"url": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
|
|
"default_user": "ubuntu",
|
|
},
|
|
{
|
|
"key": "ubuntu-24.04-aarch64",
|
|
"label": "Ubuntu Server 24.04 LTS",
|
|
"arch": "aarch64",
|
|
"filename": "ubuntu-24.04-aarch64.qcow2",
|
|
"url": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img",
|
|
"default_user": "ubuntu",
|
|
},
|
|
{
|
|
"key": "ubuntu-22.04-x86_64",
|
|
"label": "Ubuntu Server 22.04 LTS",
|
|
"arch": "x86_64",
|
|
"filename": "ubuntu-22.04-x86_64.qcow2",
|
|
"url": "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img",
|
|
"default_user": "ubuntu",
|
|
},
|
|
{
|
|
"key": "ubuntu-22.04-aarch64",
|
|
"label": "Ubuntu Server 22.04 LTS",
|
|
"arch": "aarch64",
|
|
"filename": "ubuntu-22.04-aarch64.qcow2",
|
|
"url": "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img",
|
|
"default_user": "ubuntu",
|
|
},
|
|
{
|
|
"key": "debian-12-x86_64",
|
|
"label": "Debian 12 Bookworm",
|
|
"arch": "x86_64",
|
|
"filename": "debian-12-x86_64.qcow2",
|
|
"url": "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2",
|
|
"default_user": "debian",
|
|
},
|
|
{
|
|
"key": "debian-12-aarch64",
|
|
"label": "Debian 12 Bookworm",
|
|
"arch": "aarch64",
|
|
"filename": "debian-12-aarch64.qcow2",
|
|
"url": "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-arm64.qcow2",
|
|
"default_user": "debian",
|
|
},
|
|
]
|
|
|
|
|
|
def catalog_entry(key: str) -> dict[str, str] | None:
|
|
for entry in CATALOG:
|
|
if entry["key"] == key:
|
|
return entry
|
|
return None
|
|
|
|
|
|
def catalog_for_arch(host_arch: str) -> list[dict[str, Any]]:
|
|
"""Каталог с отметкой native — образы родной архитектуры хоста показываются первыми."""
|
|
native = "aarch64" if host_arch in ("aarch64", "arm64") else "x86_64"
|
|
entries = [dict(entry, native=entry["arch"] == native, downloaded=(CLOUD_IMAGES_DIR / entry["filename"]).exists()) for entry in CATALOG]
|
|
return sorted(entries, key=lambda item: (not item["native"], item["label"]))
|
|
|
|
|
|
def safe_image_filename(filename: str) -> str | None:
|
|
name = Path(filename or "").name.strip()
|
|
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.(qcow2|img)", name):
|
|
return None
|
|
return name
|
|
|
|
|
|
def image_path_by_name(name: str) -> Path | None:
|
|
safe_name = safe_image_filename(name)
|
|
if not safe_name:
|
|
return None
|
|
path = (CLOUD_IMAGES_DIR / safe_name).resolve()
|
|
if CLOUD_IMAGES_DIR.resolve() not in path.parents:
|
|
return None
|
|
return path
|
|
|
|
|
|
def list_images() -> list[dict[str, str]]:
|
|
CLOUD_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
|
files = []
|
|
for item in sorted(CLOUD_IMAGES_DIR.glob("*")):
|
|
if item.suffix.lower() not in (".qcow2", ".img") or item.name.endswith(".part"):
|
|
continue
|
|
try:
|
|
stat = item.stat()
|
|
size_mb = stat.st_size / 1024 / 1024
|
|
updated = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
|
|
except OSError:
|
|
size_mb = 0
|
|
updated = "unknown"
|
|
known = next((entry for entry in CATALOG if entry["filename"] == item.name), None)
|
|
files.append({
|
|
"name": item.name,
|
|
"path": str(item),
|
|
"size": f"{size_mb:.0f} MB",
|
|
"updated": updated,
|
|
"label": known["label"] if known else item.stem,
|
|
"arch": known["arch"] if known else ("aarch64" if "arm64" in item.name or "aarch64" in item.name else "x86_64"),
|
|
"default_user": known["default_user"] if known else "user",
|
|
})
|
|
return files
|
|
|
|
|
|
def valid_cloud_username(value: str) -> bool:
|
|
return bool(re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}", value or ""))
|
|
|
|
|
|
def valid_ssh_key(value: str) -> bool:
|
|
if not value:
|
|
return True
|
|
return bool(re.fullmatch(r"(ssh-(rsa|ed25519|dss)|ecdsa-sha2-[a-z0-9-]+) [A-Za-z0-9+/=]+( [^\r\n]{0,256})?", value.strip()))
|
|
|
|
|
|
def build_user_data(hostname: str, username: str, password: str, ssh_key: str) -> str:
|
|
lines = [
|
|
"#cloud-config",
|
|
f"hostname: {hostname}",
|
|
"manage_etc_hosts: true",
|
|
f"ssh_pwauth: {'true' if password else 'false'}",
|
|
"users:",
|
|
f" - name: {username}",
|
|
" groups: [sudo]",
|
|
" shell: /bin/bash",
|
|
' sudo: "ALL=(ALL) NOPASSWD:ALL"',
|
|
" lock_passwd: false",
|
|
]
|
|
if ssh_key:
|
|
lines.append(" ssh_authorized_keys:")
|
|
lines.append(f" - {ssh_key.strip()}")
|
|
if password:
|
|
lines += [
|
|
"chpasswd:",
|
|
" expire: false",
|
|
" users:",
|
|
f" - name: {username}",
|
|
f" password: {json.dumps(password, ensure_ascii=False)}",
|
|
" type: text",
|
|
]
|
|
lines.append("package_update: false")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def build_meta_data(name: str) -> str:
|
|
return f"instance-id: virtuality-{name}\nlocal-hostname: {name}\n"
|