Compare commits

..
2 Commits
Author SHA1 Message Date
ciomek 5b2290cf97 cleanup
Build and Push Docker Image / build-and-push (push) Successful in 1m52s
2026-10-07 19:26:14 +02:00
ciomek d046ca2dd8 Fix 2026-10-06 16:41:01 +02:00
24 changed files with 443 additions and 157 deletions

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
# Project map: colony
_Auto-generated by the `explorer` subagent. Factual overview — refresh via
explorer, not by hand._
## What it does
Self-organizing cluster of nodes on a LAN (README.md). Nodes discover each other (TCP probe on the discovery port over a configured IP range), gossip membership, and bid for "roles" (replicated storage, password vault, web dashboard, chat, ...), so roles keep running as nodes join/leave/die. Each node is one Python process; `colonyctl` controls the local node.
## How it works
- `main.py` boot order: validate discovery range -> `StateManager` (data/state.json) -> `Network` -> `RoleRegistry(available_roles)` -> `Dispatcher` + `RoleManager` (general + on-demand roles, init) -> TLS `Listener` bind -> unix `ControlSocket` bind -> threads for ControlSocket, Listener, `Discover.listen` -> `Gossip.join_or_bootstrap()`; if bootstrapped, `Foreman.tick()` runs to assign roles -> `start_all()` -> `ControlQueue.run_forever()` on main thread.
- Node-to-node: length-prefixed (4-byte `!I`, 16 MiB max) JSON frames over TLS (`colony/transport/framing.py`, `listener.py`, `whisperer.py` client). Payload `{"command": {role, name, args, kwargs, from}}`; `Dispatcher.dispatch` maps `Role.Command` to the role's `COMMANDS` enum and calls `execute_local`.
- Roles (`colony/core/roles/<name>/role.py`) subclass `Role` (`roles/role.py`): `CONTINUOUS` + `LOOP_SECONDS_WAIT` run `loop()` in a daemon thread; commands registered via `Command(callback, with_caller)`.
- Role assignment: `Foreman` proposes unclaimed roles to capable nodes; `RoleManager` (`roles/manager.py`) + `policy.py` (config `role_policy`) + `role_registry.py` (priority, hardware preferences, `minimal_percentage`, `on_demand`) decide who runs what. Hardware scoring: `core/hardware_scores.py`, `network/hardware.py`.
- Unreachable nodes: `Gateway` role relays commands (`network/relay.py`, builds `Gateway.Forward`; config `gateway.allow_commands` whitelists relayable commands).
- Local control: `colonyctl` -> unix socket `data/colony.sock` (`transport/control.py`) -> `ControlQueue` -> `ControlActions` (`core/control.py`).
## Tech stack
Python >=3.14 (`.python-version` 3.14), uv (`uv.lock`, `package = false`). Deps (pyproject.toml): dependency-injector 4.48.3, dynaconf 3.2.12, Flask 3.1.3, GPUtil 1.4.0, psutil 7.2.1, setuptools 80.9.0. Web UI static JS/CSS (no build step). Docker image python:3.14-alpine.
## Repositories / modules
Single flat package.
- `main.py` node entrypoint; `colonyctl.py` -> `colony/cli.py` (argparse CLI); `role_settings.py` role list; `config.json` defaults.
- `colony/core/` — `context.py` (RoleContext), `dispatcher.py`, `control.py`, `state_manager.py`, `logger/`, `exceptions.py`.
- `colony/core/network/` — `core.py` (Network), `membership.py`, `nodes.py`, `advice.py`, `hardware.py`, `relay.py`, `requests.py`.
- `colony/core/roles/` — Unit, Gossip, Foreman, Gateway, VaultWarden, Replicator, Explorer, WebView (Flask `app.py` + `static/`), Chatter; `manager.py`, `policy.py`.
- `colony/core/storages/` — `core.py` (Storage base, `storages/` dir, tar/hash helpers), `default.py`, `replica.py`, `chat.py`.
- `colony/transport/` — `listener.py`, `whisperer.py`, `discover.py`, `control.py`, `framing.py`.
- `colony/utils/` — `files.py` (atomic_write_json), `network.py` (hosts_in_range).
- `certs/`, `data/`, `storages/`, `logs/` — runtime dirs (git-ignored: certs, data, storages).
## Key implementations
- Startup/wiring: `main.py`.
- Role catalogue and priorities: `role_settings.py` (Unit, Gossip, Foreman, Gateway = GENERAL; VaultWarden, Replicator = CRITICAL (Replicator `minimal_percentage=51`); Explorer = HIGH; WebView, Chatter = LOW and on-demand).
- Role base/lifecycle: `colony/core/roles/role.py`, `manager.py`.
- Membership/join: `roles/gossip/role.py`, `network/membership.py`.
- Role placement: `roles/foreman/role.py`, `roles/policy.py`.
- CLI (`status`, `role add`, `webview`, `chat`): `colony/cli.py`.
- Settings: `colony/settings.py` (`setting("a.b")` raises KeyError when missing).
## Build, run, test
- Install/run: `uv sync` then `uv run python main.py`.
- CLI: `./colonyctl.py status [--json]`, `role add <Role>`, `webview`, `chat <node> [text]`.
- Docker: `docker build -t colony .`; `docker compose up -d` (host network; volumes /app/data, /app/storages, /app/logs; ports 65334/tcp, 28149/tcp, 8080/tcp).
- CI: `.gitea/workflows/docker.yml` builds and pushes `registry.ciomek.pl/colony:latest` on push to main.
- Tests: none in the repo.
## External integrations
No third-party services. Peers over TLS (cert/key at `certs/server.crt`, `certs/server.key`), TCP discovery on 28149, optional web dashboard on 8080 with basic credentials (`web.username/password`), private Docker registry in CI.
## Conventions & gotchas
- Config: `config.json` overridden by env `COLONY_<SECTION>__<KEY>` (dynaconf); `docker-compose.yml` lists keys.
- Discovery range defaults to 192.168.65.100-120; node exits if range invalid, listener or control socket cannot bind. Warns if node IP is 127.x.
- Role commands (`COMMANDS` enums) are for node-to-node calls; the CLI uses the control socket actions instead.
- State persisted atomically in `data/state.json`; a corrupt file is copied to `.corrupt` and reset.
- Loop exceptions in continuous roles are logged and retried.
- Roles with `on_demand=True` only start via `roles.on_demand` config or `colonyctl role add`.
<!-- generated-at-commit: 244fefd8530bf5cd01267a13f8aee34782c31934 | 2026-10-06 -->
+1 -1
View File
@@ -23,6 +23,6 @@ RUN chmod +x colonyctl.py && ln -s /app/colonyctl.py /usr/local/bin/colonyctl
VOLUME ["/app/data", "/app/storages", "/app/logs"] VOLUME ["/app/data", "/app/storages", "/app/logs"]
EXPOSE 65334/tcp 28149/udp 8080/tcp EXPOSE 65334/tcp 28149/tcp 8080/tcp
CMD ["python", "main.py"] CMD ["python", "main.py"]
+9 -1
View File
@@ -15,7 +15,7 @@ cluster keeps running the roles it needs even as nodes join, leave, or die.
hardware preferences (disk, longevity, etc.) and policy (`role_policy` in hardware preferences (disk, longevity, etc.) and policy (`role_policy` in
`config.json`). `config.json`).
- **Transport**: nodes talk over a TLS listener (`Listener`/`Whisperer`) and - **Transport**: nodes talk over a TLS listener (`Listener`/`Whisperer`) and
discover each other over UDP (`Discover`). A local control socket discover each other over TCP (`Discover`). A local control socket
(`ControlSocket`) is how `colonyctl` talks to the node process on the same (`ControlSocket`) is how `colonyctl` talks to the node process on the same
host. host.
- **Roles shipped today** (see `role_settings.py`): - **Roles shipped today** (see `role_settings.py`):
@@ -46,6 +46,14 @@ Configuration lives in `config.json` and can be overridden with
every available key). At minimum, point `discovery.range_start`/`range_end` every available key). At minimum, point `discovery.range_start`/`range_end`
at the IP range your nodes live on. at the IP range your nodes live on.
Certificates are loaded once per process:
restart the node after rotating them. To set a key to null through the
environment, use `@none` (a plain `null` is read as a string).
Replicated storage (the vault and its replicas) travels as one archive, so it
is limited to 16 MiB per node, and to `gateway.max_payload_bytes` (4 MiB by
default) when a replica is fetched through a gateway.
## Controlling a node (`colonyctl`) ## Controlling a node (`colonyctl`)
`colonyctl` talks to the local node over its control socket: `colonyctl` talks to the local node over its control socket:
+48 -13
View File
@@ -4,7 +4,6 @@ import sys
import time import time
from datetime import datetime from datetime import datetime
from colony.core.storages.chat import ChatStorage
from colony.settings import setting from colony.settings import setting
from colony.transport import control from colony.transport import control
@@ -26,13 +25,28 @@ def call(args, action, **params):
def table(rows): def table(rows):
widths = [max(len(str(row[i])) for row in rows) for i in range(len(rows[0]))] widths = []
return "\n".join(" ".join(str(cell).ljust(width) for cell, width in zip(row, widths)).rstrip() for row in rows) for column in range(len(rows[0])):
width = 0
for row in rows:
width = max(width, len(str(row[column])))
widths.append(width)
lines = []
for row in rows:
cells = []
for cell, width in zip(row, widths):
cells.append(str(cell).ljust(width))
lines.append(" ".join(cells).rstrip())
return "\n".join(lines)
def usage(stats, name): def usage(stats, name):
used, total = stats.get(f"{name}_used_gb"), stats.get(f"{name}_total_gb") used = stats.get(f"{name}_used_gb")
return f"{used:.1f}/{total:.1f} GB" if used is not None and total is not None else "-" total = stats.get(f"{name}_total_gb")
if used is None or total is None:
return "-"
return f"{used:.1f}/{total:.1f} GB"
def render_status(cluster): def render_status(cluster):
@@ -62,16 +76,33 @@ def render_status(cluster):
def render_chat(messages, peer): def render_chat(messages, peer):
if not messages: if not messages:
return f"No messages with {peer} yet." return f"No messages with {peer} yet."
return "\n".join(f"{datetime.fromisoformat(m['at']).astimezone():%H:%M} {'you' if m['direction'] == 'out' else peer}: {m['text']}" for m in messages)
lines = []
for message in messages:
sent_at = datetime.fromisoformat(message["at"]).astimezone()
author = peer
if message["direction"] == "out":
author = "you"
lines.append(f"{sent_at:%H:%M} {author}: {message['text']}")
return "\n".join(lines)
def find_node(cluster, name): def find_node(cluster, name):
matches = [ wanted = name.lower()
member for member in cluster["members"] matches = []
if name.lower() in (member["hostname"].lower(), f"{member['ip']}:{member['port']}", f"{member['hostname']}@{member['ip']}".lower()) for member in cluster["members"]:
names = [
member["hostname"].lower(),
f"{member['ip']}:{member['port']}",
f"{member['hostname']}@{member['ip']}".lower(),
] ]
if len(matches) != 1: if wanted in names:
raise CliError(f"{'no node' if not matches else 'more than one node'} matches {name!r}") matches.append(member)
if len(matches) == 0:
raise CliError(f"no node matches {name!r}")
if len(matches) > 1:
raise CliError(f"more than one node matches {name!r}")
return matches[0] return matches[0]
@@ -122,8 +153,12 @@ def chat(args):
call(args, "send_chat", peer=peer_id, text=args.text) call(args, "send_chat", peer=peer_id, text=args.text)
print(f"Sent to {peer['hostname']}") print(f"Sent to {peer['hostname']}")
else: else:
history = lambda: ChatStorage().messages(peer_id) def render():
show(args, lambda: json.dumps(history(), indent=2) if args.json else render_chat(history(), peer["hostname"])) history = call(args, "chat_history", peer=peer_id)
if args.json:
return json.dumps(history, indent=2)
return render_chat(history, peer["hostname"])
show(args, render)
def build_parser(): def build_parser():
+7 -1
View File
@@ -5,6 +5,7 @@ class ControlActions:
"status": self.status, "status": self.status,
"add_role": self.add_role, "add_role": self.add_role,
"send_chat": self.send_chat, "send_chat": self.send_chat,
"chat_history": self.chat_history,
} }
def run(self, payload): def run(self, payload):
@@ -28,7 +29,12 @@ class ControlActions:
return self._role("Unit").show_cluster() return self._role("Unit").show_cluster()
def add_role(self, role): def add_role(self, role):
return self._role("Unit").request_role(role) return self._role("Unit").add_role_locally(role)
def send_chat(self, peer, text): def send_chat(self, peer, text):
self._role("Chatter").send(peer, text) self._role("Chatter").send(peer, text)
def chat_history(self, peer):
if not isinstance(peer, str):
raise ValueError("peer must be a node id")
return self._role("Chatter").history(peer)
-5
View File
@@ -1,5 +0,0 @@
class NetworkException(Exception):
pass
class NoRoleException(Exception):
pass
-50
View File
@@ -1,4 +1,3 @@
import json
from enum import Enum from enum import Enum
from typing import List, Optional from typing import List, Optional
@@ -186,52 +185,3 @@ class Network:
self._via.pop(key, None) self._via.pop(key, None)
self._direct_ok[key] = self.membership.now() self._direct_ok[key] = self.membership.now()
return response return response
def load_network_from_snapshot(self, file_path: str = "storages/default/colony_snapshot.json"):
self.logger.info(f"Loading network snapshot from {file_path}")
nodes = []
try:
with open(file_path, "r", encoding="utf-8") as f:
snapshot = json.load(f)
except FileNotFoundError:
self.logger.warning(f"Snapshot file {file_path} not found. Starting with empty network")
return nodes
except (json.JSONDecodeError, OSError) as e:
self.logger.warning(f"Could not read snapshot {file_path}: {e}")
return nodes
if not isinstance(snapshot, dict):
self.logger.warning(f"Snapshot {file_path} is not an object. Ignoring it")
return nodes
for node_ip, node_data in snapshot.items():
if not isinstance(node_data, dict):
self.logger.warning(f"Skipping node {node_ip}: entry is not a dict")
continue
if node_data.get("status") == "unreachable":
self.logger.info(f"Skipping node {node_ip}: last seen unreachable")
continue
result = node_data.get("result")
if not isinstance(result, dict):
self.logger.warning(f"Skipping node {node_ip}: result is not a dict")
continue
roles = result.get("roles", [])
try:
node = Node(
hostname=result["hostname"],
ip=result["ip"],
port=result["port"],
roles=roles if isinstance(roles, list) else []
)
except KeyError as e:
self.logger.warning(f"Skipping node {node_ip}: result has no {e}")
continue
self.logger.info(f"Loaded node from snapshot: {node.id}")
nodes.append(node)
return nodes
-18
View File
@@ -1,6 +1,5 @@
import datetime import datetime
import platform import platform
import json
import GPUtil import GPUtil
import psutil import psutil
@@ -74,23 +73,6 @@ class HardwareStatistics:
stats["uptime_hours"] = HardwareScorer.longevity_score(self) stats["uptime_hours"] = HardwareScorer.longevity_score(self)
return stats return stats
def to_json(self):
return json.dumps(self.to_dict(), indent=2)
@staticmethod
def from_dict(hw_dict: dict):
hw = HardwareStatistics()
hw.cpu = hw_dict.get("cpu")
hw.mem = hw_dict.get("memory")
hw.gpu = hw_dict.get("gpu")
hw.disk = hw_dict.get("disk")
hw.os_info = hw_dict.get("os")
hw.boot_time = hw_dict.get("boot_time")
# Optionally parse boot_time back to datetime
if hw.boot_time:
hw.boot_time = datetime.datetime.fromisoformat(hw.boot_time)
return hw
def _get_cpu_stats(self): def _get_cpu_stats(self):
return { return {
"cores": psutil.cpu_count(logical=False), "cores": psutil.cpu_count(logical=False),
+21 -1
View File
@@ -135,6 +135,10 @@ class Membership:
def key(self): def key(self):
return self._self.key return self._self.key
@property
def max_records(self):
return self.max_members + 1
def now(self): def now(self):
return self.clock() return self.clock()
@@ -197,13 +201,29 @@ class Membership:
current = self._entries.get(record.key) current = self._entries.get(record.key)
if current is not None and record.version <= current[0].version: if current is not None and record.version <= current[0].version:
continue continue
if current is None and len(self._entries) >= self.max_members: if current is None:
if not self.admit(record):
continue
if len(self._entries) >= self.max_members and not self._evict_dead(now):
continue continue
self._entries[record.key] = (record, now) self._entries[record.key] = (record, now)
changed += 1 changed += 1
self._purge(now) self._purge(now)
return changed return changed
def _evict_dead(self, now):
oldest_key = None
oldest_age = self.member_timeout
for key, (_, advanced) in self._entries.items():
age = now - advanced
if age > oldest_age:
oldest_key = key
oldest_age = age
if oldest_key is None:
return False
del self._entries[oldest_key]
return True
def _purge(self, now): def _purge(self, now):
for key in [key for key, (_, advanced) in self._entries.items() if now - advanced > self.forget_after]: for key in [key for key, (_, advanced) in self._entries.items() if now - advanced > self.forget_after]:
del self._entries[key] del self._entries[key]
+4
View File
@@ -35,6 +35,10 @@ class Chatter(Role):
def receive(self, text, caller_id): def receive(self, text, caller_id):
if not isinstance(text, str) or not text.strip() or len(text) > MAX_MESSAGE_LENGTH: if not isinstance(text, str) or not text.strip() or len(text) > MAX_MESSAGE_LENGTH:
raise ValueError("Invalid message") raise ValueError("Invalid message")
if not caller_id:
raise ValueError("Missing sender")
if self.context.node_by_id(caller_id) is None:
raise ValueError("Unknown sender")
self.storage.append(caller_id, "in", text, self.max_messages) self.storage.append(caller_id, "in", text, self.max_messages)
return True return True
+2 -2
View File
@@ -155,7 +155,7 @@ class Explorer(Role):
return [] return []
members = [] members = []
for row in rows[:setting("membership.max_members")]: for row in rows[:self.context.membership().max_records]:
try: try:
record = MemberRecord.from_dict(row) record = MemberRecord.from_dict(row)
except ValueError as e: except ValueError as e:
@@ -216,5 +216,5 @@ class Explorer(Role):
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
self.logger.warning(f"Ignoring bad member list from {ip}:{port}: {e}") self.logger.warning(f"Ignoring bad member list from {ip}:{port}: {e}")
return False return False
membership.merge(members[:setting("membership.max_members")]) membership.merge(members[:membership.max_records])
return True return True
+4 -2
View File
@@ -43,7 +43,6 @@ class Gossip(Role):
self.reprobe_interval = setting("membership.reprobe_interval") self.reprobe_interval = setting("membership.reprobe_interval")
self.fanout = setting("membership.fanout") self.fanout = setting("membership.fanout")
self.hello_timeout = setting("membership.hello_timeout") self.hello_timeout = setting("membership.hello_timeout")
self.max_members = setting("membership.max_members")
self.discover_port = setting("discovery.discover_port") self.discover_port = setting("discovery.discover_port")
self.probe_timeout = setting("discovery.probe_timeout") self.probe_timeout = setting("discovery.probe_timeout")
self.probe_workers = setting("discovery.probe_workers") self.probe_workers = setting("discovery.probe_workers")
@@ -73,10 +72,13 @@ class Gossip(Role):
records = self._parse_records(known) records = self._parse_records(known)
self.membership.merge([record] + records) self.membership.merge([record] + records)
if not self.membership.knows(record.key):
self.logger.debug(f"No room for {record.ip}:{record.port}, not admitting it")
return {"admitted": False, "members": []}
return {"admitted": True, "members": self.membership.export(), "responder": self.membership.self_record().to_dict()} return {"admitted": True, "members": self.membership.export(), "responder": self.membership.self_record().to_dict()}
def _parse_records(self, raw): def _parse_records(self, raw):
if not isinstance(raw, list) or len(raw) > self.max_members: if not isinstance(raw, list) or len(raw) > self.membership.max_records:
raise ValueError("Member list must be a list of bounded size") raise ValueError("Member list must be a list of bounded size")
return [MemberRecord.from_dict(item) for item in raw] return [MemberRecord.from_dict(item) for item in raw]
+39 -11
View File
@@ -96,12 +96,7 @@ class RoleManager:
self.logger.info("Roles already up to date") self.logger.info("Roles already up to date")
return return
context = RoleContext(self.network, self) added = self._create_roles(roles_to_add)
added = {}
for role_name in roles_to_add:
self.logger.debug(f"Creating new role {role_name}")
role_cls = self.role_registry.get_role_class_by_name(role_name)
added[role_name] = role_cls(context=context)
final_roles = { final_roles = {
name: current_roles[name] if name in current_roles else added[name] name: current_roles[name] if name in current_roles else added[name]
@@ -111,15 +106,45 @@ class RoleManager:
self.state_manager.set(StateManager.Data.Roles, list(final_roles.keys())) self.state_manager.set(StateManager.Data.Roles, list(final_roles.keys()))
self._publish(final_roles) self._publish(final_roles)
for role_name in roles_to_remove: self._start_roles(added, current_roles)
self.logger.debug(f"Stopping removed role {role_name}")
current_roles[role_name].stop()
removed = {name: current_roles[name] for name in roles_to_remove}
self._stop_roles(removed)
self.logger.info(f"Roles updated to: {list(final_roles.keys())}")
def _create_roles(self, role_names):
context = RoleContext(self.network, self)
added = {}
try:
for role_name in role_names:
self.logger.debug(f"Creating new role {role_name}")
role_cls = self.role_registry.get_role_class_by_name(role_name)
added[role_name] = role_cls(context=context)
except Exception:
self._stop_roles(added)
raise
return added
def _start_roles(self, added, previous_roles):
try:
for role_name, role in added.items(): for role_name, role in added.items():
self.logger.info(f"Starting role {role_name}") self.logger.info(f"Starting role {role_name}")
role.start() role.start()
except Exception:
self.logger.warning("A new role failed to start, going back to the previous roles")
self._stop_roles(added)
self.state_manager.set(StateManager.Data.Roles, list(previous_roles.keys()))
self._publish(previous_roles)
raise
self.logger.info(f"Roles updated to: {list(final_roles.keys())}") def _stop_roles(self, roles):
for role_name, role in roles.items():
self.logger.debug(f"Stopping role {role_name}")
try:
role.stop()
except Exception:
self.logger.exception(f"Could not stop role {role_name}")
def drop_roles(self, role_names): def drop_roles(self, role_names):
with self._lock: with self._lock:
@@ -132,7 +157,7 @@ class RoleManager:
def _decline(self, role_name): def _decline(self, role_name):
self.network.membership.decline(role_name, self.role_policy.decline_cooldown) self.network.membership.decline(role_name, self.role_policy.decline_cooldown)
def request_role(self, role_name): def request_role(self, role_name, allow_on_demand=False):
membership = self.network.membership membership = self.network.membership
with self._lock: with self._lock:
@@ -143,6 +168,9 @@ class RoleManager:
if role_name in held: if role_name in held:
return {"accepted": True, "reason": "already held"} return {"accepted": True, "reason": "already held"}
if self.role_registry.roles[role_name].on_demand and not allow_on_demand:
return {"accepted": False, "reason": "on-demand roles can only be added on the node itself"}
if membership.is_declined(role_name): if membership.is_declined(role_name):
return {"accepted": False, "reason": "recently declined"} return {"accepted": False, "reason": "recently declined"}
+12
View File
@@ -15,11 +15,23 @@ class RolePolicy:
class DefaultRolePolicy(RolePolicy): class DefaultRolePolicy(RolePolicy):
def __init__(self, max_roles=None, deny=None, min_scores=None, decline_cooldown=None): def __init__(self, max_roles=None, deny=None, min_scores=None, decline_cooldown=None):
self.max_roles = setting("role_policy.max_roles", max_roles) self.max_roles = setting("role_policy.max_roles", max_roles)
self._validate_max_roles()
self.deny = set(setting("role_policy.deny", deny)) self.deny = set(setting("role_policy.deny", deny))
raw_minimums = setting("role_policy.min_scores", min_scores) raw_minimums = setting("role_policy.min_scores", min_scores)
self.min_scores = {role: dict(minimums) for role, minimums in dict(raw_minimums).items()} self.min_scores = {role: dict(minimums) for role, minimums in dict(raw_minimums).items()}
self.decline_cooldown = setting("role_policy.decline_cooldown", decline_cooldown) self.decline_cooldown = setting("role_policy.decline_cooldown", decline_cooldown)
def _validate_max_roles(self):
if self.max_roles is None:
return
error = ValueError(f"role_policy.max_roles must be a whole number or null, got {self.max_roles!r}")
if isinstance(self.max_roles, bool):
raise error
if not isinstance(self.max_roles, int):
raise error
if self.max_roles < 0:
raise error
def _violation(self, role_name, scores): def _violation(self, role_name, scores):
if role_name in self.deny: if role_name in self.deny:
return "role is on the deny list" return "role is on the deny list"
+118 -22
View File
@@ -1,10 +1,75 @@
import base64 import base64
import random import random
import threading
from collections import Counter from collections import Counter
from enum import Enum from enum import Enum
from colony.core.logger.logging import get_logger
from colony.core.roles.role import Role, Command from colony.core.roles.role import Role, Command
from colony.core.storages.replica import ReplicaStorage from colony.core.storages.replica import ReplicaStorage
from colony.transport.framing import MAX_FRAME_SIZE
HASH_TIMEOUT = 3
RETRIEVE_TIMEOUT = 30
FRAME_HEADROOM = 4096
logger = get_logger("Replicator")
def reply_result(response):
if isinstance(response, dict) and response.get("status") == "ok":
return response.get("result")
return None
def majority_copy(context, local_checksum=None):
own_key = context.membership().key
checksums = {}
for node in context.nodes_with_role(Replicator):
if (node.ip, node.port) == own_key:
continue
try:
response = context.execute_on(node, Replicator.COMMANDS.HASH_SUM, timeout=HASH_TIMEOUT)
except Exception as e:
logger.warning(f"Could not get the replica checksum from {node.ip}:{node.port}: {e}")
continue
checksum = reply_result(response)
if isinstance(checksum, str):
checksums[node] = checksum
votes = list(checksums.values())
if local_checksum is not None:
votes.append(local_checksum)
if not votes:
return None, []
checksum = Counter(votes).most_common(1)[0][0]
matching = [node for node, node_checksum in checksums.items() if node_checksum == checksum]
return checksum, matching
def fetch_copy(context, nodes):
nodes = list(nodes)
random.shuffle(nodes)
for node in nodes:
try:
response = context.execute_on(node, Replicator.COMMANDS.RETRIEVE, timeout=RETRIEVE_TIMEOUT)
except Exception as e:
logger.warning(f"Could not retrieve the replica from {node.ip}:{node.port}: {e}")
continue
encoded = reply_result(response)
if not isinstance(encoded, str):
error = response.get("error") if isinstance(response, dict) else "bad reply"
logger.warning(f"{node.ip}:{node.port} could not send its replica: {error}")
continue
try:
return base64.b64decode(encoded, validate=True)
except ValueError:
logger.warning(f"{node.ip}:{node.port} sent a replica that is not valid base64")
return None
class Replicator(Role): class Replicator(Role):
@@ -23,43 +88,74 @@ class Replicator(Role):
} }
self.storage = storage or ReplicaStorage() self.storage = storage or ReplicaStorage()
self._write_lock = threading.Lock()
self._syncing = False
self._written_during_sync = {}
super().__init__(commands, context) super().__init__(commands, context)
def start(self): def start(self):
replicator_checksums = dict() with self._start_lock:
if self._thread is not None:
return
self._thread = threading.Thread(target=self._sync_safely, daemon=True, name="ReplicaSync")
self._thread.start()
for node in self.context.nodes_with_role(Replicator): def _sync_safely(self):
if (node.ip, node.port) == self.context.membership().key: try:
continue self.sync()
except Exception:
self.logger.exception("Replica sync failed")
response = self.context.execute_on(node, Replicator.COMMANDS.HASH_SUM) def sync(self):
if response['status'] != 'ok': with self._write_lock:
continue self._syncing = True
self._written_during_sync = {}
try:
self._sync_from_majority()
finally:
with self._write_lock:
self._syncing = False
self._written_during_sync = {}
checksum = response['result'] def _sync_from_majority(self):
checksum, nodes = majority_copy(self.context)
if checksum is None or self._stop_event.is_set():
return
if checksum == self.hash_sum():
return
replicator_checksums[node] = checksum data = fetch_copy(self.context, nodes)
if data is None or self._stop_event.is_set():
return
if not self.storage.archive_files(data) and self.storage.archive_files(self.storage.archive()):
self.logger.warning("The majority replica is empty, keeping the local files")
return
if replicator_checksums: with self._write_lock:
checksum = Counter(replicator_checksums.values()).most_common(1)[0][0] if self._stop_event.is_set():
matching_nodes = [ return
node self.storage.restore(data)
for node, node_checksum in replicator_checksums.items() for filename, content in self._written_during_sync.items():
if node_checksum == checksum self.storage.save(filename, content)
] self.logger.info("Replica synced from the cluster")
if checksum != self.hash_sum():
node = random.choice(matching_nodes)
response = self.context.execute_on(node, Replicator.COMMANDS.RETRIEVE)
self.storage.restore(base64.b64decode(response['result']))
def replicate(self, filename, content): def replicate(self, filename, content):
with self._write_lock:
self.storage.save(filename, content) self.storage.save(filename, content)
if self._syncing:
self._written_during_sync[filename] = content
return True return True
def retrieve(self): def retrieve(self):
return base64.b64encode(self.storage.archive()).decode() encoded = base64.b64encode(self.storage.archive()).decode()
if len(encoded) + FRAME_HEADROOM >= MAX_FRAME_SIZE:
self.logger.error(
f"The replica is {len(encoded)} bytes encoded, over the {MAX_FRAME_SIZE} byte frame limit. "
f"Relayed requests are also limited by gateway.max_payload_bytes"
)
raise ValueError("replica too large to retrieve")
return encoded
def hash_sum(self): def hash_sum(self):
return self.storage.dir_checksum() return self.storage.dir_checksum()
+3
View File
@@ -60,3 +60,6 @@ class Unit(Role):
def request_role(self, role): def request_role(self, role):
return self.context.get_role_manager().request_role(role) return self.context.get_role_manager().request_role(role)
def add_role_locally(self, role):
return self.context.get_role_manager().request_role(role, allow_on_demand=True)
+69 -1
View File
@@ -1,12 +1,15 @@
import threading
from enum import Enum from enum import Enum
from colony.core.storages.default import DefaultStorage from colony.core.storages.default import DefaultStorage
from colony.core.roles.replicator.role import Replicator from colony.core.roles.replicator.role import Replicator, majority_copy, fetch_copy
from colony.core.roles.role import Role, Command from colony.core.roles.role import Role, Command
class VaultWarden(Role): class VaultWarden(Role):
REPLICATE_TIMEOUT = 3 REPLICATE_TIMEOUT = 3
RESTORE_ATTEMPTS = 5
RESTORE_RETRY_SECONDS = 5
class COMMANDS(Enum): class COMMANDS(Enum):
LS = 'VaultWarden.ls' LS = 'VaultWarden.ls'
@@ -21,9 +24,18 @@ class VaultWarden(Role):
} }
self.storage = DefaultStorage() self.storage = DefaultStorage()
self._write_lock = threading.Lock()
self._written = False
super().__init__(commands, context) super().__init__(commands, context)
def start(self):
with self._start_lock:
if self._thread is not None:
return
self._thread = threading.Thread(target=self._restore_safely, daemon=True, name="VaultRestore")
self._thread.start()
def ls(self, dir): def ls(self, dir):
return self.storage.ls(dir) return self.storage.ls(dir)
@@ -31,7 +43,9 @@ class VaultWarden(Role):
return self.storage.open(file) return self.storage.open(file)
def overwrite(self, filename, content): def overwrite(self, filename, content):
with self._write_lock:
path = self.storage.save(filename, content) path = self.storage.save(filename, content)
self._written = True
if self.storage.SHARED: if self.storage.SHARED:
self._push_to_replicators(filename, content) self._push_to_replicators(filename, content)
return path return path
@@ -52,3 +66,57 @@ class VaultWarden(Role):
if not isinstance(resp, dict) or resp.get("status") != "ok": if not isinstance(resp, dict) or resp.get("status") != "ok":
error = resp.get("error") if isinstance(resp, dict) else "bad reply" error = resp.get("error") if isinstance(resp, dict) else "bad reply"
self.logger.warning(f"{target} did not accept {filename}: {error}") self.logger.warning(f"{target} did not accept {filename}: {error}")
def _restore_safely(self):
try:
self.restore_from_replicas()
except Exception:
self.logger.exception("Could not restore the vault from the replicas")
def _local_replica(self):
replicator = self.context.self().get_role_instances().get(Replicator.__name__)
if replicator is None:
return None
return replicator.storage
def restore_from_replicas(self):
for attempt in range(self.RESTORE_ATTEMPTS):
if attempt > 0 and self._stop_event.wait(self.RESTORE_RETRY_SECONDS):
return
if self._restore_once():
return
self.logger.warning("No replica could be read, the vault starts from its local files")
def _restore_once(self):
local = self._local_replica()
local_checksum = None
if local is not None:
local_checksum = local.dir_checksum()
checksum, nodes = majority_copy(self.context, local_checksum=local_checksum)
if checksum is None:
return False
if checksum == local_checksum:
data = local.archive()
else:
data = fetch_copy(self.context, nodes)
if data is None:
return False
if not self.storage.archive_files(data):
self.logger.info("The replicas are empty, keeping the local vault files")
return True
if self.storage.dir_checksum() == checksum:
return True
with self._write_lock:
if self._stop_event.is_set():
return True
if self._written:
self.logger.info("The vault was written before the restore finished, keeping the local files")
return True
self.storage.restore(data)
self.logger.info("Restored the vault from the replicas")
return True
+32
View File
@@ -96,6 +96,38 @@ class Storage:
return buffer.getvalue() return buffer.getvalue()
@staticmethod
def archive_files(data):
names = set()
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
for member in tar.getmembers():
if member.isfile():
names.add(member.name)
return names
def restore(self, data): def restore(self, data):
wanted = self.archive_files(data)
with _write_lock:
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar: with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
tar.extractall(self.storage_dir, filter="data") tar.extractall(self.storage_dir, filter="data")
root = Path(self.storage_dir)
self._prune_missing(root, wanted)
self._remove_empty_dirs(root)
@staticmethod
def _prune_missing(root, wanted):
for file in root.rglob("*"):
if not file.is_file() or file.name.startswith(".tmp-"):
continue
if file.relative_to(root).as_posix() not in wanted:
file.unlink()
@staticmethod
def _remove_empty_dirs(root):
directories = [p for p in root.rglob("*") if p.is_dir()]
directories.sort(key=lambda p: len(p.parts), reverse=True)
for directory in directories:
try:
directory.rmdir()
except OSError:
pass
-5
View File
@@ -17,11 +17,6 @@ class HardwareType(Enum):
LONGEVITY = 5 LONGEVITY = 5
class HardwarePriority:
hardware: HardwareType
priority: int
@dataclass @dataclass
class HardwarePreferences: class HardwarePreferences:
priorities: dict[HardwareType, int] priorities: dict[HardwareType, int]
+14 -1
View File
@@ -1,10 +1,14 @@
import json import json
import socket import socket
import ssl import ssl
import threading
from colony.settings import configuration from colony.settings import configuration
from colony.transport.framing import send_frame, recv_frame from colony.transport.framing import send_frame, recv_frame
_contexts = {}
_contexts_lock = threading.Lock()
class ConnectFailed(OSError): class ConnectFailed(OSError):
pass pass
@@ -19,6 +23,15 @@ class Whisperer:
self.cert_file = cert_file or getattr(configuration.whisperer, "cert_file", "certs/server.crt") self.cert_file = cert_file or getattr(configuration.whisperer, "cert_file", "certs/server.crt")
self.keyfile = keyfile or getattr(configuration.whisperer, "keyfile", "certs/server.key") self.keyfile = keyfile or getattr(configuration.whisperer, "keyfile", "certs/server.key")
def _ssl_context(self):
key = (str(self.cert_file), str(self.keyfile))
with _contexts_lock:
context = _contexts.get(key)
if context is None:
context = self._build_ssl_context()
_contexts[key] = context
return context
def _build_ssl_context(self): def _build_ssl_context(self):
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.minimum_version = ssl.TLSVersion.TLSv1_2 context.minimum_version = ssl.TLSVersion.TLSv1_2
@@ -30,7 +43,7 @@ class Whisperer:
def send(self, payload, timeout=DEFAULT_TIMEOUT, connect_timeout=None): def send(self, payload, timeout=DEFAULT_TIMEOUT, connect_timeout=None):
data = json.dumps(payload).encode() data = json.dumps(payload).encode()
context = self._build_ssl_context() context = self._ssl_context()
try: try:
sock = socket.create_connection((self.host, self.port), timeout=connect_timeout or timeout) sock = socket.create_connection((self.host, self.port), timeout=connect_timeout or timeout)
-3
View File
@@ -4,9 +4,6 @@ import socket
MAX_RANGE_SIZE = 1024 MAX_RANGE_SIZE = 1024
def get_local_subnet():
return ipaddress.ip_network(f"{get_local_ip()}/24", strict=False)
def get_local_ip(): def get_local_ip():
try: try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
+2 -2
View File
@@ -54,11 +54,11 @@ services:
COLONY_GATEWAY__ALLOW_COMMANDS: >- COLONY_GATEWAY__ALLOW_COMMANDS: >-
["Gossip.Hello","Unit.ShowStats","Unit.ShowRoles","Unit.ShowHardware", ["Gossip.Hello","Unit.ShowStats","Unit.ShowRoles","Unit.ShowHardware",
"Unit.ShowCluster","Unit.RequestRole","Replicator.HashSum", "Unit.ShowCluster","Unit.RequestRole","Replicator.HashSum",
"Replicator.Retrieve","Replicator.Replicate"] "Replicator.Retrieve","Replicator.Replicate","Chatter.Receive"]
COLONY_ROLES__ON_DEMAND: "[]" COLONY_ROLES__ON_DEMAND: "[]"
COLONY_ROLE_POLICY__MAX_ROLES: null COLONY_ROLE_POLICY__MAX_ROLES: "@none"
COLONY_ROLE_POLICY__DENY: "[]" COLONY_ROLE_POLICY__DENY: "[]"
COLONY_ROLE_POLICY__MIN_SCORES: "{}" COLONY_ROLE_POLICY__MIN_SCORES: "{}"
COLONY_ROLE_POLICY__DECLINE_COOLDOWN: 60 COLONY_ROLE_POLICY__DECLINE_COOLDOWN: 60
-1
View File
@@ -4,7 +4,6 @@ version = "0.1.0"
description = "Colony" description = "Colony"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
"dependency-injector==4.48.3",
"dynaconf==3.2.12", "dynaconf==3.2.12",
"Flask==3.1.3", "Flask==3.1.3",
"GPUtil==1.4.0", "GPUtil==1.4.0",
Generated
-17
View File
@@ -25,7 +25,6 @@ name = "colony"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "dependency-injector" },
{ name = "dynaconf" }, { name = "dynaconf" },
{ name = "flask" }, { name = "flask" },
{ name = "gputil" }, { name = "gputil" },
@@ -35,7 +34,6 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "dependency-injector", specifier = "==4.48.3" },
{ name = "dynaconf", specifier = "==3.2.12" }, { name = "dynaconf", specifier = "==3.2.12" },
{ name = "flask", specifier = "==3.1.3" }, { name = "flask", specifier = "==3.1.3" },
{ name = "gputil", specifier = "==1.4.0" }, { name = "gputil", specifier = "==1.4.0" },
@@ -43,21 +41,6 @@ requires-dist = [
{ name = "setuptools", specifier = "==80.9.0" }, { name = "setuptools", specifier = "==80.9.0" },
] ]
[[package]]
name = "dependency-injector"
version = "4.48.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/14/4f7b2d24d3ad50ac399262636cdf4b66b9318bfd0e9de5c61669625dc77c/dependency_injector-4.48.3.tar.gz", hash = "sha256:106e43a6a9959b2c49926edc45bdf07fdc377bc5fa1fd59012741590df30b20c", size = 1129251, upload-time = "2025-12-04T18:43:55.751Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/d1/bebb2921e1cfdeba992e57c62bcd871f4703fde852b1d64ceb0dd65540ee/dependency_injector-4.48.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:37d5b962d9ba67f6536557dec3c83136cefd7a747bbe8ba53b158e34e050bae5", size = 1746065, upload-time = "2025-12-04T18:43:06.655Z" },
{ url = "https://files.pythonhosted.org/packages/c3/da/068f23d12b55cda6e2d5e7783d1b602aa40f0889f35cf182ecfa1b12f7b8/dependency_injector-4.48.3-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:54aa2b8b8ff057555ab03ee98035eb41120d30f8ec220cf43aa3b117f6b9b1b1", size = 1829780, upload-time = "2025-12-04T18:43:08.497Z" },
{ url = "https://files.pythonhosted.org/packages/e4/7f/7d5f16a87d6cc4111be41c8593ae6b98ebcdf0dc797c6e08e303ce6155b7/dependency_injector-4.48.3-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a1f8d5b7dcf24773c27ddb9beab043753d8e251804f2398468944b93c767a35", size = 1741838, upload-time = "2025-12-04T18:43:10.072Z" },
{ url = "https://files.pythonhosted.org/packages/ea/1d/251c79858c9ebf53c6d8b51c9a653c794db0392070ab5a8f3f4a298a8b26/dependency_injector-4.48.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7ed11a944f5c84a5ad3f15bc9154f07affae2902967b4e47c74504d4595ed92f", size = 1726565, upload-time = "2025-12-04T18:43:11.892Z" },
{ url = "https://files.pythonhosted.org/packages/45/92/023bd0b72baf170368323412a911fe31fd1c5cccbd4d399744bdec21cecd/dependency_injector-4.48.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00c7ad5b5b5f9b1616c363770eed8c629a42dd0155b398c4458c949b8c0b9ae0", size = 1820764, upload-time = "2025-12-04T18:43:13.765Z" },
{ url = "https://files.pythonhosted.org/packages/ef/ae/0f6c65c6f67be9a7bcc18546ad08610b1cb294cef0da3d16502a1b336246/dependency_injector-4.48.3-cp310-abi3-win32.whl", hash = "sha256:2870a3b84ddb2e4b13731b7250b7714f0d4a07f6147a181b3ecbceed061b74f2", size = 1527160, upload-time = "2025-12-04T18:43:15.534Z" },
{ url = "https://files.pythonhosted.org/packages/ae/74/51e8d051d55ae3b88fb0d94356cc9998e070a8b0cf6c93ef8456b8d05c01/dependency_injector-4.48.3-cp310-abi3-win_amd64.whl", hash = "sha256:d0e6244b3674a48ee2c5b0af29bf20d2b0b5baef806dd1739af9aab9d20f08de", size = 1655409, upload-time = "2025-12-04T18:43:16.901Z" },
]
[[package]] [[package]]
name = "dynaconf" name = "dynaconf"
version = "3.2.12" version = "3.2.12"