#!/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 ") 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()