This commit is contained in:
1 parent
d046ca2dd8
commit
5b2290cf97
22 files changed
+383
-155
No files matched your search
@@ -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
@@ -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():
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
class NetworkException(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class NoRoleException(Exception):
|
|
||||||
pass
|
|
||||||
@@ -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
|
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in new issue
Block a user