feat: add homelab deployment configs, documentation, and automation scripts
This commit is contained in:
164
snarfnet-game-seeds/core/manager.py
Normal file
164
snarfnet-game-seeds/core/manager.py
Normal file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SnarfNet Game Seed Management Plane (Yggdrasil-compatible)
|
||||
Declarative orchestrator for private game servers on SnarfNet Gaming PC.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import glob
|
||||
import yaml
|
||||
import socket
|
||||
import struct
|
||||
import tarfile
|
||||
import datetime
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
TARGET_MAC = "bc:24:11:67:16:cd"
|
||||
BROADCAST_IP = "140.44.44.255"
|
||||
NAS_BACKUP_ROOT = "/mnt/nas/game-backups"
|
||||
SEEDS_DIR = Path(__file__).parent.parent / "seeds"
|
||||
|
||||
def send_wol(mac=TARGET_MAC, bcast=BROADCAST_IP):
|
||||
"""Sends a Wake-on-LAN magic packet to the gaming PC."""
|
||||
clean_mac = mac.replace(":", "").replace("-", "")
|
||||
if len(clean_mac) != 12:
|
||||
raise ValueError(f"Invalid MAC address: {mac}")
|
||||
data = bytes.fromhex("FF" * 6 + clean_mac * 16)
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
s.sendto(data, (bcast, 9))
|
||||
print(f"[WOL] Sent magic packet to {mac} via {bcast}:9")
|
||||
|
||||
def load_seeds():
|
||||
"""Loads all declarative seed YAML files from the seeds directory."""
|
||||
seeds = []
|
||||
for filepath in glob.glob(str(SEEDS_DIR / "*.seed.yaml")):
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
docs = yaml.safe_load(f)
|
||||
if docs and docs.get("kind") == "GameSeed":
|
||||
seeds.append(docs)
|
||||
return seeds
|
||||
|
||||
def cmd_plan():
|
||||
"""Previews desired state across all defined game seeds."""
|
||||
seeds = load_seeds()
|
||||
print(f"\n=======================================================")
|
||||
print(f"🌲 SnarfNet Game Seed Plan ({len(seeds)} Seeds Declared)")
|
||||
print(f"=======================================================")
|
||||
for s in seeds:
|
||||
meta = s.get("metadata", {})
|
||||
spec = s.get("spec", {})
|
||||
game = spec.get("game", {})
|
||||
server = spec.get("server", {})
|
||||
net = spec.get("network", {})
|
||||
runtime = spec.get("runtime", {})
|
||||
res = spec.get("resources", {})
|
||||
|
||||
ports = ", ".join([f"{p['port']}/{p['protocol']}" for p in net.get("ports", [])])
|
||||
print(f"\n🎮 [{meta.get('name')}] {meta.get('displayName')}")
|
||||
print(f" Steam AppID: {game.get('appId')} (Branch: {game.get('branch')})")
|
||||
print(f" Image: {runtime.get('image')}")
|
||||
print(f" Ports: {ports}")
|
||||
print(f" Memory: {res.get('memoryReservation')} -> {res.get('memoryLimit')}")
|
||||
print(f" Save Dir: {server.get('saveDirectory')}")
|
||||
print(f" Backup: {spec.get('backup', {}).get('schedule')} (Retention: {spec.get('backup', {}).get('retentionDays')}d)")
|
||||
|
||||
def cmd_generate_compose(output_path="docker-compose.yml"):
|
||||
"""Generates standard docker-compose.yml from all declared seeds."""
|
||||
seeds = load_seeds()
|
||||
services = {}
|
||||
|
||||
for s in seeds:
|
||||
name = s["metadata"]["name"]
|
||||
spec = s["spec"]
|
||||
runtime = spec.get("runtime", {})
|
||||
server = spec.get("server", {})
|
||||
net = spec.get("network", {})
|
||||
res = spec.get("resources", {})
|
||||
|
||||
ports = []
|
||||
for p in net.get("ports", []):
|
||||
ports.append(f"{p['port']}:{p['port']}/{p['protocol'].lower()}")
|
||||
|
||||
save_dir = server.get("saveDirectory")
|
||||
data_dir = f"/opt/game-servers/{name}/data"
|
||||
|
||||
services[name] = {
|
||||
"image": runtime.get("image"),
|
||||
"container_name": f"gameseed-{name}",
|
||||
"restart": runtime.get("restartPolicy", "unless-stopped"),
|
||||
"environment": runtime.get("environment", {}),
|
||||
"ports": ports,
|
||||
"volumes": [
|
||||
f"{save_dir}:{save_dir}",
|
||||
f"{data_dir}:/serverdata"
|
||||
],
|
||||
"deploy": {
|
||||
"resources": {
|
||||
"limits": {
|
||||
"memory": res.get("memoryLimit", "8Gi"),
|
||||
"cpus": res.get("cpuLimit", "4")
|
||||
},
|
||||
"reservations": {
|
||||
"memory": res.get("memoryReservation", "4Gi")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compose = {
|
||||
"version": "3.8",
|
||||
"services": services
|
||||
}
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(compose, f, sort_keys=False)
|
||||
print(f"[COMPOSE] Rendered {len(services)} services to {output_path}")
|
||||
|
||||
def cmd_backup():
|
||||
"""Executes atomic snapshots of all world save directories and copies to Synology NAS."""
|
||||
seeds = load_seeds()
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
nas_root = Path(NAS_BACKUP_ROOT)
|
||||
|
||||
print(f"\n📦 Starting Declarative Game Save Backups ({timestamp})...")
|
||||
for s in seeds:
|
||||
name = s["metadata"]["name"]
|
||||
save_dir = Path(s["spec"]["server"]["saveDirectory"])
|
||||
target_dir = nas_root / name
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
archive_name = f"{name}_save_{timestamp}.tar.gz"
|
||||
archive_path = target_dir / archive_name
|
||||
|
||||
if save_dir.exists():
|
||||
print(f" Archiving {name}: {save_dir} -> {archive_path}")
|
||||
with tarfile.open(archive_path, "w:gz") as tar:
|
||||
tar.add(save_dir, arcname=name)
|
||||
print(f" ✓ Backup complete: {archive_path} ({archive_path.stat().st_size} bytes)")
|
||||
else:
|
||||
print(f" [SKIP] Save directory {save_dir} not found on host.")
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python manager.py <plan|compose|backup|wol>")
|
||||
sys.exit(1)
|
||||
|
||||
action = sys.argv[1].lower()
|
||||
if action == "plan":
|
||||
cmd_plan()
|
||||
elif action == "compose":
|
||||
cmd_generate_compose()
|
||||
elif action == "backup":
|
||||
cmd_backup()
|
||||
elif action == "wol":
|
||||
send_wol()
|
||||
else:
|
||||
print(f"Unknown command: {action}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user