165 lines
5.8 KiB
Python
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()
|
|
|