init
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.reasonix
|
||||||
|
*.asc
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.11
|
||||||
32
BUILD.sh
Executable file
32
BUILD.sh
Executable file
@@ -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 . $@
|
||||||
|
|
||||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -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"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
14
host/virtiofsd.service
Normal file
14
host/virtiofsd.service
Normal file
@@ -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
|
||||||
26
image/.gitignore
vendored
Normal file
26
image/.gitignore
vendored
Normal file
@@ -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/**
|
||||||
72
image/Makefile
Normal file
72
image/Makefile
Normal file
@@ -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
|
||||||
57
image/configure.sh
Executable file
57
image/configure.sh
Executable file
@@ -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 ==='
|
||||||
16
image/overlay/daemon/update.sh
Executable file
16
image/overlay/daemon/update.sh
Executable file
@@ -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
|
||||||
2
image/overlay/data/PLACEHOLDER
Normal file
2
image/overlay/data/PLACEHOLDER
Normal file
@@ -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.
|
||||||
3
image/overlay/etc/dhcp/dhclient.conf
Normal file
3
image/overlay/etc/dhcp/dhclient.conf
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
interface "eth0" {
|
||||||
|
supersede routers GATEWAY_ADDRESS;
|
||||||
|
}
|
||||||
9
image/overlay/etc/docker/daemon.json
Normal file
9
image/overlay/etc/docker/daemon.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"data-root": "/data/docker",
|
||||||
|
"log-driver": "json-file",
|
||||||
|
"log-opts": {
|
||||||
|
"max-size": "10m",
|
||||||
|
"max-file": "3"
|
||||||
|
},
|
||||||
|
"storage-driver": "overlay2"
|
||||||
|
}
|
||||||
5
image/overlay/etc/fstab
Normal file
5
image/overlay/etc/fstab
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# /etc/fstab: static file system information
|
||||||
|
#
|
||||||
|
# <file system> <mount point> <type> <options> <dump> <pass>
|
||||||
|
/dev/root / ext4 rw,noatime 0 1
|
||||||
|
data /data virtiofs rw,noatime,_netdev 0 0
|
||||||
7
image/overlay/etc/network/interfaces
Normal file
7
image/overlay/etc/network/interfaces
Normal file
@@ -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
|
||||||
2
image/overlay/var/spool/cron/crontabs/root
Normal file
2
image/overlay/var/spool/cron/crontabs/root
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# min hour day month weekday command
|
||||||
|
*/15 * * * * /usr/bin/sh /daemon/update.sh
|
||||||
10
image/packages
Normal file
10
image/packages
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
python3
|
||||||
|
py3-yaml
|
||||||
|
py3-pydantic
|
||||||
|
git
|
||||||
|
curl
|
||||||
|
gnupg
|
||||||
|
docker
|
||||||
|
docker-cli-buildx
|
||||||
|
docker-cli-compose
|
||||||
|
cronie
|
||||||
2
image/repositories
Normal file
2
image/repositories
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
https://dl-cdn.alpinelinux.org/alpine/latest-stable/main
|
||||||
|
https://dl-cdn.alpinelinux.org/alpine/latest-stable/community
|
||||||
7
pyproject.toml
Normal file
7
pyproject.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[project]
|
||||||
|
name = "bearnet"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = []
|
||||||
9
scripts/entrypoint.sh
Normal file
9
scripts/entrypoint.sh
Normal file
@@ -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 \
|
||||||
|
$@
|
||||||
65
scripts/gen-gpg-key.sh
Executable file
65
scripts/gen-gpg-key.sh
Executable file
@@ -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
|
||||||
277
scripts/orchestrate.py
Normal file
277
scripts/orchestrate.py
Normal file
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
17
scripts/setup-hypervisor.sh
Normal file
17
scripts/setup-hypervisor.sh
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user