217 lines
8.5 KiB
Python
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()
|