#!/usr/bin/env python3 """ ccsite-manager — Mass orchestration CLI for the 10-shop fleet. Usage: ccsite push [--site N] Push a file to all/specific sites ccsite cmd "" [--site N] Run a shell command on all sites ccsite restart [--site N] Restart the shop service ccsite 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 [--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 '' [--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 '' [--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()