Foreman
This commit is contained in:
1 parent
30e758fe3a
commit
9add2c6382
8 files changed
+261
-11
No files matched your search
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
.stfolder
|
.stfolder
|
||||||
logs
|
logs
|
||||||
storage
|
storage
|
||||||
|
state.json
|
||||||
|
|
||||||
# Created by https://www.toptal.com/developers/gitignore/api/python
|
# Created by https://www.toptal.com/developers/gitignore/api/python
|
||||||
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
||||||
@@ -165,7 +166,7 @@ cython_debug/
|
|||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
#.idea/
|
.idea/
|
||||||
|
|
||||||
### Python Patch ###
|
### Python Patch ###
|
||||||
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from colony.core.network.nodes import LocalNode
|
from colony.core.network.nodes import LocalNode
|
||||||
|
from colony.role_registry import RoleRegistry
|
||||||
|
|
||||||
|
|
||||||
class RoleContext:
|
class RoleContext:
|
||||||
@@ -20,3 +21,9 @@ class RoleContext:
|
|||||||
|
|
||||||
def set_self_node_roles(self, roles):
|
def set_self_node_roles(self, roles):
|
||||||
self._role_manager.set_roles(roles)
|
self._role_manager.set_roles(roles)
|
||||||
|
|
||||||
|
def get_role_manager(self):
|
||||||
|
return self._role_manager
|
||||||
|
|
||||||
|
def get_role_registry(self) -> RoleRegistry:
|
||||||
|
return self._role_manager.role_registry
|
||||||
@@ -14,8 +14,6 @@ class Network:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.logger = get_logger(self.__class__.__name__)
|
self.logger = get_logger(self.__class__.__name__)
|
||||||
|
|
||||||
self.nodes = []
|
|
||||||
|
|
||||||
self.self_node = LocalNode(
|
self.self_node = LocalNode(
|
||||||
hostname=get_hostname(),
|
hostname=get_hostname(),
|
||||||
ip=get_local_ip(),
|
ip=get_local_ip(),
|
||||||
@@ -24,6 +22,8 @@ class Network:
|
|||||||
)
|
)
|
||||||
self.logger.info(f"Self node created: {self.self_node.id}")
|
self.logger.info(f"Self node created: {self.self_node.id}")
|
||||||
|
|
||||||
|
self.nodes = self.load_network_from_snapshot()
|
||||||
|
|
||||||
if not self.nodes:
|
if not self.nodes:
|
||||||
self.nodes.append(self.self_node)
|
self.nodes.append(self.self_node)
|
||||||
|
|
||||||
@@ -72,12 +72,18 @@ class Network:
|
|||||||
self.logger.warning(f"Snapshot file {file_path} not found. Starting with empty network")
|
self.logger.warning(f"Snapshot file {file_path} not found. Starting with empty network")
|
||||||
snapshot = {"nodes": []}
|
snapshot = {"nodes": []}
|
||||||
|
|
||||||
for node_data in snapshot.get("nodes", []):
|
for node_ip, node_data in snapshot.items():
|
||||||
|
result = node_data.get('result')
|
||||||
|
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
self.logger.warning(f"Skipping node {node_ip}: result is not a dict")
|
||||||
|
continue
|
||||||
|
|
||||||
node = Node(
|
node = Node(
|
||||||
hostname=node_data["hostname"],
|
hostname=result["hostname"],
|
||||||
ip=node_data["ip"],
|
ip=result["ip"],
|
||||||
roles=node_data.get("roles", []),
|
port=result["port"],
|
||||||
port=node_data["port"]
|
roles=result.get("roles", [])
|
||||||
)
|
)
|
||||||
self.logger.info(f"Loaded node from snapshot: {node.id}")
|
self.logger.info(f"Loaded node from snapshot: {node.id}")
|
||||||
nodes.append(node)
|
nodes.append(node)
|
||||||
|
|||||||
@@ -41,6 +41,20 @@ class HardwareStatistics:
|
|||||||
def to_json(self):
|
def to_json(self):
|
||||||
return json.dumps(self.to_dict(), indent=2)
|
return json.dumps(self.to_dict(), indent=2)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(hw_dict: str):
|
||||||
|
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),
|
||||||
@@ -98,3 +112,98 @@ class HardwareStatistics:
|
|||||||
"machine": platform.machine(),
|
"machine": platform.machine(),
|
||||||
"processor": platform.processor()
|
"processor": platform.processor()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HardwareScorer:
|
||||||
|
@staticmethod
|
||||||
|
def cpu_score(hw_obj) -> float:
|
||||||
|
"""
|
||||||
|
Compute available CPU units:
|
||||||
|
(cores + threads) adjusted for current usage.
|
||||||
|
Returns a float representing "effective compute units" currently free.
|
||||||
|
"""
|
||||||
|
cpu = hw_obj.cpu
|
||||||
|
if not cpu:
|
||||||
|
return 0.0
|
||||||
|
cores = cpu.get("cores", 1)
|
||||||
|
threads = cpu.get("threads", 1)
|
||||||
|
usage = cpu.get("usage_percent", 0) # current CPU utilization in percent
|
||||||
|
available = (cores + threads) * (1 - usage / 100)
|
||||||
|
return round(available, 2)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def memory_score(hw_obj) -> float:
|
||||||
|
"""
|
||||||
|
Compute memory available in GB.
|
||||||
|
Converts total bytes to GB and multiplies by free percentage.
|
||||||
|
"""
|
||||||
|
mem = hw_obj.mem
|
||||||
|
if not mem:
|
||||||
|
return 0.0
|
||||||
|
total = mem.get("total", 1)
|
||||||
|
percent_used = mem.get("percent", 100)
|
||||||
|
free_gb = (total / (1024**3)) * (1 - percent_used / 100)
|
||||||
|
return round(free_gb, 2)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def gpu_score(hw_obj) -> float:
|
||||||
|
"""
|
||||||
|
Compute total GPU memory free in GB across all GPUs.
|
||||||
|
Accounts for current GPU load.
|
||||||
|
"""
|
||||||
|
gpus = hw_obj.gpu
|
||||||
|
if not gpus:
|
||||||
|
return 0.0
|
||||||
|
total_free_gb = 0
|
||||||
|
for gpu in gpus:
|
||||||
|
memory_total = gpu.get("memory_total", 1)
|
||||||
|
load = gpu.get("load", 100) # GPU utilization in percent
|
||||||
|
free_gb = (memory_total / 1024) * (1 - load / 100)
|
||||||
|
total_free_gb += free_gb
|
||||||
|
return round(total_free_gb, 2)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def disk_score(hw_obj) -> float:
|
||||||
|
"""
|
||||||
|
Compute total disk space free in GB across all disks.
|
||||||
|
Accounts for current usage percentage.
|
||||||
|
"""
|
||||||
|
disks = hw_obj.disk
|
||||||
|
if not disks:
|
||||||
|
return 0.0
|
||||||
|
total_free_gb = 0
|
||||||
|
for d in disks:
|
||||||
|
total_gb = d.get("total", 1) / (1024**3)
|
||||||
|
percent_used = d.get("percent", 100)
|
||||||
|
free_gb = total_gb * (1 - percent_used / 100)
|
||||||
|
total_free_gb += free_gb
|
||||||
|
return round(total_free_gb, 2)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def longevity_score(hw_obj) -> float:
|
||||||
|
"""
|
||||||
|
Compute uptime in hours.
|
||||||
|
Converts boot_time from ISO format to datetime, then calculates
|
||||||
|
the difference from the current time.
|
||||||
|
"""
|
||||||
|
boot_time_iso = hw_obj.boot_time
|
||||||
|
try:
|
||||||
|
boot_time = datetime.datetime.fromisoformat(boot_time_iso)
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
uptime_hours = (datetime.datetime.now() - boot_time).total_seconds() / 3600
|
||||||
|
return round(uptime_hours, 2)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_object(cls, hw_obj) -> dict:
|
||||||
|
"""
|
||||||
|
Convenience method to get all scores in one dictionary.
|
||||||
|
Keys: 'cpu', 'memory', 'gpu', 'disk', 'longevity'.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"cpu": cls.cpu_score(hw_obj),
|
||||||
|
"memory": cls.memory_score(hw_obj),
|
||||||
|
"gpu": cls.gpu_score(hw_obj),
|
||||||
|
"disk": cls.disk_score(hw_obj),
|
||||||
|
"longevity": cls.longevity_score(hw_obj)
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ class Explorer(Role):
|
|||||||
try:
|
try:
|
||||||
port = Discover.request(str(ip))
|
port = Discover.request(str(ip))
|
||||||
peers[str(ip)] = port
|
peers[str(ip)] = port
|
||||||
except (TimeoutError, ConnectionRefusedError):
|
except (TimeoutError, ConnectionRefusedError, OSError):
|
||||||
continue
|
continue
|
||||||
return peers
|
return peers
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
from colony.core.network.hardware import HardwareStatistics, HardwareScorer
|
||||||
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.role_registry import HardwareType, RolePriority
|
||||||
|
|
||||||
|
_HARDWARE_SCORE_KEY = {
|
||||||
|
HardwareType.DISK_SPACE: "disk",
|
||||||
|
HardwareType.CPU: "cpu",
|
||||||
|
HardwareType.GPU: "gpu",
|
||||||
|
HardwareType.RAM: "memory",
|
||||||
|
HardwareType.LONGEVITY: "longevity",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Foreman(Role):
|
class Foreman(Role):
|
||||||
@@ -17,5 +28,113 @@ class Foreman(Role):
|
|||||||
|
|
||||||
super().__init__(commands, context)
|
super().__init__(commands, context)
|
||||||
|
|
||||||
|
def _peers_for_assignment(self):
|
||||||
|
self_node = self.context.self()
|
||||||
|
by_key = {}
|
||||||
|
for p in self.context.peers():
|
||||||
|
by_key[(p.ip, p.port)] = p
|
||||||
|
k = (self_node.ip, self_node.port)
|
||||||
|
if k not in by_key:
|
||||||
|
by_key[k] = self_node
|
||||||
|
return list(by_key.values())
|
||||||
|
|
||||||
|
def _fetch_live_roles(self, peers):
|
||||||
|
live = {}
|
||||||
|
for peer in peers:
|
||||||
|
resp = peer.execute(Unit.COMMANDS.SHOW_STATS)
|
||||||
|
if resp.get("status") != "ok":
|
||||||
|
self.logger.warning(
|
||||||
|
"SHOW_STATS failed for %s:%s, assuming no roles",
|
||||||
|
peer.ip,
|
||||||
|
peer.port,
|
||||||
|
)
|
||||||
|
live[peer] = []
|
||||||
|
continue
|
||||||
|
live[peer] = list(resp["result"].get("roles", []))
|
||||||
|
return live
|
||||||
|
|
||||||
|
def _assign_critical_holders(self, peers, critical_roles, live_roles):
|
||||||
|
peer_list = list(peers)
|
||||||
|
n = len(peer_list)
|
||||||
|
holders = {}
|
||||||
|
for i, critical_role in enumerate(critical_roles):
|
||||||
|
current = next(
|
||||||
|
(p for p in peer_list if critical_role in live_roles.get(p, ())),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if current is not None:
|
||||||
|
holders[critical_role] = current
|
||||||
|
else:
|
||||||
|
holders[critical_role] = peer_list[i % n]
|
||||||
|
return holders
|
||||||
|
|
||||||
|
def _best_peer_for_optional(self, peers, role_stat):
|
||||||
|
best_score = float("-inf")
|
||||||
|
best_peer = None
|
||||||
|
for peer in peers:
|
||||||
|
hw_resp = peer.execute(Unit.COMMANDS.SHOW_HARDWARE)
|
||||||
|
if hw_resp.get("status") != "ok":
|
||||||
|
continue
|
||||||
|
hw_obj = HardwareStatistics.from_dict(hw_resp["result"])
|
||||||
|
hs = HardwareScorer.from_object(hw_obj)
|
||||||
|
score = 0.0
|
||||||
|
if role_stat.hardware_preferences:
|
||||||
|
for hw_type, weight in role_stat.hardware_preferences.priorities.items():
|
||||||
|
key = _HARDWARE_SCORE_KEY.get(hw_type)
|
||||||
|
if key is not None:
|
||||||
|
score += hs.get(key, 0) * weight
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_peer = peer
|
||||||
|
return best_peer
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _dedupe_preserve_order(role_names):
|
||||||
|
seen = set()
|
||||||
|
out = []
|
||||||
|
for r in role_names:
|
||||||
|
if r not in seen:
|
||||||
|
seen.add(r)
|
||||||
|
out.append(r)
|
||||||
|
return out
|
||||||
|
|
||||||
def loop(self):
|
def loop(self):
|
||||||
self.context.self().execute(Unit.COMMANDS.SET_ROLES, ['Unit', 'Foreman', 'Explorer', 'VaultWarden'])
|
role_registry = self.context.get_role_registry()
|
||||||
|
roles = role_registry.roles
|
||||||
|
|
||||||
|
general_roles = sorted(
|
||||||
|
name for name, stats in roles.items() if stats.priority == RolePriority.GENERAL
|
||||||
|
)
|
||||||
|
critical_roles = sorted(
|
||||||
|
name for name, stats in roles.items() if stats.priority == RolePriority.CRITICAL
|
||||||
|
)
|
||||||
|
optional_roles = sorted(
|
||||||
|
name
|
||||||
|
for name, stats in roles.items()
|
||||||
|
if stats.priority not in (RolePriority.GENERAL, RolePriority.CRITICAL)
|
||||||
|
)
|
||||||
|
|
||||||
|
peers = self._peers_for_assignment()
|
||||||
|
if not peers:
|
||||||
|
return
|
||||||
|
|
||||||
|
live_roles = self._fetch_live_roles(peers)
|
||||||
|
critical_holders = self._assign_critical_holders(
|
||||||
|
peers, critical_roles, live_roles
|
||||||
|
)
|
||||||
|
|
||||||
|
desired = {peer: list(general_roles) for peer in peers}
|
||||||
|
for critical_role, holder in critical_holders.items():
|
||||||
|
desired[holder].append(critical_role)
|
||||||
|
|
||||||
|
for role_name in optional_roles:
|
||||||
|
role_stat = roles[role_name]
|
||||||
|
best_peer = self._best_peer_for_optional(peers, role_stat)
|
||||||
|
if best_peer is not None:
|
||||||
|
desired[best_peer].append(role_name)
|
||||||
|
|
||||||
|
for peer in peers:
|
||||||
|
peer.execute(
|
||||||
|
Unit.COMMANDS.SET_ROLES,
|
||||||
|
self._dedupe_preserve_order(desired[peer]),
|
||||||
|
)
|
||||||
@@ -17,7 +17,11 @@ class StateManager:
|
|||||||
def _load(self):
|
def _load(self):
|
||||||
if self.STATE_FILE.exists():
|
if self.STATE_FILE.exists():
|
||||||
with open(self.STATE_FILE, "r", encoding="utf-8") as f:
|
with open(self.STATE_FILE, "r", encoding="utf-8") as f:
|
||||||
self._data = json.load(f)
|
try:
|
||||||
|
self._data = json.load(f)
|
||||||
|
except json.decoder.JSONDecodeError:
|
||||||
|
self._data = {}
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self._data = {}
|
self._data = {}
|
||||||
self._save()
|
self._save()
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ from colony.settings import configuration
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
state_manager = StateManager()
|
state_manager = StateManager()
|
||||||
|
|
||||||
|
if 'Unit' not in state_manager.get(StateManager.Data.Roles):
|
||||||
|
state_manager.set(StateManager.Data.Roles, ['Unit', 'Foreman'])
|
||||||
|
|
||||||
network = Network()
|
network = Network()
|
||||||
role_registry = RoleRegistry(role_list=available_roles)
|
role_registry = RoleRegistry(role_list=available_roles)
|
||||||
dispatcher = Dispatcher(network=network, role_registry=role_registry)
|
dispatcher = Dispatcher(network=network, role_registry=role_registry)
|
||||||
|
|||||||
Reference in new issue
Block a user