Files

165 lines
5.8 KiB
Python

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