Files
ccsite-manager/ccsite.py

217 lines
8.5 KiB
Python

#!/usr/bin/env python3
"""
ccsite-manager — Mass orchestration CLI for the 10-shop fleet.
Usage:
ccsite push <local_path> <remote_path> [--site N] Push a file to all/specific sites
ccsite cmd "<command>" [--site N] Run a shell command on all sites
ccsite restart [--site N] Restart the shop service
ccsite sql "<SQL>" [--site N] Run SQLite query on all sites
ccsite list List all sites with status
ccsite health Health check all sites
ccsite push-template <name> <content> [--site N] Push a Jinja2 template to all sites
"""
import subprocess
import sys
import json
import base64
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
SITES = {
1: {"ip": "10.30.20.210", "vmid": "123", "name": "Digital Marketplace"},
2: {"ip": "10.30.20.211", "vmid": "124", "name": "CardVault Pro"},
3: {"ip": "10.30.20.212", "vmid": "125", "name": "StreamPass Hub"},
4: {"ip": "10.30.20.213", "vmid": "126", "name": "KeyForge Digital"},
5: {"ip": "10.30.20.214", "vmid": "127", "name": "CoinBridge Market"},
6: {"ip": "10.30.20.217", "vmid": "128", "name": "GiftShift"},
7: {"ip": "10.30.20.218", "vmid": "129", "name": "PremiumPortal"},
8: {"ip": "10.30.20.219", "vmid": "130", "name": "DigiDeals"},
9: {"ip": "10.30.20.220", "vmid": "131", "name": "NexusKeys"},
10: {"ip": "10.30.20.221", "vmid": "132", "name": "VaultPass"},
}
PROXMOX = "root@10.30.20.85"
SHOP_DIR = "/opt/shop"
SHOP_DB = "/opt/shop/shop.db"
def ssh_cmd(ip, cmd, timeout=15):
"""Run a command on a site via direct SSH."""
full = f"ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=5 root@{ip} '{cmd}'"
try:
r = subprocess.run(full, shell=True, capture_output=True, text=True, timeout=timeout)
return {"ok": r.returncode == 0, "stdout": r.stdout.strip(), "stderr": r.stderr.strip()}
except subprocess.TimeoutExpired:
return {"ok": False, "stdout": "", "stderr": "TIMEOUT"}
except Exception as e:
return {"ok": False, "stdout": "", "stderr": str(e)}
def pct_exec(vmid, cmd, timeout=15):
"""Run a command inside a container via Proxmox pct exec."""
safe_cmd = cmd.replace("'", "'\"'\"'")
full = f"ssh {PROXMOX} \"pct exec {vmid} -- bash -c '{safe_cmd}'\""
try:
r = subprocess.run(full, shell=True, capture_output=True, text=True, timeout=timeout)
return {"ok": r.returncode == 0, "stdout": r.stdout.strip(), "stderr": r.stderr.strip()}
except subprocess.TimeoutExpired:
return {"ok": False, "stdout": "", "stderr": "TIMEOUT"}
except Exception as e:
return {"ok": False, "stdout": "", "stderr": str(e)}
def push_file(ip, local_path, remote_path, timeout=15):
"""Push a file to a site via base64 + SSH."""
with open(local_path, 'rb') as f:
b64 = base64.b64encode(f.read()).decode()
cmd = f"echo '{b64}' | base64 -d > {remote_path}"
return ssh_cmd(ip, cmd, timeout)
def push_content(ip, content, remote_path, timeout=15):
"""Push raw content to a site."""
b64 = base64.b64encode(content.encode()).decode()
cmd = f"echo '{b64}' | base64 -d > {remote_path}"
return ssh_cmd(ip, cmd, timeout)
def run_on_sites(sites_filter=None, func=None, desc=""):
"""Run a function across sites in parallel. Returns dict of results."""
targets = {k: v for k, v in SITES.items() if sites_filter is None or k in sites_filter}
results = {}
with ThreadPoolExecutor(max_workers=10) as ex:
futures = {ex.submit(func, s): (i, s) for i, s in targets.items()}
for f in as_completed(futures):
i, s = futures[f]
try:
results[i] = f.result()
except Exception as e:
results[i] = {"ok": False, "stderr": str(e)}
status = "" if results[i].get("ok") else ""
print(f" [{status}] ccsite{i} ({s['name']}) — {desc}")
return results
# ─── Commands ─────────────────────────────────────────────
def cmd_list():
print(f"{'#':<3} {'Name':<25} {'IP':<16} {'VMID':<6} Status")
print("-" * 60)
def check(s):
r = ssh_cmd(s['ip'], "systemctl is-active shop 2>/dev/null || echo dead")
status = r.get('stdout', '?').strip()
return r
results = run_on_sites(func=check, desc="status check")
for i in sorted(SITES):
s = SITES[i]
r = results.get(i, {})
status = r.get('stdout', '?').strip()
status_icon = "🟢" if status == "active" else "🔴"
print(f"{i:<3} {s['name']:<25} {s['ip']:<16} {s['vmid']:<6} {status_icon} {status}")
def cmd_health():
print("Health check across all sites...")
def check(s):
try:
r = subprocess.run(
f"curl -s --connect-timeout 3 http://{s['ip']}:5000/health",
shell=True, capture_output=True, text=True, timeout=5
)
if r.returncode == 0:
d = json.loads(r.stdout)
return {"ok": True, "stdout": f"{d.get('products', '?')} products, {d.get('users', '?')} users"}
return {"ok": False, "stdout": r.stderr or "no response"}
except:
return {"ok": False, "stdout": "connection failed"}
results = run_on_sites(func=check, desc="health")
ok = sum(1 for r in results.values() if r.get("ok"))
print(f"\n{ok}/10 sites healthy")
def cmd_restart(sites_filter=None):
print("Restarting shop service...")
def restart(s):
return ssh_cmd(s['ip'], "systemctl restart shop && sleep 1 && systemctl is-active shop")
run_on_sites(sites_filter, restart, "restart")
def cmd_push(args):
local = args[0]
remote = args[1] if len(args) > 1 else f"{SHOP_DIR}/{Path(local).name}"
sites_filter = parse_site_filter(args)
print(f"Pushing {local}{remote}")
def push(s):
return push_file(s['ip'], local, remote)
results = run_on_sites(sites_filter, push, f"{remote}")
ok = sum(1 for r in results.values() if r.get("ok"))
print(f"\nPushed to {ok}/{len(results)} sites")
# Restart if pushing to templates or app
if 'templates' in remote or 'app.py' in remote:
print("Code change detected — restarting services...")
cmd_restart(sites_filter)
def cmd_cmd(args):
if not args:
print("Usage: ccsite cmd '<command>' [--site N]")
return
cmd_str = args[0]
sites_filter = parse_site_filter(args)
print(f"Running: {cmd_str}")
def run(s):
return ssh_cmd(s['ip'], cmd_str, timeout=20)
results = run_on_sites(sites_filter, run, cmd_str[:40])
for i in sorted(results):
r = results[i]
out = r.get('stdout', '') or r.get('stderr', '')
if out:
print(f"\n── ccsite{i} ({SITES[i]['name']}) ──")
print(out[:500])
def cmd_sql(args):
if not args:
print("Usage: ccsite sql '<SQL query>' [--site N]")
return
sql = args[0]
sites_filter = parse_site_filter(args)
print(f"SQL: {sql}")
def run(s):
return ssh_cmd(s['ip'], f"sqlite3 {SHOP_DB} \"{sql}\" 2>&1", timeout=10)
results = run_on_sites(sites_filter, run, sql[:40])
for i in sorted(results):
r = results[i]
out = r.get('stdout', '') or r.get('stderr', '')
print(f"\n── ccsite{i} ({SITES[i]['name']}) ──")
print(out[:500] if out else "(no results)")
def parse_site_filter(args):
"""Extract --site N from args. Returns set of site IDs or None for all."""
for a in args:
if a.startswith('--site='):
try:
return {int(a.split('=')[1])}
except:
pass
return None
def print_usage():
print(__doc__)
# ─── Main ──────────────────────────────────────────────────
if __name__ == '__main__':
if len(sys.argv) < 2:
print_usage()
sys.exit(1)
command = sys.argv[1]
rest = sys.argv[2:]
if command == 'list':
cmd_list()
elif command == 'health':
cmd_health()
elif command == 'restart':
cmd_restart(parse_site_filter(rest))
elif command == 'push':
cmd_push(rest)
elif command == 'cmd':
cmd_cmd(rest)
elif command == 'sql':
cmd_sql(rest)
else:
print(f"Unknown command: {command}")
print_usage()