replicator

This commit is contained in:
ciomek committed 2026-09-20 21:49:31 +02:00
1 parent 1484483100
commit 6513655948
23 files changed
+243 -34

No files matched your search

+2
View File
@@ -9,6 +9,8 @@ docs
tests tests
logs logs
storage storage
storages
data
state.json state.json
state.json.corrupt state.json.corrupt
.tmp-*.json .tmp-*.json
+2
View File
@@ -1,6 +1,8 @@
.stfolder .stfolder
logs logs
storage storage
/storages
/data
state.json state.json
state.json.corrupt state.json.corrupt
.tmp-*.json .tmp-*.json
+1 -1
View File
@@ -12,7 +12,7 @@ COPY . .
RUN chmod +x colonyctl.py && ln -s /app/colonyctl.py /usr/local/bin/colonyctl RUN chmod +x colonyctl.py && ln -s /app/colonyctl.py /usr/local/bin/colonyctl
VOLUME ["/app/storage", "/app/logs"] VOLUME ["/app/data", "/app/storages", "/app/logs"]
EXPOSE 65334/tcp 28149/udp 8080/tcp EXPOSE 65334/tcp 28149/udp 8080/tcp
+2 -1
View File
@@ -111,13 +111,14 @@ def role_states(payload):
for role in payload["roles"]: for role in payload["roles"]:
holders = sorted(address(member) for member in alive if role["name"] in member["roles"]) holders = sorted(address(member) for member in alive if role["name"] in member["roles"])
required = role["required"] required = role["required"]
limit = role.get("max", required)
if role.get("on_demand"): if role.get("on_demand"):
state = "held" if holders else "off" state = "held" if holders else "off"
elif required is None: elif required is None:
state = "general" state = "general"
elif len(holders) < required: elif len(holders) < required:
state = "unmet" state = "unmet"
elif len(holders) > required: elif limit is not None and len(holders) > limit:
state = "duplicate" state = "duplicate"
else: else:
state = "held" state = "held"
+12
View File
@@ -10,7 +10,16 @@ class RoleContext:
def execute(self, command, *args, **kwargs): def execute(self, command, *args, **kwargs):
return self._network.execute(command, *args, **kwargs) return self._network.execute(command, *args, **kwargs)
def execute_on(self, target, command, *args, **kwargs):
"""Run a command on one particular node, going through a gateway if it cannot be dialled.
`target` is a Node or a MemberRecord, anything with `ip` and `port`, for example an item of
`nodes_with_role(...)`. `execute` picks the node for you; this one does not.
"""
return self._network.send_command(target.ip, target.port, command, *args, **kwargs)
def execute_to_unregistered_node(self, ip, port, command, *args, **kwargs): def execute_to_unregistered_node(self, ip, port, command, *args, **kwargs):
"""The same as `execute_on` for a caller that only has an address."""
return self._network.send_command(ip, port, command, *args, **kwargs) return self._network.send_command(ip, port, command, *args, **kwargs)
def send_direct(self, ip, port, payload, timeout=None, connect_timeout=None): def send_direct(self, ip, port, payload, timeout=None, connect_timeout=None):
@@ -28,6 +37,9 @@ class RoleContext:
def peers(self): def peers(self):
return self._network.get_all_nodes() return self._network.get_all_nodes()
def nodes_with_role(self, role):
return self._network.get_nodes_of_role(role if isinstance(role, str) else role.__name__)
def membership(self): def membership(self):
return self._network.membership return self._network.membership
+10 -4
View File
@@ -1,3 +1,5 @@
import math
from colony.role_registry import HardwareType, RolePriority from colony.role_registry import HardwareType, RolePriority
HARDWARE_SCORE_KEY = { HARDWARE_SCORE_KEY = {
@@ -9,12 +11,16 @@ HARDWARE_SCORE_KEY = {
} }
def required_holders(role_stat): def required_holders(role_stat, cluster_size=1):
return 0 if role_stat.on_demand else 1 if role_stat.on_demand:
return 0
return max(1, math.ceil(cluster_size * role_stat.minimal_percentage / 100))
def max_holders(role_stat): def max_holders(role_stat):
return None if role_stat.on_demand else required_holders(role_stat) if role_stat.on_demand or role_stat.minimal_percentage:
return None
return required_holders(role_stat)
def assignable_roles(roles): def assignable_roles(roles):
@@ -29,7 +35,7 @@ def claimants(records, role_name):
def unmet_roles(records, roles): def unmet_roles(records, roles):
return [ return [
name for name in assignable_roles(roles) name for name in assignable_roles(roles)
if len(claimants(records, name)) < required_holders(roles[name]) if len(claimants(records, name)) < required_holders(roles[name], len(records))
] ]
+8 -6
View File
@@ -1,6 +1,6 @@
import json import json
from enum import Enum from enum import Enum
from typing import Optional from typing import List, Optional
from colony.core.network.membership import Membership from colony.core.network.membership import Membership
from colony.core.network.nodes import LocalNode, Node from colony.core.network.nodes import LocalNode, Node
@@ -56,11 +56,13 @@ class Network:
def get_all_nodes(self): def get_all_nodes(self):
return self.nodes return self.nodes
def get_nodes_of_role(self, role_name: str) -> List[Node]:
return [node for node in self.nodes if role_name in node.roles]
def get_node_of_role(self, role_name: str) -> Optional[Node]: def get_node_of_role(self, role_name: str) -> Optional[Node]:
for node in self.nodes: holders = self.get_nodes_of_role(role_name)
for role in node.roles: if holders:
if role == role_name: return holders[0]
return node
self.logger.warning(f"No node found with role: {role_name}") self.logger.warning(f"No node found with role: {role_name}")
return None return None
@@ -190,7 +192,7 @@ class Network:
return Whisperer(ip, port).send(request.collapse(), **options) return Whisperer(ip, port).send(request.collapse(), **options)
def load_network_from_snapshot(self, file_path: str = "storage/colony_snapshot.json"): def load_network_from_snapshot(self, file_path: str = "storages/default/colony_snapshot.json"):
self.logger.info(f"Loading network snapshot from {file_path}") self.logger.info(f"Loading network snapshot from {file_path}")
nodes = [] nodes = []
try: try:
+9 -3
View File
@@ -8,7 +8,8 @@ from dataclasses import dataclass, field, asdict
from colony.settings import setting from colony.settings import setting
ADMISSION_POLICIES = { ADMISSION_POLICIES = {
"open": lambda record: True, "open": lambda membership, record: True,
"closed": lambda membership, record: membership.knows(record.key),
} }
MAX_TEXT = 255 MAX_TEXT = 255
@@ -107,7 +108,7 @@ class MemberRecord:
def resolve_admission(admission): def resolve_admission(admission):
if callable(admission): if callable(admission):
return admission return lambda membership, record: admission(record)
try: try:
return ADMISSION_POLICIES[admission] return ADMISSION_POLICIES[admission]
except KeyError: except KeyError:
@@ -138,7 +139,12 @@ class Membership:
return self.clock() return self.clock()
def admit(self, record): def admit(self, record):
return bool(self._admission(record)) return bool(self._admission(self, record))
def knows(self, key):
with self._lock:
self._purge(self.clock())
return key in self._entries
def _current_self(self): def _current_self(self):
now = self.clock() now = self.clock()
Whitespace-only changes.
+66
View File
@@ -0,0 +1,66 @@
import base64
import random
from collections import Counter
from enum import Enum
from colony.core.roles.role import Role, Command
from colony.core.storages.replica import ReplicaStorage
class Replicator(Role):
class COMMANDS(Enum):
# Write to replicator
REPLICATE = 'Replicator.Replicate'
# Read from replicator
RETRIEVE = 'Replicator.Retrieve'
HASH_SUM = 'Replicator.HashSum'
def __init__(self, context, storage=None):
commands = {
self.COMMANDS.REPLICATE.value: Command(self.replicate),
self.COMMANDS.RETRIEVE.value: Command(self.retrieve),
self.COMMANDS.HASH_SUM.value: Command(self.hash_sum),
}
self.storage = storage or ReplicaStorage()
super().__init__(commands, context)
def start(self):
# catch up
replicator_checksums = dict()
for node in self.context.nodes_with_role(Replicator):
if (node.ip, node.port) == self.context.membership().key:
continue
response = self.context.execute_on(node, Replicator.COMMANDS.HASH_SUM)
if response['status'] != 'ok':
continue
checksum = response['result']
replicator_checksums[node] = checksum
if replicator_checksums:
checksum = Counter(replicator_checksums.values()).most_common(1)[0][0]
matching_nodes = [
node
for node, node_checksum in replicator_checksums.items()
if node_checksum == checksum
]
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):
self.storage.save(filename, content)
return True
def retrieve(self):
return base64.b64encode(self.storage.archive()).decode()
def hash_sum(self):
return self.storage.dir_checksum()
+4 -1
View File
@@ -35,13 +35,16 @@ class Unit(Role):
via = self.context.route_of(key) via = self.context.route_of(key)
member["via"] = list(via) if via else None member["via"] = list(via) if via else None
member["direct"] = self.context.is_direct(key) member["direct"] = self.context.is_direct(key)
alive = sum(1 for member in members if member["alive"])
return { return {
"members": members, "members": members,
"roles": [ "roles": [
{ {
"name": name, "name": name,
"priority": roles[name].priority.name.lower(), "priority": roles[name].priority.name.lower(),
"required": None if roles[name].priority == RolePriority.GENERAL else advice.required_holders(roles[name]), "required": None if roles[name].priority == RolePriority.GENERAL
else advice.required_holders(roles[name], alive),
"max": advice.max_holders(roles[name]),
"on_demand": roles[name].on_demand, "on_demand": roles[name].on_demand,
} }
for name in sorted(roles, key=lambda name: (roles[name].priority.value, name)) for name in sorted(roles, key=lambda name: (roles[name].priority.value, name))
+25 -2
View File
@@ -1,10 +1,13 @@
from enum import Enum from enum import Enum
from colony.core.storage import DefaultStorage from colony.core.storages.default import DefaultStorage
from colony.core.roles.replicator.role import Replicator
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
class COMMANDS(Enum): class COMMANDS(Enum):
LS = 'VaultWarden.ls' LS = 'VaultWarden.ls'
OPEN = 'VaultWarden.open' OPEN = 'VaultWarden.open'
@@ -28,4 +31,24 @@ class VaultWarden(Role):
return self.storage.open(file) return self.storage.open(file)
def overwrite(self, filename, content): def overwrite(self, filename, content):
return self.storage.save(filename, content) path = self.storage.save(filename, content)
if self.storage.SHARED:
self._push_to_replicators(filename, content)
return path
def _push_to_replicators(self, filename, content):
for record in self.context.membership().alive_records():
if Replicator.__name__ not in record.roles:
continue
target = f"{record.ip}:{record.port}"
try:
resp = self.context.execute_to_unregistered_node(
record.ip, record.port, Replicator.COMMANDS.REPLICATE, filename, content,
timeout=self.REPLICATE_TIMEOUT,
)
except Exception as e:
self.logger.warning(f"Could not replicate {filename} to {target}: {e}")
continue
if not isinstance(resp, dict) or resp.get("status") != "ok":
error = resp.get("error") if isinstance(resp, dict) else "bad reply"
self.logger.warning(f"{target} did not accept {filename}: {error}")
+2 -1
View File
@@ -51,11 +51,12 @@
function roleStates(members, roles) { function roleStates(members, roles) {
return roles.map((role) => { return roles.map((role) => {
const holders = members.filter((node) => node.alive && node.roles.includes(role.name)).length; const holders = members.filter((node) => node.alive && node.roles.includes(role.name)).length;
const limit = role.max === undefined ? role.required : role.max;
let state = "held"; let state = "held";
if (role.on_demand) state = holders ? "held" : "off"; if (role.on_demand) state = holders ? "held" : "off";
else if (role.required === null) state = "general"; else if (role.required === null) state = "general";
else if (holders < role.required) state = "unmet"; else if (holders < role.required) state = "unmet";
else if (holders > role.required) state = "duplicate"; else if (limit !== null && holders > limit) state = "duplicate";
return { name: role.name, state: state }; return { name: role.name, state: state };
}); });
} }
+2 -1
View File
@@ -20,10 +20,11 @@ class StateManager:
Data.Peers: [], Data.Peers: [],
} }
STATE_FILE = Path("state.json") STATE_FILE = Path("data") / "state.json"
def __init__(self, path=None): def __init__(self, path=None):
self._path = Path(path) if path is not None else self.STATE_FILE self._path = Path(path) if path is not None else self.STATE_FILE
self._path.parent.mkdir(parents=True, exist_ok=True)
self.logger = get_logger(self.__class__.__name__) self.logger = get_logger(self.__class__.__name__)
self._lock = threading.RLock() self._lock = threading.RLock()
self._data = {} self._data = {}
View File
Whitespace-only changes.
@@ -1,16 +1,21 @@
import hashlib
import io
import os import os
import json import json
import tarfile
import threading import threading
from enum import Enum from pathlib import Path
from colony.utils.files import atomic_write_json from colony.utils.files import atomic_write_json
STORAGE_DIR = "storage" STORAGES_DIR = "storages"
_write_lock = threading.Lock() _write_lock = threading.Lock()
class Storage: class Storage:
SHARED = False
def __init__(self, storage_dir): def __init__(self, storage_dir):
self.storage_dir = storage_dir self.storage_dir = storage_dir
@@ -70,11 +75,27 @@ class Storage:
return False return False
def dir_checksum(self):
h = hashlib.sha256()
path = Path(self.storage_dir)
class DefaultStorage(Storage): for file in sorted(p for p in path.rglob("*") if p.is_file() and not p.name.startswith(".tmp-")):
def __init__(self): h.update(str(file.relative_to(path)).encode())
super().__init__(STORAGE_DIR) with file.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
class DEFAULT_STORAGE_FILES(Enum): def archive(self):
COLONY_SNAPSHOT = 'colony_snapshot.json' buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
for file in sorted(p for p in Path(self.storage_dir).rglob("*") if p.is_file() and not p.name.startswith(".tmp-")):
tar.add(file, arcname=file.relative_to(self.storage_dir).as_posix())
return buffer.getvalue()
def restore(self, data):
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
tar.extractall(self.storage_dir, filter="data")
+15
View File
@@ -0,0 +1,15 @@
import os
from enum import Enum
from colony.core.storages.core import STORAGES_DIR, Storage
STORAGE_DIR = os.path.join(STORAGES_DIR, "default")
class DefaultStorage(Storage):
SHARED = True
COLONY_SNAPSHOT = 'colony_snapshot.json'
def __init__(self):
super().__init__(STORAGE_DIR)
+10
View File
@@ -0,0 +1,10 @@
import os
from colony.core.storages.core import STORAGES_DIR, Storage
REPLICA_DIR = os.path.join(STORAGES_DIR, "replica")
class ReplicaStorage(Storage):
def __init__(self):
super().__init__(REPLICA_DIR)
+2 -1
View File
@@ -31,12 +31,13 @@ class HardwarePreferences:
class RoleStats: class RoleStats:
def __init__(self, cls, priority: RolePriority, hardware_preferences: HardwarePreferences = None, on_demand: bool = False): def __init__(self, cls, priority: RolePriority, hardware_preferences: HardwarePreferences = None, on_demand: bool = False, minimal_percentage: int = 0):
self.name = cls.__name__ self.name = cls.__name__
self.cls = cls self.cls = cls
self.priority = priority self.priority = priority
self.hardware_preferences = hardware_preferences self.hardware_preferences = hardware_preferences
self.on_demand = on_demand self.on_demand = on_demand
self.minimal_percentage = max(0, min(minimal_percentage, 100))
class RoleRegistry: class RoleRegistry:
+3
View File
@@ -38,6 +38,9 @@ DEFAULTS = {
"Unit.ShowHardware", "Unit.ShowHardware",
"Unit.ShowCluster", "Unit.ShowCluster",
"Unit.RequestRole", "Unit.RequestRole",
"Replicator.HashSum",
"Replicator.Retrieve",
"Replicator.Replicate",
], ],
"web.host": "0.0.0.0", "web.host": "0.0.0.0",
"web.port": 8080, "web.port": 8080,
+24 -4
View File
@@ -13,7 +13,7 @@
}, },
"discovery": { "discovery": {
"range_start": "192.168.65.100", "range_start": "192.168.65.100",
"range_end": "192.168.65.120", "range_end": "192.168.65.100",
"discover_port": 28149, "discover_port": 28149,
"probe_timeout": 1.0, "probe_timeout": 1.0,
"probe_workers": 64 "probe_workers": 64
@@ -25,8 +25,8 @@
"fanout": 3, "fanout": 3,
"forget_after": 600, "forget_after": 600,
"hello_timeout": 3, "hello_timeout": 3,
"max_members": 1024, "max_members": 10,
"admission": "open" "admission": "closed"
}, },
"foreman": { "foreman": {
"interval": 5, "interval": 5,
@@ -38,8 +38,28 @@
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 8080 "port": 8080
}, },
"explorer": {
"hosts": []
},
"gateway": { "gateway": {
"max_attempts": 4 "client_fallback": true,
"connect_timeout": 2,
"direct_retry": 30,
"max_attempts": 4,
"forward_timeout": 8,
"max_concurrent": 16,
"max_payload_bytes": 4194304,
"allow_commands": [
"Gossip.Hello",
"Unit.ShowStats",
"Unit.ShowRoles",
"Unit.ShowHardware",
"Unit.ShowCluster",
"Unit.RequestRole",
"Replicator.HashSum",
"Replicator.Retrieve",
"Replicator.Replicate"
]
}, },
"role_policy": { "role_policy": {
"max_roles": null, "max_roles": null,
+4 -2
View File
@@ -5,7 +5,9 @@ services:
restart: unless-stopped restart: unless-stopped
network_mode: host network_mode: host
volumes: volumes:
- colony-storage:/app/storage - colony-data:/app/data
- colony-storages:/app/storages
volumes: volumes:
colony-storage: colony-data:
colony-storages:
+12
View File
@@ -1,4 +1,5 @@
from colony.core.roles.explorer.role import Explorer from colony.core.roles.explorer.role import Explorer
from colony.core.roles.replicator.role import Replicator
from colony.core.roles.unit.role import Unit from colony.core.roles.unit.role import Unit
from colony.role_registry import RoleStats, HardwarePreferences, RolePriority, HardwareType from colony.role_registry import RoleStats, HardwarePreferences, RolePriority, HardwareType
from colony.core.roles.foreman.role import Foreman from colony.core.roles.foreman.role import Foreman
@@ -48,4 +49,15 @@ available_roles = [
cls=Gateway, cls=Gateway,
priority=RolePriority.GENERAL, priority=RolePriority.GENERAL,
), ),
RoleStats(
cls=Replicator,
priority=RolePriority.CRITICAL,
hardware_preferences=HardwarePreferences(
{
HardwareType.DISK_SPACE: 1,
HardwareType.LONGEVITY: 3,
}
),
minimal_percentage=51
)
] ]