refactor: use bubble instead

This commit is contained in:
iceBear67
2026-06-15 17:22:01 +08:00
parent ab9b4c7e13
commit 37d68dfcab
9 changed files with 47 additions and 381 deletions

View File

@@ -15,10 +15,6 @@ 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; \
}
alpine-make-vm-image \
--branch $(ALPINE_BRANCH) \
--image-format $(IMAGE_FORMAT) \
@@ -28,7 +24,7 @@ build:
--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 cronie" \
--packages "git curl docker docker-cli-buildx docker-cli-compose cronie" \
$(IMAGE_FILE) \
$(CONFIGURE_SH)
@echo ">>> Image built: $(IMAGE_FILE)"

View File

@@ -33,25 +33,15 @@ 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
step 'Setup git user'
git config --user.email bearnet+keeper@sab.ee
git config --user.name "B.B.K.K.B.K.K"
adduser -S keeper
mkdir /users
chown keeper /users
echo ''
echo '=== Configure script completed ==='

View File

@@ -0,0 +1,17 @@
#!/bin/sh
# VARIABLES: _REVISION_ _REPO_
set -euo pipefail
mkdir -p /users && chown keeper /users && chmod 644 /users
su keeper
init_repo(){
git clone -b _REVISION_ _REPO_ /users
}
if [[ ! -d /users/.git ]]; then
init_repo
elif [[ -d /users && cd /users && ! git pull origin _REVISION_ ]]; then
init_repo
fi

View File

@@ -1,16 +0,0 @@
#!/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

View File

@@ -0,0 +1,11 @@
#!/sbin/openrc-run
command="/usr/bin/auth-server"
command_background=true
command_args="-addr 0.0.0.0:8080 -root /users"
command_user="keeper"
pidfile="/run/${RC_SVCNAME}.pid"
depend() {
need net
}

10
image/overlay/etc/init.d/bubble Executable file
View File

@@ -0,0 +1,10 @@
#!/sbin/openrc-run
depend() {
need auth-server docker
}
command="/usr/bin/bubble"
command_args="-config /daemon/config.yaml"
pidfile="/run/${RC_SVCNAME}.pid"
command_background=true

View File

@@ -1,2 +1,2 @@
# min hour day month weekday command
*/15 * * * * /usr/bin/sh /daemon/update.sh
*/15 * * * * /usr/bin/sh /daemon/update-keys.sh

View File

@@ -1,65 +0,0 @@
#!/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

View File

@@ -1,277 +0,0 @@
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)