commit 87e76877ca3d1679fe6f3660cc89ef7cc1ce9f65 Author: iceBear67 Date: Fri Jun 5 16:42:56 2026 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..323a02a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.reasonix +*.asc \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/BUILD.sh b/BUILD.sh new file mode 100755 index 0000000..dc2622f --- /dev/null +++ b/BUILD.sh @@ -0,0 +1,32 @@ +#!/bin/sh + +hasunset=0 + +if [[ "$CLOUD_CONFIG_REPO" -eq "" ]]; then + echo "CLOUD_CONFIG_REPO is not set." + hasunset=1 +fi + +if [[ "$CLOUD_CONFIG_REVISION" -eq "" ]]; then + echo "CLOUD_CONFIG_REVISION is not set." + hasunset=1 +fi + +if [[ "$CLOUD_GATEWAY_ADDRESS" -eq "" ]]; then + echo "CLOUD_GATEWAY_ADDRESS is not set." + hasunset=1 +fi + +if [[ "$hasunset" -ne 0 ]]; then + echo "" + echo "Default values will be used for unset environments:" + echo "" + grep -E "^ENV" ./Dockerfile +fi + +echo "Additional arguments: $@" +echo "Continue?" +read + +sudo docker build . $@ + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..93c6fab --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM alpine:latest AS rootfs-builder + +ENV CLOUD_CONFIG_REPO=https://git.sfclub.cc/cloud/bearnet +ENV CLOUD_CONFIG_REVISION=wish +ENV CLOUD_GATEWAY_ADDRESS=10.0.0.119 + +COPY /image /kitchen +COPY ./scripts/orchestrate.py /kitchen/overlay/daemon/orchestrate.py +COPY bot-gpg-key.asc /kitchen/overlay/root/gpg-key.asc +RUN sed -i "s/_REPO_/$CLOUD_CONFIG_REPO/g" /kitchen/overlay/daemon/update.sh \ + sed -i "s/_REVISION_/$CLOUD_CONFIG_REVISION/g" /kitchen/overlay/daemon/update.sh \ + sed -i "s/GATEWAY_ADDRESS/$CLOUD_GATEWAY_ADDRESS/g" /kitchen/overlay/etc/dhcp/dhclient.conf +RUN apk update && apk add alpine-make-vm-image \ + cd /kitchen && make build extract-kernel && mkdir /image && cp ./$IMAGE_NAME* /image + +FROM alpine:latest AS hypervisor +ADD ./scripts/setup-hypervisor.sh /setup.sh +# Download cloud hypervisor +RUN apk update && apk add bash curl jq tini && sh /setup.sh && rm /setup.sh && mkdir /app +COPY --from=rootfs-builder /image /image +COPY ./scripts/entrypoint.sh /entrypoint.sh + +ENTRYPOINT ["/usr/bin/tini", "/entrypoint.sh"] + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/host/virtiofsd.service b/host/virtiofsd.service new file mode 100644 index 0000000..988fe1c --- /dev/null +++ b/host/virtiofsd.service @@ -0,0 +1,14 @@ +[Unit] +Description=VirtioFS daemon + +[Service] +Type=simple +ExecStart=/usr/libexec/virtiofsd \ + --socket-path=/run//virtiofs.sock \ + --shared-dir=/srv/vm1-data + +Restart=always +RestartSec=1 + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/image/.gitignore b/image/.gitignore new file mode 100644 index 0000000..3e7a52e --- /dev/null +++ b/image/.gitignore @@ -0,0 +1,26 @@ +# Build artifacts +*.qcow2 +*.raw +*.img + +# Extracted kernel / initramfs +*-vmlinuz +*-initramfs +*-boot/ + +# Downloaded tool +alpine-make-vm-image + +# Generated GPG keys (secrets) +overlay/root/gpg-key.asc +overlay/root/gpg-pubkey.asc + +# Build marker +.gpg-done + +# Editor / OS +.DS_Store +*.swp +*.swo +*~ +.reasonix/** diff --git a/image/Makefile b/image/Makefile new file mode 100644 index 0000000..21895eb --- /dev/null +++ b/image/Makefile @@ -0,0 +1,72 @@ +# Alpine VM Image Builder +# ------------------------ +# Builds a bootable Alpine Linux disk image for Cloud Hypervisor direct boot. +# +# Usage: +# make build — generate GPG key + build qcow2 image +# make build-raw — build raw image (for Cloud Hypervisor direct boot) +# make extract-kernel — extract kernel + initramfs from image +# make clean — remove build artifacts +# +# Configurable variables (override on command line): +# IMAGE_SIZE Size of the disk image (default: 4G) +# ALPINE_BRANCH Alpine release branch (default: latest-stable) +# KERNEL_FLAVOR Kernel variant (default: virt) +# ALPINE_MIRROR APK mirror (default: http://dl-cdn.alpinelinux.org/alpine) + +SHELL := /bin/sh + +# --- configurable ---------------------------------------------------- +IMAGE_NAME = alpine-vm +IMAGE_SIZE ?= 4G +IMAGE_FORMAT = raw +ALPINE_BRANCH ?= latest-stable +KERNEL_FLAVOR ?= virt +#INITFS_FEATURES ?= kms scsi virtio + +IMAGE_FILE = vm.$(IMAGE_FORMAT) +SCRIPT_DIR = $(dir $(realpath $(lastword $(MAKEFILE_LIST)))) +OVERLAY_DIR = $(SCRIPT_DIR)/overlay +CONFIGURE_SH = $(SCRIPT_DIR)/configure.sh + +build: + @echo ">>> Building $(IMAGE_FILE) ..." + @test -f $(OVERLAY_DIR)/root/gpg-key.asc || { \ + echo "ERROR: GPG key not found. Generate key first" >&2; \ + exit 1; \ + } + $(MAKE_VM_IMAGE) \ + --branch $(ALPINE_BRANCH) \ + --image-format $(IMAGE_FORMAT) \ + --image-size $(IMAGE_SIZE) \ + --kernel-flavor $(KERNEL_FLAVOR) \ + --serial-console \ + --fs-skel-dir $(OVERLAY_DIR) \ + --fs-skel-chown root:root \ + --script-chroot \ + --packages "python3 py3-yaml py3-pydantic git curl gnupg docker docker-cli-buildx docker-cli-compose" \ + $(IMAGE_FILE) \ + $(CONFIGURE_SH) + @echo ">>> Image built: $(IMAGE_FILE)" + @ls -lh $(IMAGE_FILE) + +# Raw image (best for Cloud Hypervisor) +build-raw: + $(MAKE) build IMAGE_FORMAT=raw + +# --- kernel extraction ----------------------------------------------- + +extract-kernel: $(IMAGE_FILE) + @echo ">>> Extracting kernel and initramfs from $(IMAGE_FILE) ..." + @which guestmount >/dev/null 2>&1 || { \ + echo "ERROR: guestmount (libguestfs) required. Install: apk add libguestfs" >&2; \ + exit 1; \ + } + @mkdir -p $(IMAGE_NAME)-boot + guestmount -a $(IMAGE_FILE) -m /dev/sda --ro $(IMAGE_NAME)-boot + cp $(IMAGE_NAME)-boot/boot/vmlinuz-$(KERNEL_FLAVOR) vmlinuz + cp $(IMAGE_NAME)-boot/boot/initramfs-$(KERNEL_FLAVOR) initramfs + guestunmount $(IMAGE_NAME)-boot + rmdir $(IMAGE_NAME)-boot + +.PHONY: build build-raw build-no-gpg extract-kernel gpg-key gpg-fingerprint diff --git a/image/configure.sh b/image/configure.sh new file mode 100755 index 0000000..6db77cc --- /dev/null +++ b/image/configure.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# configure.sh — runs inside chroot after base install and overlay copy. +# Invoked via alpine-make-vm-image --script-chroot. +set -eu + +_step_counter=0 +step() { + _step_counter=$(( _step_counter + 1 )) + printf '\n\033[1;36m%d) %s\033[0m\n' $_step_counter "$@" >&2 +} + +uname -a + +step 'Set timezone to UTC' +setup-timezone -z UTC + +step 'Set up networking' +# The interfaces file was placed by --fs-skel-dir; link init.d scripts. +ln -sf networking /etc/init.d/net.lo +ln -sf networking /etc/init.d/net.eth0 + +step 'Adjust rc.conf' +sed -Ei \ + -e 's/^[# ](rc_depend_strict)=.*/\1=NO/' \ + -e 's/^[# ](rc_logger)=.*/\1=YES/' \ + -e 's/^[# ](unicode)=.*/\1=YES/' \ + /etc/rc.conf + +step 'Enable base services' +rc-update add net.lo boot +rc-update add net.eth0 default +rc-update add acpid default +rc-update add docker default +rc-update add cronie default + +step 'Import GPG key for root' +GPG_KEY_FILE="/root/gpg-key.asc" +if [ -f "$GPG_KEY_FILE" ]; then + echo "Found GPG key file: $GPG_KEY_FILE" + gpg --batch --import "$GPG_KEY_FILE" + # Mark the imported key as ultimately trusted (non-interactive) + fingerprint=$(gpg --batch --with-colons --fingerprint \ + | grep '^fpr:' | head -1 | cut -d: -f10) + if [ -n "$fingerprint" ]; then + echo "$fingerprint:6:" | gpg --batch --import-ownertrust + echo " * GPG key trusted: $fingerprint" + fi + rm -f "$GPG_KEY_FILE" +else + echo "WARNING: GPG key file not found at $GPG_KEY_FILE — skipping import" >&2 +fi + +step 'Clean up APK cache' +rm -rf /var/cache/apk/* || true + +echo '' +echo '=== Configure script completed ===' diff --git a/image/overlay/daemon/update.sh b/image/overlay/daemon/update.sh new file mode 100755 index 0000000..54bdc40 --- /dev/null +++ b/image/overlay/daemon/update.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +mkdir -p /app +cd /app +rm -rf ./template ./snapshot +mkdir /app/template /app/snapshot + +set -euo pipefail + +git clone -b _REVISION_ _REPO_ template + +python3 /daemon/orchestrate.py \ + --root /app/template \ + --network cloud \ + --volume-parent /data/volumes \ + --snapshot-root /app/snapshot diff --git a/image/overlay/data/PLACEHOLDER b/image/overlay/data/PLACEHOLDER new file mode 100644 index 0000000..a496f9b --- /dev/null +++ b/image/overlay/data/PLACEHOLDER @@ -0,0 +1,2 @@ +# This directory is the virtiofs mount point for host-shared data. +# Do not store critical data here before the virtiofs share is mounted. diff --git a/image/overlay/etc/dhcp/dhclient.conf b/image/overlay/etc/dhcp/dhclient.conf new file mode 100644 index 0000000..d30dfb4 --- /dev/null +++ b/image/overlay/etc/dhcp/dhclient.conf @@ -0,0 +1,3 @@ +interface "eth0" { + supersede routers GATEWAY_ADDRESS; +} \ No newline at end of file diff --git a/image/overlay/etc/docker/daemon.json b/image/overlay/etc/docker/daemon.json new file mode 100644 index 0000000..e36f762 --- /dev/null +++ b/image/overlay/etc/docker/daemon.json @@ -0,0 +1,9 @@ +{ + "data-root": "/data/docker", + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + }, + "storage-driver": "overlay2" +} diff --git a/image/overlay/etc/fstab b/image/overlay/etc/fstab new file mode 100644 index 0000000..a9c645f --- /dev/null +++ b/image/overlay/etc/fstab @@ -0,0 +1,5 @@ +# /etc/fstab: static file system information +# +# +/dev/root / ext4 rw,noatime 0 1 +data /data virtiofs rw,noatime,_netdev 0 0 diff --git a/image/overlay/etc/network/interfaces b/image/overlay/etc/network/interfaces new file mode 100644 index 0000000..de21e34 --- /dev/null +++ b/image/overlay/etc/network/interfaces @@ -0,0 +1,7 @@ +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet manual + post-up dhclient -v eth0 + pre-down dhclient -x eth0 \ No newline at end of file diff --git a/image/overlay/var/spool/cron/crontabs/root b/image/overlay/var/spool/cron/crontabs/root new file mode 100644 index 0000000..461d9ce --- /dev/null +++ b/image/overlay/var/spool/cron/crontabs/root @@ -0,0 +1,2 @@ +# min hour day month weekday command +*/15 * * * * /usr/bin/sh /daemon/update.sh \ No newline at end of file diff --git a/image/packages b/image/packages new file mode 100644 index 0000000..eabf4f1 --- /dev/null +++ b/image/packages @@ -0,0 +1,10 @@ +python3 +py3-yaml +py3-pydantic +git +curl +gnupg +docker +docker-cli-buildx +docker-cli-compose +cronie \ No newline at end of file diff --git a/image/repositories b/image/repositories new file mode 100644 index 0000000..3a81b6a --- /dev/null +++ b/image/repositories @@ -0,0 +1,2 @@ +https://dl-cdn.alpinelinux.org/alpine/latest-stable/main +https://dl-cdn.alpinelinux.org/alpine/latest-stable/community diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..640bcf9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "bearnet" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [] diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100644 index 0000000..76421f0 --- /dev/null +++ b/scripts/entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -euo pipefail + +/usr/bin/cloud-hypervisor \ + --kernel /image/vmlinuz --initramfs /image/initramfs --disk path=/image/vm.raw \ + --cmdline "root=/dev/vda rootfstype=ext4 modules=ext4a rw console=hvc0" \ + --cpus boot=${CPU_COUNT:-4} \ + --memory size=${MEMORY:-4G},shared=on \ + $@ diff --git a/scripts/gen-gpg-key.sh b/scripts/gen-gpg-key.sh new file mode 100755 index 0000000..7d9b4ca --- /dev/null +++ b/scripts/gen-gpg-key.sh @@ -0,0 +1,65 @@ +#!/bin/sh +# gen-gpg-key.sh — Generate a passwordless GPG key and export to overlay/. +# Run this BEFORE alpine-make-vm-image on the build host. +set -eu + +KEY_FILE="./bot-gpg-key.asc" +KEY_NAME=${KEY_NAME:-"VM Builder"} +KEY_EMAIL=${KEY_EMAIL:-"builder@localhost"} +echo "Gnerating GPG key on behalf of $KEY_NAME ($KEY_EMAIL)" +if ! command -v gpg >/dev/null 2>&1; then + echo "ERROR: gpg (gnupg) is required on the build host" >&2 + exit 1 +fi + +# Use an isolated temporary GNUPGHOME so the host keyring is never touched. +GNUPGHOME="$(mktemp -d /tmp/gpg-tmphome.XXXXXX)" +export GNUPGHOME +cleanup_home() { rm -rf "$GNUPGHOME"; } +trap cleanup_home EXIT + +# Ensure the target directory exists +mkdir -p "$(dirname "$KEY_FILE")" + +# Only generate if the key file doesn't already exist +if [ -f "$KEY_FILE" ]; then + echo "GPG key already exists: $KEY_FILE" + echo "Remove it first if you want to regenerate." + exit 0 +fi + +echo "=== Generating passwordless RSA 4096 GPG key ===" + +# Create a batch specification for unattended key generation. +# %no-protection means no passphrase. +BATCH_FILE="$(mktemp /tmp/gpg-batch.XXXXXX)" +cat > "$BATCH_FILE" <<'GPGBATCH' +%echo Generating RSA 4096 key... +Key-Type: RSA +Key-Length: 4096 +Subkey-Type: RSA +Subkey-Length: 4096 +Name-Real: KEY_NAME +Name-Email: KEY_EMAIL +Expire-Date: 0 +%no-protection +%commit +%echo Done +GPGBATCH + +sed -i "s/KEY_NAME/$KEY_NAME/g" "$BATCH_FILE" +sed -i "s/KEY_EMAIL/$KEY_EMAIL/g" "$BATCH_FILE" + +gpg --batch --yes --pinentry-mode loopback --generate-key "$BATCH_FILE" +rm -f "$BATCH_FILE" + +echo "" +echo "=== Exporting secret key to $KEY_FILE ===" + +gpg --batch --yes --pinentry-mode loopback --export-secret-keys --armor "$KEY_EMAIL" > "$KEY_FILE" + +# Also export just the public key for reference +gpg --batch --yes --pinentry-mode loopback --export --armor "$KEY_EMAIL" > "./bot-gpg-pubkey.asc" + +# Print fingerprint +gpg --batch --fingerprint "$KEY_EMAIL" || true diff --git a/scripts/orchestrate.py b/scripts/orchestrate.py new file mode 100644 index 0000000..fa629f3 --- /dev/null +++ b/scripts/orchestrate.py @@ -0,0 +1,277 @@ +import argparse +import os +import re +import shutil +import subprocess +import sys + +from pydantic import BaseModel, ValidationError + +import yaml + + +class BuildSpec(BaseModel): + context: str + dockerfile: str + args: dict[str, str] = {} + + +class Service(BaseModel): + build: str | BuildSpec | None = None + image: str | None = None + volumes: list[str] | None = None + command: str | None = None + depends_on: list[str] | None = None + entrypoint: list[str] | None = None + env_file: str | None = None + environment: dict[str, str] | None = None + + +class ComposeSpec(BaseModel): + services: dict[str, Service] + + +parser = argparse.ArgumentParser() +parser.add_argument("--dry", action="store_true", default=False) +parser.add_argument("--root", default=".", type=str) +parser.add_argument("--snapshot-root", default="snapshot", type=str) +parser.add_argument("--network", default="cloud", type=str) +parser.add_argument("--volume-parent", type=str, required=True) + +args = parser.parse_args() +dry_run = args.dry +root = args.root +snapshot_root=args.snapshot_root +network = args.network +vol_parent = args.volume_parent + +has_error = False + + +def err(msg: str): + global has_error + has_error = True + print(msg) + + +userNamePattern = r"^[a-z0-9]+$" + +users: list[tuple[int, str]] = [] +for userDir in os.listdir(f"{root}/users"): + spl = userDir.split("-", 1) + if len(spl) != 2: + err(f"ERR: Valid priority isn't set for userDir {spl}") + continue + elif not spl[0].isdigit(): + err(f"ERR: UserDir {spl} has an invalid priority.") + continue + elif not re.match(userNamePattern, spl[1]): + err(f"ERR: Name {spl[1]} doesn't match {userNamePattern}") + continue + users.append((int(spl[0]), spl[1])) + +users.sort(key=lambda x: x[0]) + +serviceNamePattern = r"^[a-z0-9]+$" + +def validate_compose_spec(spec: ComposeSpec, workdir: str): + invalid_services = [serviceName for serviceName, _ in spec.services.items() if + not re.match(serviceNamePattern, serviceName)] + if len(invalid_services) > 0: + err(f"ERR: Invalid service names: {', '.join([x for x in invalid_services])}") + for invalid_service in invalid_services: + spec.services.pop(invalid_service) + for key, svc in spec.services.items(): + validate_service_spec(svc, workdir, key) + + depended_services = [(svc.depends_on or []) for _name, svc in spec.services.items()] + depended_services = [item for sublist in depended_services for item in sublist] + for serviceName in depended_services: + if serviceName not in spec.services: + err(f"ERR: Service {serviceName} is depended on but not defined in the compose spec") + + + +# Fields recognized by our Pydantic models — anything else in the YAML +# is silently dropped by Pydantic and could interfere with our injection. +_TOP_LEVEL_KNOWN = {"services"} +_SERVICE_KNOWN = {"build", "image", "volumes", "command", "depends_on", + "entrypoint", "env_file", "environment"} +_BUILD_KNOWN = {"context", "dockerfile", "args"} + + +def detect_injected_fields(path_: str, data: dict): + """Warn about YAML keys not captured by the Pydantic model — these would + be silently dropped and could interfere with network injection.""" + if not isinstance(data, dict): + return + extra_toplevel = set(data.keys()) - _TOP_LEVEL_KNOWN + if extra_toplevel: + err(f"WARN: {path_} has unexpected top-level keys (ignored by model): " + f"{', '.join(sorted(extra_toplevel))}. " + f"These may interfere with injected networks/config.") + services = data.get("services") + if not isinstance(services, dict): + return + for svc_name, svc_data in services.items(): + if not isinstance(svc_data, dict): + continue + extra_svc = set(svc_data.keys()) - _SERVICE_KNOWN + if extra_svc: + err(f"WARN: {path_} service '{svc_name}' has unexpected keys " + f"(ignored by model): {', '.join(sorted(extra_svc))}") + build = svc_data.get("build") + if isinstance(build, dict): + extra_build = set(build.keys()) - _BUILD_KNOWN + if extra_build: + err(f"WARN: {path_} service '{svc_name}' build block has " + f"unexpected keys: {', '.join(sorted(extra_build))}") + + +for prio, name in users: + workdir = f"{root}/users/{prio}-{name}/" + path = f"{workdir}compose.yml" + with open(path) as csf: + data = yaml.safe_load(csf) + detect_injected_fields(path, data) + try: + spec = ComposeSpec(**data) + except ValidationError as e: + err(f"Cannot validate compose spec at {path}") + print(e.errors()) + continue + validate_compose_spec(spec, workdir) + + +def validate_env_file(path_: str) -> bool: + try: + with open(path_, "r") as f: + for idx, raw in enumerate(f, start=1): + line = raw.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[len("export "):].strip() + if "=" not in line: + err(f"ERR: Invalid line {idx} in env file {path_}: no '=' found") + return False + key, _ = line.split("=", 1) + key = key.strip() + if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key): + err(f"ERR: Invalid env var name '{key}' in {path_} at line {idx}") + return False + return True + except Exception as e: + err(f"ERR: Cannot read env_file {path_}: {e}") + return False + + +def validate_service_spec(serv: Service, workdir: str, name: str): + if serv.image is None and serv.build is None: + err(f"ERR: Service {name} doesn't have an image or build spec") + if isinstance(serv.build, str) and not os.path.exists(f"{workdir}/{serv.build}/Dockerfile"): + err(f"ERR: Service {name}: Dockerfile doesn't exist at {workdir}/{serv.build}/Dockerfile") + for volume in (serv.volumes or []): + spl = volume.split(":") + if (len(spl) != 2 and len(spl) != 3) or \ + not os.path.normpath(spl[0]).startswith(vol_parent) or \ + (len(spl) == 3 and spl[2] != "ro"): + err(f"ERR: Invalid volume spec {volume} in service {name}") + if serv.env_file and not os.path.exists(f"{workdir}/{serv.env_file}") \ + and not os.path.exists(f"{workdir}/{serv.env_file}.gpg"): + err(f"ERR: Service {name}: env_file {serv.env_file} (or .gpg) doesn't exist in {workdir}") + elif serv.env_file and os.path.exists(f"{workdir}/{serv.env_file}"): + validate_env_file(f"{workdir}/{serv.env_file}") + if isinstance(serv.build, BuildSpec) and not os.path.exists(f"{workdir}/{serv.build.dockerfile}"): + err(f"ERR: Service {name}: Dockerfile {serv.build.dockerfile} doesn't exist in {workdir}") + +if has_error: + print("Errors found during validation. Exiting.") + sys.exit(1) + +# ── Snapshot: copy userdir + inject networks into compose.yml ────────── +print(f"Snapshot target: {snapshot_root}") + +snapshot_dirs: list[tuple[str, str]] = [] # (dst_dir, name) for compose-up later + +for prio, name in users: + src_dir = f"{root}/users/{prio}-{name}" + dst_dir = f"{snapshot_root}/{prio}-{name}" + + if dry_run: + print(f" [dry] would snapshot {src_dir} -> {dst_dir}") + print(f" [dry] inject networks: .networks.{network}.external=true, " + f".networks.{name}.name={name}, services[].networks=[{network}, {name}]") + snapshot_dirs.append((dst_dir, name)) + continue + + print(f" Snapshotting {src_dir} -> {dst_dir}") + try: + if os.path.exists(dst_dir): + shutil.rmtree(dst_dir) + os.makedirs(dst_dir, exist_ok=True) + + for item in os.listdir(src_dir): + src_item = os.path.join(src_dir, item) + dst_item = os.path.join(dst_dir, item) + if item == "compose.yml": + # Rewrite with injected network configuration + with open(src_item) as f: + compose_data = yaml.safe_load(f) + compose_data.setdefault("networks", {}) + compose_data["networks"][network] = {"external": True} + compose_data["networks"][name] = {"name": name} + for svc_name, svc in (compose_data.get("services") or {}).items(): + svc["networks"] = [network, name] + print(f" Injected networks into service '{svc_name}': " + f"[{network}, {name}]") + with open(dst_item, "w") as f: + yaml.dump(compose_data, f, default_flow_style=False, sort_keys=False) + print(f" Wrote {dst_item} (networks injected)") + elif item.endswith(".gpg"): + # Decrypt .gpg file → output without .gpg suffix + plain_name = item[:-4] # strip ".gpg" + dst_plain = os.path.join(dst_dir, plain_name) + print(f" Decrypting {item} -> {plain_name}") + subprocess.run( + ["gpg", "--decrypt", "--batch", "--output", dst_plain, src_item], + check=True, + ) + elif os.path.isdir(src_item): + shutil.copytree(src_item, dst_item) + else: + shutil.copy2(src_item, dst_item) + + snapshot_dirs.append((dst_dir, name)) + print(f" Done snapshotting {name}") + except Exception as e: + err(f"ERR: Snapshot failed for {name}: {e}") + +# ── Compose up in each snapshot ──────────────────────────────────────── +if has_error: + print("Snapshot errors detected — skipping compose up.") + sys.exit(1) + +if dry_run: + for dst_dir, name in snapshot_dirs: + print(f" [dry] would run: docker compose -f {dst_dir}/compose.yml " + f"up --remove-orphans --build --detach") +else: + for dst_dir, name in snapshot_dirs: + print(f" Starting services for {name} in {dst_dir} …") + try: + subprocess.run( + ["docker", "compose", "-f", f"{dst_dir}/compose.yml", + "up", "--remove-orphans", "--build", "--detach"], + cwd=dst_dir, check=True, + ) + print(f" ✓ {name} started") + except subprocess.CalledProcessError as e: + err(f"ERR: docker compose up failed for {name}: {e}") + +if has_error: + print("Errors during compose up. Exiting.") + sys.exit(1) + + + diff --git a/scripts/setup-hypervisor.sh b/scripts/setup-hypervisor.sh new file mode 100644 index 0000000..e7089cd --- /dev/null +++ b/scripts/setup-hypervisor.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +set -u + +echo "fetching latest version of cloud-hypervisor" +DOWNLOAD_URL=$(curl https://api.github.com/repos/cloud-hypervisor/cloud-hypervisor/releases | jq -r '.[0].assets.[] | select( .name == "cloud-hypervisor-static") | .browser_download_url') +if [ $? -ne 0 ]; then + echo "FAILED TO FETCH DOWNLOAD LINK OF CLOUD-HYPERVISOR-STATIC" + exit -1 +fi + +curl -sLo /usr/bin/cloud-hypervisor $DOWNLOAD_URL && chmod +x /usr/bin/cloud-hypervisor && cloud-hypervisor --help + +if [ $? -ne 0 ]; then + echo "FAILED TO DOWNLOAD CLOUD-HYPERVISOR or CLOUD-HYPERVISOR IS NOT EXECUTABLE. (wrong arch?)" + exit -1 +fi \ No newline at end of file