Explorer + cli

This commit is contained in:
ciomek committed 2026-09-19 21:39:29 +02:00
1 parent d00a12c31c
commit 48349569e7
7 files changed
+316 -1

No files matched your search

+2 -1
View File
@@ -10,7 +10,8 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
# Runtime state lives in the working directory: state.json, storage/, logs/ RUN chmod +x colonyctl.py && ln -s /app/colonyctl.py /usr/local/bin/colonyctl
VOLUME ["/app/storage", "/app/logs"] VOLUME ["/app/storage", "/app/logs"]
EXPOSE 65334/tcp 28149/udp EXPOSE 65334/tcp 28149/udp
+193
View File
@@ -0,0 +1,193 @@
import argparse
import json
import sys
import time
from colony.core.network.requests import CommandRequest
from colony.core.roles.unit.role import Unit
from colony.settings import configuration
from colony.transport.whisperer import Whisperer
DEFAULT_TIMEOUT = 5
DEFAULT_INTERVAL = 0.5
HOME = "\033[H"
CLEAR_SCREEN = "\033[2J"
CLEAR_LINE = "\033[K"
CLEAR_BELOW = "\033[J"
SCORE_COLUMNS = (
("cpu", "CPU"),
("memory", "MEM GB"),
("gpu", "GPU GB"),
("disk", "DISK GB"),
("longevity", "UP H"),
)
def fetch_cluster(host, port, timeout=DEFAULT_TIMEOUT, cert_file=None, keyfile=None):
payload = CommandRequest.form(Unit.COMMANDS.SHOW_CLUSTER).collapse()
resp = Whisperer(host, port, cert_file, keyfile).send(payload, timeout=timeout)
if not isinstance(resp, dict) or resp.get("status") != "ok" or not isinstance(resp.get("result"), dict):
reason = resp.get("error") if isinstance(resp, dict) else resp
raise RuntimeError(f"the node answered with an error: {reason}")
return resp["result"]
def format_age(seconds):
seconds = int(seconds)
if seconds < 60:
return f"{seconds}s"
if seconds < 3600:
return f"{seconds // 60}m{seconds % 60:02d}s"
return f"{seconds // 3600}h{seconds % 3600 // 60:02d}m"
def table(headers, rows):
widths = [max(len(str(cell)) for cell in column) for column in zip(headers, *rows)]
lines = [" ".join(str(cell).ljust(width) for cell, width in zip(row, widths)).rstrip() for row in [headers, *rows]]
return "\n".join(lines)
def format_score(value):
return "-" if value is None else f"{value:.1f}"
def address(member):
return f"{member['ip']}:{member['port']}"
def role_states(payload):
alive = [member for member in payload["members"] if member["alive"]]
states = []
for role in payload["roles"]:
holders = sorted(address(member) for member in alive if role["name"] in member["roles"])
required = role["required"]
if required is None:
state = "general"
elif len(holders) < required:
state = "unmet"
elif len(holders) > required:
state = "duplicate"
else:
state = "held"
states.append({**role, "holders": holders, "state": state})
return states
def render_status(payload):
members = payload["members"]
hostnames = [member["hostname"] for member in members]
labels = {
address(member): member["hostname"] if hostnames.count(member["hostname"]) == 1 else address(member)
for member in members
}
alive = [member for member in members if member["alive"]]
down = len(members) - len(alive)
self_member = next(member for member in members if member["self"])
title = f"Cluster as seen by {labels[address(self_member)]}: {len(alive)} node(s) alive"
if down:
title += f", {down} down"
show_declined = any(member["declined"] for member in members)
node_headers = ["NODE", "ADDRESS", "STATE", "SEEN", "ROLES"] + (["DECLINED"] if show_declined else [])
node_rows = []
for member in members:
label = labels[address(member)] + (" (this node)" if member["self"] else "")
row = [
label,
address(member),
"alive" if member["alive"] else "DOWN",
"-" if member["self"] else format_age(member["seen_ago"]),
" ".join(member["roles"]) or "-",
]
if show_declined:
row.append(" ".join(member["declined"]) or "-")
node_rows.append(row)
alive_addresses = {address(member) for member in alive}
role_rows = []
for role in role_states(payload):
holders = role["holders"]
if role["state"] == "general":
state = "ok"
else:
state = {
"held": "ok",
"unmet": f"UNMET ({len(holders)}/{role['required']})",
"duplicate": f"DUPLICATE ({len(holders)}/{role['required']})",
}[role["state"]]
if role["state"] == "general" and set(holders) == alive_addresses:
names = "all nodes"
else:
names = ", ".join(labels[holder] for holder in holders) or "-"
role_rows.append([role["name"], role["priority"], state, names])
score_rows = [
[labels[address(member)]] + [format_score(member["scores"].get(key)) for key, _ in SCORE_COLUMNS]
for member in alive
]
sections = [
title,
table(node_headers, node_rows),
table(["ROLE", "PRIORITY", "STATE", "HOLDERS"], role_rows),
table(["NODE"] + [header for _, header in SCORE_COLUMNS], score_rows),
]
return "\n\n".join(sections)
def build_parser():
parser = argparse.ArgumentParser(prog="colonyctl", description="Inspect a running colony node.")
commands = parser.add_subparsers(dest="command", required=True)
status = commands.add_parser("status", help="show members, roles and hardware scores (live in a terminal)")
status.add_argument("--host", default="127.0.0.1", help="node to ask (default: this machine)")
status.add_argument("--port", type=int, default=None, help="node port (default: listener.port from config)")
status.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT)
status.add_argument("--interval", type=float, default=DEFAULT_INTERVAL, help="seconds between refreshes")
status.add_argument("--raw", action="store_true", help="print once and exit instead of refreshing")
status.add_argument("--json", action="store_true", help="print the raw data as JSON once and exit")
return parser
def draw(out, text):
out.write(HOME + text.replace("\n", CLEAR_LINE + "\n") + CLEAR_LINE + CLEAR_BELOW)
out.flush()
def watch(host, port, args, out):
out.write(CLEAR_SCREEN)
try:
while True:
try:
text = render_status(fetch_cluster(host, port, timeout=args.timeout))
except (OSError, ValueError, RuntimeError) as e:
text = f"colonyctl: cannot get status from {host}:{port}: {e}\nretrying..."
footer = f"Every {args.interval:g}s. Updated {time.strftime('%H:%M:%S')}. Ctrl-C to quit."
draw(out, f"{text}\n\n{footer}")
time.sleep(args.interval)
except KeyboardInterrupt:
out.write("\n")
return 0
def main(argv=None, out=None):
args = build_parser().parse_args(argv)
out = sys.stdout if out is None else out
port = args.port if args.port is not None else configuration.listener.port
if not args.raw and not args.json and out.isatty():
return watch(args.host, port, args, out)
try:
payload = fetch_cluster(args.host, port, timeout=args.timeout)
except (OSError, ValueError, RuntimeError) as e:
print(f"colonyctl: cannot get status from {args.host}:{port}: {e}", file=sys.stderr)
return 1
print(json.dumps(payload, indent=2) if args.json else render_status(payload), file=out)
return 0
+10
View File
@@ -203,6 +203,16 @@ class Membership:
if now - advanced <= self.member_timeout] if now - advanced <= self.member_timeout]
return sorted(alive, key=lambda record: record.key) return sorted(alive, key=lambda record: record.key)
def overview(self):
with self._lock:
now = self.clock()
self._purge(now)
rows = [{**self._current_self().to_dict(), "alive": True, "seen_ago": 0.0, "self": True}]
for record, advanced in self._entries.values():
seen_ago = now - advanced
rows.append({**record.to_dict(), "alive": seen_ago <= self.member_timeout, "seen_ago": seen_ago, "self": False})
return sorted(rows, key=lambda row: (row["ip"], row["port"]))
def known_addresses(self): def known_addresses(self):
with self._lock: with self._lock:
self._purge(self.clock()) self._purge(self.clock())
+81
View File
@@ -2,7 +2,9 @@ import datetime
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from enum import Enum from enum import Enum
from colony.core.network.membership import MemberRecord
from colony.core.storage import DEFAULT_STORAGE_FILES from colony.core.storage import DEFAULT_STORAGE_FILES
from colony.core.roles.gossip.role import Gossip
from colony.core.roles.role import Role from colony.core.roles.role import Role
from colony.core.roles.unit.role import Unit from colony.core.roles.unit.role import Unit
from colony.core.roles.vaultwarden.role import VaultWarden from colony.core.roles.vaultwarden.role import VaultWarden
@@ -66,6 +68,7 @@ class Explorer(Role):
return return
self.save_snapshot(snapshot) self.save_snapshot(snapshot)
self.introduce_unknown_peers(discovered_peers)
def load_snapshot(self): def load_snapshot(self):
try: try:
@@ -137,3 +140,81 @@ class Explorer(Role):
self.logger.warning(f"Peer {ip}:{port} answered SHOW_STATS with an error") self.logger.warning(f"Peer {ip}:{port} answered SHOW_STATS with an error")
return None return None
return resp['result'] return resp['result']
def fetch_members_from_peer(self, ip, port):
try:
resp = self.context.execute_to_unregistered_node(ip, port, Unit.COMMANDS.SHOW_CLUSTER)
except Exception as e:
self.logger.warning(f"Could not fetch cluster members from {ip}:{port}: {e}")
return []
result = resp.get('result') if isinstance(resp, dict) and resp.get('status') == 'ok' else None
rows = result.get('members') if isinstance(result, dict) else None
if not isinstance(rows, list):
self.logger.warning(f"Peer {ip}:{port} answered SHOW_CLUSTER with an error")
return []
members = []
for row in rows[:setting("membership.max_members")]:
try:
record = MemberRecord.from_dict(row)
except ValueError as e:
self.logger.debug(f"Ignoring bad member from {ip}:{port}: {e}")
continue
if row.get('alive') is True:
members.append(record.key)
return members
def _ping(self, address):
ip, _ = address
try:
port = Discover.request(ip, timeout=self.SCAN_TIMEOUT, discover_port=setting("discovery.discover_port"))
except Exception:
return None
return (ip, port) if port is not None else None
def introduce_unknown_peers(self, discovered_peers):
membership = self.context.membership()
known = {record.key for record in membership.alive_records()} | set(discovered_peers.items())
candidates = {}
for ip, port in discovered_peers.items():
for address in self.fetch_members_from_peer(ip, port):
if address not in known:
candidates[address] = None
if not candidates or self._stop_event.is_set():
return
addresses = list(candidates)[:setting("membership.max_members")]
with ThreadPoolExecutor(max_workers=min(self.SCAN_WORKERS, len(addresses))) as pool:
pinged = [address for address in pool.map(self._ping, addresses) if address is not None]
for ip, port in pinged:
self.logger.info(f"Peers know {ip}:{port}, asking it to become our peer")
self.send_hello(ip, port)
def send_hello(self, ip, port):
membership = self.context.membership()
sender = membership.self_record().to_dict()
known = [record.to_dict() for record in membership.alive_records() if record.key != membership.key]
try:
resp = self.context.execute_to_unregistered_node(
ip, port, Gossip.COMMANDS.HELLO, sender, known, timeout=setting("membership.hello_timeout")
)
except Exception as e:
self.logger.warning(f"Hello to {ip}:{port} failed: {e}")
return False
result = resp.get('result') if isinstance(resp, dict) and resp.get('status') == 'ok' else None
if not isinstance(result, dict) or not result.get('admitted'):
self.logger.warning(f"{ip}:{port} did not admit this node")
return False
try:
members = [MemberRecord.from_dict(item) for item in result.get('members', [])]
except (ValueError, TypeError) as e:
self.logger.warning(f"Ignoring bad member list from {ip}:{port}: {e}")
return False
membership.merge(members[:setting("membership.max_members")])
return True
+5
View File
@@ -65,6 +65,8 @@ class Gossip(Role):
def hello(self, sender, known): def hello(self, sender, known):
record = MemberRecord.from_dict(sender) record = MemberRecord.from_dict(sender)
if record.key == self.membership.key:
return {"admitted": False, "members": [], "self": True}
if not self.membership.admit(record): if not self.membership.admit(record):
return {"admitted": False, "members": []} return {"admitted": False, "members": []}
@@ -137,6 +139,9 @@ class Gossip(Role):
if not isinstance(result, dict): if not isinstance(result, dict):
self.logger.debug(f"Hello to {ip}:{port} was answered with an error") self.logger.debug(f"Hello to {ip}:{port} was answered with an error")
return FAILED return FAILED
if result.get("self"):
self.logger.debug(f"{ip}:{port} is this node")
return FAILED
if not result.get("admitted"): if not result.get("admitted"):
self.logger.warning(f"{ip}:{port} refused to admit this node") self.logger.warning(f"{ip}:{port} refused to admit this node")
return REFUSED return REFUSED
+18
View File
@@ -1,6 +1,8 @@
from enum import Enum from enum import Enum
from colony.core.network import advice
from colony.core.network.hardware import HardwareStatistics from colony.core.network.hardware import HardwareStatistics
from colony.core.roles.role import Role, Command from colony.core.roles.role import Role, Command
from colony.role_registry import RolePriority
class Unit(Role): class Unit(Role):
@@ -8,6 +10,7 @@ class Unit(Role):
SHOW_STATS = 'Unit.ShowStats' SHOW_STATS = 'Unit.ShowStats'
SHOW_ROLES = 'Unit.ShowRoles' SHOW_ROLES = 'Unit.ShowRoles'
SHOW_HARDWARE = 'Unit.ShowHardware' SHOW_HARDWARE = 'Unit.ShowHardware'
SHOW_CLUSTER = 'Unit.ShowCluster'
REQUEST_ROLE = 'Unit.RequestRole' REQUEST_ROLE = 'Unit.RequestRole'
def __init__(self, context): def __init__(self, context):
@@ -15,6 +18,7 @@ class Unit(Role):
self.COMMANDS.SHOW_STATS.value: Command(self.show_stats), self.COMMANDS.SHOW_STATS.value: Command(self.show_stats),
self.COMMANDS.SHOW_ROLES.value: Command(self.show_roles), self.COMMANDS.SHOW_ROLES.value: Command(self.show_roles),
self.COMMANDS.SHOW_HARDWARE.value: Command(self.show_hardware), self.COMMANDS.SHOW_HARDWARE.value: Command(self.show_hardware),
self.COMMANDS.SHOW_CLUSTER.value: Command(self.show_cluster),
self.COMMANDS.REQUEST_ROLE.value: Command(self.request_role), self.COMMANDS.REQUEST_ROLE.value: Command(self.request_role),
} }
@@ -23,6 +27,20 @@ class Unit(Role):
def show_hardware(self): def show_hardware(self):
return HardwareStatistics.collect().to_dict() return HardwareStatistics.collect().to_dict()
def show_cluster(self):
roles = self.context.get_role_registry().roles
return {
"members": self.context.membership().overview(),
"roles": [
{
"name": name,
"priority": roles[name].priority.name.lower(),
"required": None if roles[name].priority == RolePriority.GENERAL else advice.required_holders(roles[name]),
}
for name in sorted(roles, key=lambda name: (roles[name].priority.value, name))
],
}
def show_roles(self): def show_roles(self):
return list(self.context.self().roles) return list(self.context.self().roles)
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env python
import sys
from colony.cli import main
if __name__ == "__main__":
sys.exit(main())