gateway
This commit is contained in:
1 parent
5bf545d4c6
commit
eb32643156
17 files changed
+511
-47
No files matched your search
+12
-1
@@ -125,6 +125,10 @@ def role_states(payload):
|
|||||||
return states
|
return states
|
||||||
|
|
||||||
|
|
||||||
|
def address_of(key):
|
||||||
|
return f"{key[0]}:{key[1]}" if key else ""
|
||||||
|
|
||||||
|
|
||||||
def render_status(payload):
|
def render_status(payload):
|
||||||
members = payload["members"]
|
members = payload["members"]
|
||||||
|
|
||||||
@@ -142,7 +146,8 @@ def render_status(payload):
|
|||||||
title += f", {down} down"
|
title += f", {down} down"
|
||||||
|
|
||||||
show_declined = any(member["declined"] for member in members)
|
show_declined = any(member["declined"] for member in members)
|
||||||
node_headers = ["NODE", "ADDRESS", "STATE", "SEEN", "ROLES"] + (["DECLINED"] if show_declined else [])
|
show_route = any(member.get("via") for member in members)
|
||||||
|
node_headers = ["NODE", "ADDRESS", "STATE", "SEEN", "ROLES"] + (["DECLINED"] if show_declined else []) + (["ROUTE"] if show_route else [])
|
||||||
node_rows = []
|
node_rows = []
|
||||||
for member in members:
|
for member in members:
|
||||||
label = labels[address(member)] + (" (this node)" if member["self"] else "")
|
label = labels[address(member)] + (" (this node)" if member["self"] else "")
|
||||||
@@ -155,6 +160,12 @@ def render_status(payload):
|
|||||||
]
|
]
|
||||||
if show_declined:
|
if show_declined:
|
||||||
row.append(" ".join(member["declined"]) or "-")
|
row.append(" ".join(member["declined"]) or "-")
|
||||||
|
if show_route:
|
||||||
|
via = member.get("via")
|
||||||
|
if via:
|
||||||
|
row.append(f"via {labels.get(address_of(via), address_of(via))}")
|
||||||
|
else:
|
||||||
|
row.append("direct" if member.get("direct") or member["self"] else "unknown")
|
||||||
node_rows.append(row)
|
node_rows.append(row)
|
||||||
|
|
||||||
alive_addresses = {address(member) for member in alive}
|
alive_addresses = {address(member) for member in alive}
|
||||||
|
|||||||
+13
-1
@@ -11,7 +11,19 @@ class RoleContext:
|
|||||||
return self._network.execute(command, *args, **kwargs)
|
return self._network.execute(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):
|
||||||
return self._network.execute_to_unregistered_node(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):
|
||||||
|
return self._network.send_direct(ip, port, payload, timeout, connect_timeout)
|
||||||
|
|
||||||
|
def is_direct(self, key):
|
||||||
|
return self._network.is_direct(key)
|
||||||
|
|
||||||
|
def route_of(self, key):
|
||||||
|
return self._network.route_of(key)
|
||||||
|
|
||||||
|
def set_dial_alias(self, key, address):
|
||||||
|
self._network.set_dial_alias(key, address)
|
||||||
|
|
||||||
def peers(self):
|
def peers(self):
|
||||||
return self._network.get_all_nodes()
|
return self._network.get_all_nodes()
|
||||||
|
|||||||
+23
-23
@@ -1,6 +1,28 @@
|
|||||||
from colony.core.logger.logging import get_logger
|
from colony.core.logger.logging import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
def parse_command(payload):
|
||||||
|
command = payload.get("command") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(command, dict):
|
||||||
|
raise ValueError("Payload has no command")
|
||||||
|
|
||||||
|
role_name = command.get("role")
|
||||||
|
command_name = command.get("name")
|
||||||
|
if not isinstance(role_name, str) or not role_name or not isinstance(command_name, str) or not command_name:
|
||||||
|
raise ValueError("Command needs a role and a name")
|
||||||
|
|
||||||
|
args = command.get("args")
|
||||||
|
if args is None:
|
||||||
|
args = []
|
||||||
|
kwargs = command.get("kwargs")
|
||||||
|
if kwargs is None:
|
||||||
|
kwargs = {}
|
||||||
|
if not isinstance(args, (list, tuple)) or not isinstance(kwargs, dict):
|
||||||
|
raise ValueError("Command args must be a list and kwargs a dict")
|
||||||
|
|
||||||
|
return role_name, command_name, tuple(args), kwargs
|
||||||
|
|
||||||
|
|
||||||
class Dispatcher:
|
class Dispatcher:
|
||||||
def __init__(self, network, role_registry):
|
def __init__(self, network, role_registry):
|
||||||
self.logger = get_logger(self.__class__.__name__)
|
self.logger = get_logger(self.__class__.__name__)
|
||||||
@@ -8,30 +30,8 @@ class Dispatcher:
|
|||||||
self.network = network
|
self.network = network
|
||||||
self.role_registry = role_registry
|
self.role_registry = role_registry
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse(payload):
|
|
||||||
command = payload.get("command") if isinstance(payload, dict) else None
|
|
||||||
if not isinstance(command, dict):
|
|
||||||
raise ValueError("Payload has no command")
|
|
||||||
|
|
||||||
role_name = command.get("role")
|
|
||||||
command_name = command.get("name")
|
|
||||||
if not isinstance(role_name, str) or not role_name or not isinstance(command_name, str) or not command_name:
|
|
||||||
raise ValueError("Command needs a role and a name")
|
|
||||||
|
|
||||||
args = command.get("args")
|
|
||||||
if args is None:
|
|
||||||
args = []
|
|
||||||
kwargs = command.get("kwargs")
|
|
||||||
if kwargs is None:
|
|
||||||
kwargs = {}
|
|
||||||
if not isinstance(args, (list, tuple)) or not isinstance(kwargs, dict):
|
|
||||||
raise ValueError("Command args must be a list and kwargs a dict")
|
|
||||||
|
|
||||||
return role_name, command_name, tuple(args), kwargs
|
|
||||||
|
|
||||||
def dispatch(self, payload):
|
def dispatch(self, payload):
|
||||||
role_name, command_name, args, kwargs = self._parse(payload)
|
role_name, command_name, args, kwargs = parse_command(payload)
|
||||||
self.logger.debug(f"Dispatching payload for command {role_name}.{command_name}")
|
self.logger.debug(f"Dispatching payload for command {role_name}.{command_name}")
|
||||||
|
|
||||||
role_entry = self.role_registry.get_role_class_by_name(role_name)
|
role_entry = self.role_registry.get_role_class_by_name(role_name)
|
||||||
|
|||||||
+122
-4
@@ -4,15 +4,16 @@ from typing import 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
|
||||||
|
from colony.core.network.relay import GATEWAY_ROLE, RelayError, build_forward, read_reply
|
||||||
from colony.core.network.requests import CommandRequest
|
from colony.core.network.requests import CommandRequest
|
||||||
from colony.core.logger.logging import get_logger
|
from colony.core.logger.logging import get_logger
|
||||||
from colony.settings import configuration
|
from colony.settings import configuration, setting
|
||||||
from colony.transport.whisperer import Whisperer
|
from colony.transport.whisperer import ConnectFailed, Whisperer
|
||||||
from colony.utils.network import get_local_ip, get_hostname
|
from colony.utils.network import get_local_ip, get_hostname
|
||||||
|
|
||||||
|
|
||||||
class Network:
|
class Network:
|
||||||
def __init__(self, ip=None, port=None, hostname=None, membership=None):
|
def __init__(self, ip=None, port=None, hostname=None, membership=None, sender=None):
|
||||||
self.logger = get_logger(self.__class__.__name__)
|
self.logger = get_logger(self.__class__.__name__)
|
||||||
|
|
||||||
self.self_node = LocalNode(
|
self.self_node = LocalNode(
|
||||||
@@ -27,6 +28,19 @@ class Network:
|
|||||||
self.self_node.ip, self.self_node.port, self.self_node.hostname
|
self.self_node.ip, self.self_node.port, self.self_node.hostname
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._sender = sender or self._whisper
|
||||||
|
self._client_fallback = setting("gateway.client_fallback")
|
||||||
|
self._connect_timeout = setting("gateway.connect_timeout")
|
||||||
|
self._direct_retry = setting("gateway.direct_retry")
|
||||||
|
self._max_attempts = setting("gateway.max_attempts")
|
||||||
|
self._forward_timeout = setting("gateway.forward_timeout")
|
||||||
|
self._direct_failed = {}
|
||||||
|
self._gateway_bad = {}
|
||||||
|
self._no_route = {}
|
||||||
|
self._direct_ok = {}
|
||||||
|
self._via = {}
|
||||||
|
self._dial = {}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def nodes(self):
|
def nodes(self):
|
||||||
peers = [
|
peers = [
|
||||||
@@ -59,10 +73,114 @@ class Network:
|
|||||||
if node is None:
|
if node is None:
|
||||||
raise ValueError(f"No node found with role {request.role}")
|
raise ValueError(f"No node found with role {request.role}")
|
||||||
|
|
||||||
result = node.execute(command, *args, **kwargs)
|
result = self.send_command(node.ip, node.port, command, *args, **kwargs)
|
||||||
self.logger.debug(f"Command {request.id} executed")
|
self.logger.debug(f"Command {request.id} executed")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _whisper(ip, port, payload, timeout, connect_timeout):
|
||||||
|
return Whisperer(ip, port).send(payload, timeout=timeout, connect_timeout=connect_timeout)
|
||||||
|
|
||||||
|
def set_dial_alias(self, key, address):
|
||||||
|
key, address = tuple(key), tuple(address)
|
||||||
|
members = {record.key for record in self.membership.alive_records()}
|
||||||
|
if key == address or key == self.membership.key or address in members:
|
||||||
|
return
|
||||||
|
self._dial[key] = (address, self.membership.now() + self.membership.forget_after)
|
||||||
|
|
||||||
|
def _dial_address(self, key):
|
||||||
|
entry = self._dial.get(key)
|
||||||
|
if entry is None:
|
||||||
|
return key
|
||||||
|
if entry[1] <= self.membership.now():
|
||||||
|
self._dial.pop(key, None)
|
||||||
|
return key
|
||||||
|
return entry[0]
|
||||||
|
|
||||||
|
def send_direct(self, ip, port, payload, timeout=None, connect_timeout=None):
|
||||||
|
ip, port = self._dial_address((ip, port))
|
||||||
|
return self._sender(ip, port, payload, timeout or Whisperer.DEFAULT_TIMEOUT, connect_timeout)
|
||||||
|
|
||||||
|
def _gateways(self, key, now):
|
||||||
|
if not self._client_fallback:
|
||||||
|
return []
|
||||||
|
candidates = [
|
||||||
|
record.key for record in self.membership.alive_records()
|
||||||
|
if GATEWAY_ROLE in record.roles and record.key not in (self.membership.key, key)
|
||||||
|
and self._gateway_bad.get(record.key, 0) <= now
|
||||||
|
and self._no_route.get((record.key, key), 0) <= now
|
||||||
|
]
|
||||||
|
preferred = self._via.get(key)
|
||||||
|
return sorted(candidates, key=lambda gateway: (gateway != preferred, gateway))
|
||||||
|
|
||||||
|
def is_direct(self, key):
|
||||||
|
seen = self._direct_ok.get(key)
|
||||||
|
return seen is not None and seen + self.membership.member_timeout * 2 > self.membership.now()
|
||||||
|
|
||||||
|
def route_of(self, key):
|
||||||
|
if self._direct_failed.get(key, 0) > self.membership.now():
|
||||||
|
return self._via.get(key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def send_command(self, ip, port, command, *args, timeout=None, **kwargs):
|
||||||
|
payload = CommandRequest.form(command, *args, **kwargs).collapse()
|
||||||
|
key = (ip, port)
|
||||||
|
now = self.membership.now()
|
||||||
|
gateways = self._gateways(key, now)
|
||||||
|
|
||||||
|
if gateways and self._direct_failed.get(key, 0) > now:
|
||||||
|
return self._relay(key, payload, timeout, gateways, None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.send_direct(ip, port, payload, timeout, self._connect_timeout if gateways else None)
|
||||||
|
except ConnectFailed as error:
|
||||||
|
if not gateways:
|
||||||
|
raise
|
||||||
|
self._direct_failed[key] = now + self._direct_retry
|
||||||
|
self._direct_ok.pop(key, None)
|
||||||
|
return self._relay(key, payload, timeout, gateways, error)
|
||||||
|
|
||||||
|
self._direct_failed.pop(key, None)
|
||||||
|
self._via.pop(key, None)
|
||||||
|
self._direct_ok[key] = now
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _relay(self, key, payload, timeout, gateways, direct_error):
|
||||||
|
envelope = build_forward(key, payload["command"], min(timeout or Whisperer.DEFAULT_TIMEOUT, self._forward_timeout))
|
||||||
|
now = self.membership.now()
|
||||||
|
|
||||||
|
for gateway in gateways[:self._max_attempts]:
|
||||||
|
try:
|
||||||
|
reply = self.send_direct(gateway[0], gateway[1], envelope, timeout, self._connect_timeout)
|
||||||
|
except ConnectFailed:
|
||||||
|
self._gateway_bad[gateway] = now + self._direct_retry
|
||||||
|
continue
|
||||||
|
|
||||||
|
outcome, value = read_reply(reply)
|
||||||
|
if outcome == "ok":
|
||||||
|
self._via[key] = gateway
|
||||||
|
return value
|
||||||
|
if outcome == "failover":
|
||||||
|
if value == "unreachable":
|
||||||
|
self._no_route[(gateway, key)] = now + self._direct_retry
|
||||||
|
else:
|
||||||
|
self._gateway_bad[gateway] = now + self._direct_retry
|
||||||
|
continue
|
||||||
|
raise RelayError(value)
|
||||||
|
|
||||||
|
if direct_error is not None:
|
||||||
|
raise direct_error
|
||||||
|
return self._last_resort(key, payload, timeout)
|
||||||
|
|
||||||
|
def _last_resort(self, key, payload, timeout):
|
||||||
|
try:
|
||||||
|
response = self.send_direct(key[0], key[1], payload, timeout, self._connect_timeout)
|
||||||
|
except ConnectFailed:
|
||||||
|
raise ConnectFailed(f"Cannot reach {key[0]}:{key[1]} directly or through a gateway") from None
|
||||||
|
self._direct_failed.pop(key, None)
|
||||||
|
self._via.pop(key, None)
|
||||||
|
self._direct_ok[key] = self.membership.now()
|
||||||
|
return response
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def execute_to_unregistered_node(ip, port, command: Enum, *args, timeout=None, **kwargs):
|
def execute_to_unregistered_node(ip, port, command: Enum, *args, timeout=None, **kwargs):
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
GATEWAY_ROLE = "Gateway"
|
||||||
|
FAIL_OVER_CODES = {"busy", "unreachable"}
|
||||||
|
|
||||||
|
|
||||||
|
class RelayError(OSError):
|
||||||
|
def __init__(self, code):
|
||||||
|
super().__init__(f"The gateway refused the request: {code}")
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
def build_forward(target, inner_command, timeout):
|
||||||
|
return {"command": {
|
||||||
|
"role": GATEWAY_ROLE,
|
||||||
|
"name": "Forward",
|
||||||
|
"args": [{"target": list(target), "command": inner_command, "timeout": timeout}],
|
||||||
|
"kwargs": {},
|
||||||
|
}}
|
||||||
|
|
||||||
|
|
||||||
|
def read_reply(envelope):
|
||||||
|
if not isinstance(envelope, dict) or envelope.get("status") != "ok" or not isinstance(envelope.get("result"), dict):
|
||||||
|
return "failover", "gateway_error"
|
||||||
|
|
||||||
|
result = envelope["result"]
|
||||||
|
if result.get("ok") is True and isinstance(result.get("response"), dict):
|
||||||
|
return "ok", result["response"]
|
||||||
|
|
||||||
|
code = result.get("code")
|
||||||
|
code = code if isinstance(code, str) else "error"
|
||||||
|
return ("failover" if code in FAIL_OVER_CODES else "refused"), code
|
||||||
Whitespace-only changes.
@@ -0,0 +1,105 @@
|
|||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import threading
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from colony.core.dispatcher import parse_command
|
||||||
|
from colony.core.network.relay import GATEWAY_ROLE
|
||||||
|
from colony.core.roles.role import Role, Command
|
||||||
|
from colony.settings import setting
|
||||||
|
from colony.transport.whisperer import ConnectFailed
|
||||||
|
|
||||||
|
|
||||||
|
def failure(code):
|
||||||
|
return {"ok": False, "code": code}
|
||||||
|
|
||||||
|
|
||||||
|
class Gateway(Role):
|
||||||
|
class COMMANDS(Enum):
|
||||||
|
FORWARD = 'Gateway.Forward'
|
||||||
|
|
||||||
|
def __init__(self, context):
|
||||||
|
commands = {
|
||||||
|
self.COMMANDS.FORWARD.value: Command(self.forward),
|
||||||
|
}
|
||||||
|
|
||||||
|
super().__init__(commands, context)
|
||||||
|
|
||||||
|
self.allow = set(setting("gateway.allow_commands"))
|
||||||
|
self.max_payload = setting("gateway.max_payload_bytes")
|
||||||
|
self.forward_timeout = setting("gateway.forward_timeout")
|
||||||
|
self.connect_timeout = setting("gateway.connect_timeout")
|
||||||
|
self._slots = threading.BoundedSemaphore(setting("gateway.max_concurrent"))
|
||||||
|
self.forwarded = 0
|
||||||
|
|
||||||
|
def forward(self, request):
|
||||||
|
result = self._forward(request)
|
||||||
|
if result["ok"]:
|
||||||
|
self.forwarded += 1
|
||||||
|
self.logger.info(f"Forward finished: {'ok' if result['ok'] else result['code']}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _read(self, request):
|
||||||
|
if not isinstance(request, dict):
|
||||||
|
raise ValueError("Request must be an object")
|
||||||
|
|
||||||
|
target = request.get("target")
|
||||||
|
if not isinstance(target, list) or len(target) != 2:
|
||||||
|
raise ValueError("Target must be [ip, port]")
|
||||||
|
ip, port = target
|
||||||
|
if not isinstance(ip, str):
|
||||||
|
raise ValueError("Bad target ip")
|
||||||
|
ip = str(ipaddress.ip_address(ip))
|
||||||
|
if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= 65535:
|
||||||
|
raise ValueError("Bad target port")
|
||||||
|
|
||||||
|
command = request.get("command")
|
||||||
|
parse_command({"command": command})
|
||||||
|
|
||||||
|
timeout = request.get("timeout", self.forward_timeout)
|
||||||
|
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0:
|
||||||
|
raise ValueError("Bad timeout")
|
||||||
|
return (ip, port), command, min(timeout, self.forward_timeout)
|
||||||
|
|
||||||
|
def _allowed(self, command):
|
||||||
|
name = f"{command['role']}.{command['name']}"
|
||||||
|
return command["role"] != GATEWAY_ROLE and ("*" in self.allow or name in self.allow)
|
||||||
|
|
||||||
|
def _is_member(self, target):
|
||||||
|
membership = self.context.membership()
|
||||||
|
return any(record.key == target for record in membership.alive_records() if record.key != membership.key)
|
||||||
|
|
||||||
|
def _forward(self, request):
|
||||||
|
try:
|
||||||
|
target, command, timeout = self._read(request)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return failure("bad_request")
|
||||||
|
|
||||||
|
if not self._allowed(command):
|
||||||
|
return failure("denied")
|
||||||
|
if not self._is_member(target):
|
||||||
|
return failure("unknown_target")
|
||||||
|
|
||||||
|
payload = {"command": command}
|
||||||
|
if len(json.dumps(payload)) > self.max_payload:
|
||||||
|
return failure("too_large")
|
||||||
|
if not self._slots.acquire(blocking=False):
|
||||||
|
return failure("busy")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.context.send_direct(
|
||||||
|
target[0], target[1], payload, timeout=timeout, connect_timeout=min(self.connect_timeout, timeout)
|
||||||
|
)
|
||||||
|
except ConnectFailed:
|
||||||
|
return failure("unreachable")
|
||||||
|
except TimeoutError:
|
||||||
|
return failure("timeout")
|
||||||
|
except Exception:
|
||||||
|
return failure("error")
|
||||||
|
finally:
|
||||||
|
self._slots.release()
|
||||||
|
|
||||||
|
if len(json.dumps(response)) > self.max_payload:
|
||||||
|
return failure("too_large")
|
||||||
|
return {"ok": True, "response": response}
|
||||||
@@ -73,7 +73,7 @@ class Gossip(Role):
|
|||||||
|
|
||||||
records = self._parse_records(known)
|
records = self._parse_records(known)
|
||||||
self.membership.merge([record] + records)
|
self.membership.merge([record] + records)
|
||||||
return {"admitted": True, "members": self.membership.export()}
|
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.max_members:
|
||||||
@@ -152,8 +152,20 @@ class Gossip(Role):
|
|||||||
except ValueError as e:
|
except ValueError 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 FAILED
|
return FAILED
|
||||||
|
|
||||||
|
self.remember_dial_address(result.get("responder"), (ip, port))
|
||||||
return ADMITTED
|
return ADMITTED
|
||||||
|
|
||||||
|
def remember_dial_address(self, responder, address):
|
||||||
|
if not isinstance(responder, dict):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
key = MemberRecord.from_dict(responder).key
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
if key != address:
|
||||||
|
self.context.set_dial_alias(key, address)
|
||||||
|
|
||||||
def hardware(self):
|
def hardware(self):
|
||||||
now = self.membership.now()
|
now = self.membership.now()
|
||||||
if self._hardware_at is None or now - self._hardware_at >= self.HARDWARE_TTL:
|
if self._hardware_at is None or now - self._hardware_at >= self.HARDWARE_TTL:
|
||||||
|
|||||||
@@ -29,8 +29,14 @@ class Unit(Role):
|
|||||||
|
|
||||||
def show_cluster(self):
|
def show_cluster(self):
|
||||||
roles = self.context.get_role_registry().roles
|
roles = self.context.get_role_registry().roles
|
||||||
|
members = self.context.membership().overview()
|
||||||
|
for member in members:
|
||||||
|
key = (member["ip"], member["port"])
|
||||||
|
via = self.context.route_of(key)
|
||||||
|
member["via"] = list(via) if via else None
|
||||||
|
member["direct"] = self.context.is_direct(key)
|
||||||
return {
|
return {
|
||||||
"members": self.context.membership().overview(),
|
"members": members,
|
||||||
"roles": [
|
"roles": [
|
||||||
{
|
{
|
||||||
"name": name,
|
"name": name,
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import threading
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from werkzeug.serving import make_server
|
from werkzeug.serving import make_server
|
||||||
|
|
||||||
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.webview.app import create_app
|
from colony.core.roles.webview.app import create_app
|
||||||
from colony.settings import setting
|
from colony.settings import setting
|
||||||
|
|
||||||
|
VIEW_INTERVAL = 5
|
||||||
|
VIEW_TIMEOUT = 2
|
||||||
|
VIEW_MAX_AGE = 20
|
||||||
|
VIEW_WORKERS = 8
|
||||||
|
|
||||||
|
|
||||||
class WebView(Role):
|
class WebView(Role):
|
||||||
class COMMANDS(Enum):
|
class COMMANDS(Enum):
|
||||||
@@ -21,13 +28,75 @@ class WebView(Role):
|
|||||||
raise OSError(f"Web view cannot listen on {host}:{port}") from None
|
raise OSError(f"Web view cannot listen on {host}:{port}") from None
|
||||||
self.port = self._server.server_port
|
self.port = self._server.server_port
|
||||||
|
|
||||||
|
self._views = {}
|
||||||
|
self._views_lock = threading.Lock()
|
||||||
|
|
||||||
super().__init__({}, context)
|
super().__init__({}, context)
|
||||||
|
|
||||||
def _status(self):
|
def _unit(self):
|
||||||
unit = self.context.self().get_role_instances().get("Unit")
|
unit = self.context.self().get_role_instances().get("Unit")
|
||||||
if unit is None:
|
if unit is None:
|
||||||
raise RuntimeError("The Unit role is not available")
|
raise RuntimeError("The Unit role is not available")
|
||||||
return unit.show_cluster()
|
return unit
|
||||||
|
|
||||||
|
def _status(self):
|
||||||
|
payload = self._unit().show_cluster()
|
||||||
|
payload["links"] = self._links(payload["members"])
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def _links(self, own_rows):
|
||||||
|
own_key = next((member["ip"], member["port"]) for member in own_rows if member["self"])
|
||||||
|
views = self._fresh_views()
|
||||||
|
views[own_key] = own_rows
|
||||||
|
|
||||||
|
links = []
|
||||||
|
for source, rows in views.items():
|
||||||
|
for row in rows:
|
||||||
|
if row.get("self") or not row.get("alive") or "direct" not in row:
|
||||||
|
continue
|
||||||
|
if row["direct"] or row.get("via"):
|
||||||
|
links.append({"from": list(source), "to": [row["ip"], row["port"]], "via": None if row["direct"] else row["via"]})
|
||||||
|
return links
|
||||||
|
|
||||||
|
def _fresh_views(self):
|
||||||
|
now = self.context.membership().now()
|
||||||
|
with self._views_lock:
|
||||||
|
return {key: rows for key, (seen, rows) in self._views.items() if now - seen <= VIEW_MAX_AGE}
|
||||||
|
|
||||||
|
def _fetch_view(self, key):
|
||||||
|
try:
|
||||||
|
resp = self.context.execute_to_unregistered_node(key[0], key[1], Unit.COMMANDS.SHOW_CLUSTER, timeout=VIEW_TIMEOUT)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = resp.get("result") if isinstance(resp, dict) and resp.get("status") == "ok" else None
|
||||||
|
rows = result.get("members") if isinstance(result, dict) else None
|
||||||
|
if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows):
|
||||||
|
return None
|
||||||
|
return rows if any("direct" in row for row in rows) else None
|
||||||
|
|
||||||
|
def refresh_views(self):
|
||||||
|
targets = [
|
||||||
|
(member["ip"], member["port"]) for member in self._unit().show_cluster()["members"]
|
||||||
|
if member["alive"] and not member["self"]
|
||||||
|
]
|
||||||
|
with ThreadPoolExecutor(max_workers=max(1, min(VIEW_WORKERS, len(targets)))) as pool:
|
||||||
|
results = list(pool.map(self._fetch_view, targets))
|
||||||
|
|
||||||
|
now = self.context.membership().now()
|
||||||
|
with self._views_lock:
|
||||||
|
for key, rows in zip(targets, results):
|
||||||
|
if rows is not None:
|
||||||
|
self._views[key] = (now, rows)
|
||||||
|
self._views = {key: entry for key, entry in self._views.items() if key in targets}
|
||||||
|
|
||||||
|
def _refresh_loop(self):
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
try:
|
||||||
|
self.refresh_views()
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("Could not refresh the topology")
|
||||||
|
self._stop_event.wait(VIEW_INTERVAL)
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
with self._start_lock:
|
with self._start_lock:
|
||||||
@@ -35,6 +104,7 @@ class WebView(Role):
|
|||||||
return
|
return
|
||||||
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True, name=str(self))
|
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True, name=str(self))
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
threading.Thread(target=self._refresh_loop, daemon=True, name="WebViewTopology").start()
|
||||||
|
|
||||||
self.logger.info(f"Web view at http://{self.context.self().ip}:{self.port}")
|
self.logger.info(f"Web view at http://{self.context.self().ip}:{self.port}")
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,51 @@ function chipLayout(labels, maxWidth, maxLines) {
|
|||||||
return chips;
|
return chips;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function edgePoint(card, tx, ty) {
|
||||||
|
const dx = tx - card.cx;
|
||||||
|
const dy = ty - card.cy;
|
||||||
|
if (dx === 0 && dy === 0) return { x: card.cx, y: card.cy };
|
||||||
|
const scale = Math.min(dx !== 0 ? card.w / 2 / Math.abs(dx) : Infinity, dy !== 0 ? card.h / 2 / Math.abs(dy) : Infinity);
|
||||||
|
return { x: card.cx + dx * scale, y: card.cy + dy * scale };
|
||||||
|
}
|
||||||
|
|
||||||
|
function relayCounts(payload) {
|
||||||
|
const counts = new Map();
|
||||||
|
(payload.links || []).forEach((link) => {
|
||||||
|
if (link.via) {
|
||||||
|
const key = link.via[0] + ":" + link.via[1];
|
||||||
|
counts.set(key, (counts.get(key) || 0) + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reachLinks(payload, cards) {
|
||||||
|
const direct = new Set();
|
||||||
|
payload.links.forEach((link) => {
|
||||||
|
if (!link.via) direct.add(link.from[0] + ":" + link.from[1] + ">" + link.to[0] + ":" + link.to[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const links = [];
|
||||||
|
for (let i = 0; i < cards.length; i++) {
|
||||||
|
for (let j = i + 1; j < cards.length; j++) {
|
||||||
|
const a = cards[i];
|
||||||
|
const b = cards[j];
|
||||||
|
const forward = direct.has(a.key + ">" + b.key);
|
||||||
|
const backward = direct.has(b.key + ">" + a.key);
|
||||||
|
if (forward && backward) {
|
||||||
|
links.push({ x1: a.cx, y1: a.cy, x2: b.cx, y2: b.cy, kind: "link" });
|
||||||
|
} else if (forward || backward) {
|
||||||
|
const [from, to] = forward ? [a, b] : [b, a];
|
||||||
|
const start = edgePoint(from, to.cx, to.cy);
|
||||||
|
const end = edgePoint(to, from.cx, from.cy);
|
||||||
|
links.push({ x1: start.x, y1: start.y, x2: end.x, y2: end.y, kind: "oneway" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
function layout(payload, narrow) {
|
function layout(payload, narrow) {
|
||||||
const members = payload.members;
|
const members = payload.members;
|
||||||
const count = members.length;
|
const count = members.length;
|
||||||
@@ -242,9 +287,13 @@ function layout(payload, narrow) {
|
|||||||
const cy = -top;
|
const cy = -top;
|
||||||
|
|
||||||
const alive = cards.filter((card) => card.member.alive);
|
const alive = cards.filter((card) => card.member.alive);
|
||||||
for (let i = 0; i < alive.length; i++) {
|
if (Array.isArray(payload.links)) {
|
||||||
for (let j = i + 1; j < alive.length; j++) {
|
reachLinks(payload, alive).forEach((link) => links.push(link));
|
||||||
links.push({ x1: alive[i].cx, y1: alive[i].cy, x2: alive[j].cx, y2: alive[j].cy, kind: "link" });
|
} else {
|
||||||
|
for (let i = 0; i < alive.length; i++) {
|
||||||
|
for (let j = i + 1; j < alive.length; j++) {
|
||||||
|
links.push({ x1: alive[i].cx, y1: alive[i].cy, x2: alive[j].cx, y2: alive[j].cy, kind: "link" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const viewer = cards.find((card) => card.member.self);
|
const viewer = cards.find((card) => card.member.self);
|
||||||
@@ -259,7 +308,7 @@ function layout(payload, narrow) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { width: width, height: height, narrow: narrow, cards: cards, links: links, center: center };
|
return { width: width, height: height, narrow: narrow, cards: cards, links: links, center: center, relays: relayCounts(payload) };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof document !== "undefined") {
|
if (typeof document !== "undefined") {
|
||||||
@@ -300,11 +349,13 @@ if (typeof document !== "undefined") {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const drawCard = (parent, card, payload) => {
|
const drawCard = (parent, card, payload, relays) => {
|
||||||
const member = card.member;
|
const member = card.member;
|
||||||
const stats = describeStats(member.stats);
|
const stats = describeStats(member.stats);
|
||||||
|
const relayed = relays.get(card.key) || 0;
|
||||||
const classes = ["node", member.alive ? "alive" : "down"];
|
const classes = ["node", member.alive ? "alive" : "down"];
|
||||||
if (member.self) classes.push("self");
|
if (member.self) classes.push("self");
|
||||||
|
if (relayed) classes.push("bridge");
|
||||||
|
|
||||||
const group = svg("g", { class: classes.join(" "), transform: "translate(" + card.x + "," + card.y + ")" });
|
const group = svg("g", { class: classes.join(" "), transform: "translate(" + card.x + "," + card.y + ")" });
|
||||||
group.appendChild(svg("title", {}, member.hostname + " " + card.key + "\nroles: " + (member.roles.join(", ") || "none") +
|
group.appendChild(svg("title", {}, member.hostname + " " + card.key + "\nroles: " + (member.roles.join(", ") || "none") +
|
||||||
@@ -324,7 +375,8 @@ if (typeof document !== "undefined") {
|
|||||||
drawBar(group, 104, card.w, "CPU", stats.cpu);
|
drawBar(group, 104, card.w, "CPU", stats.cpu);
|
||||||
drawBar(group, 120, card.w, "RAM", stats.ram);
|
drawBar(group, 120, card.w, "RAM", stats.ram);
|
||||||
drawBar(group, 136, card.w, "DISK", stats.disk);
|
drawBar(group, 136, card.w, "DISK", stats.disk);
|
||||||
group.appendChild(svg("text", { class: "t-foot", x: 14, y: card.h - 8 }, shorten(stats.footer, 56)));
|
const foot = relayed ? stats.footer + " · relaying " + relayed : stats.footer;
|
||||||
|
group.appendChild(svg("text", { class: "t-foot", x: 14, y: card.h - 8 }, shorten(foot, 56)));
|
||||||
parent.appendChild(group);
|
parent.appendChild(group);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -346,11 +398,19 @@ if (typeof document !== "undefined") {
|
|||||||
graph.style.maxWidth = Math.round(geometry.width) + "px";
|
graph.style.maxWidth = Math.round(geometry.width) + "px";
|
||||||
graph.replaceChildren();
|
graph.replaceChildren();
|
||||||
|
|
||||||
|
const defs = svg("defs");
|
||||||
|
const marker = svg("marker", { id: "arrow", viewBox: "0 0 10 10", refX: 9, refY: 5, markerWidth: 8, markerHeight: 8, orient: "auto" });
|
||||||
|
marker.appendChild(svg("path", { class: "arrowhead", d: "M 0 0 L 10 5 L 0 10 z" }));
|
||||||
|
defs.appendChild(marker);
|
||||||
|
graph.appendChild(defs);
|
||||||
|
|
||||||
geometry.links.forEach((link) => {
|
geometry.links.forEach((link) => {
|
||||||
graph.appendChild(svg("line", { class: "link " + link.kind, x1: link.x1, y1: link.y1, x2: link.x2, y2: link.y2 }));
|
const attributes = { class: "link " + link.kind, x1: link.x1, y1: link.y1, x2: link.x2, y2: link.y2 };
|
||||||
|
if (link.kind === "oneway") attributes["marker-end"] = "url(#arrow)";
|
||||||
|
graph.appendChild(svg("line", attributes));
|
||||||
});
|
});
|
||||||
if (geometry.center) drawCenter(graph, geometry.center);
|
if (geometry.center) drawCenter(graph, geometry.center);
|
||||||
geometry.cards.forEach((card) => drawCard(graph, card, payload));
|
geometry.cards.forEach((card) => drawCard(graph, card, payload, geometry.relays));
|
||||||
};
|
};
|
||||||
|
|
||||||
const drawHeader = (payload) => {
|
const drawHeader = (payload) => {
|
||||||
@@ -404,5 +464,5 @@ if (typeof document !== "undefined") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== "undefined") {
|
if (typeof module !== "undefined") {
|
||||||
module.exports = { address, roleStates, layout, nodeRoles, chipLayout, chipWidth, shorten, formatAge, formatSize, formatUptime, describeStats, barLevel, alerts, summarize, onDemandHint };
|
module.exports = { address, roleStates, layout, nodeRoles, chipLayout, chipWidth, shorten, formatAge, formatSize, formatUptime, describeStats, barLevel, alerts, summarize, onDemandHint, edgePoint, relayCounts };
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,9 @@
|
|||||||
<ul class="legend">
|
<ul class="legend">
|
||||||
<li><span class="swatch alive"></span>node alive</li>
|
<li><span class="swatch alive"></span>node alive</li>
|
||||||
<li><span class="swatch down"></span>node down</li>
|
<li><span class="swatch down"></span>node down</li>
|
||||||
<li><span class="swatch link"></span>same network</li>
|
<li><span class="swatch link"></span>direct link</li>
|
||||||
|
<li><span class="swatch oneway"></span>one-way reach</li>
|
||||||
|
<li><span class="swatch bridge"></span>relays for others</li>
|
||||||
<li><span class="swatch held"></span>role held</li>
|
<li><span class="swatch held"></span>role held</li>
|
||||||
<li><span class="swatch unmet"></span>role unmet</li>
|
<li><span class="swatch unmet"></span>role unmet</li>
|
||||||
<li><span class="swatch duplicate"></span>held twice</li>
|
<li><span class="swatch duplicate"></span>held twice</li>
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ main { margin-top: 14px; }
|
|||||||
|
|
||||||
.link { stroke: var(--line); stroke-width: 2.5; }
|
.link { stroke: var(--line); stroke-width: 2.5; }
|
||||||
.link.down { stroke-dasharray: 6 6; opacity: 0.7; }
|
.link.down { stroke-dasharray: 6 6; opacity: 0.7; }
|
||||||
|
.link.oneway { stroke: var(--muted); }
|
||||||
|
.arrowhead { fill: var(--muted); }
|
||||||
|
.node.bridge .card { stroke: var(--held); stroke-width: 2; }
|
||||||
|
|
||||||
.card { fill: var(--card); stroke: var(--line); stroke-width: 1.5; }
|
.card { fill: var(--card); stroke: var(--line); stroke-width: 1.5; }
|
||||||
.node.self .card { stroke: var(--alive); stroke-width: 2; }
|
.node.self .card { stroke: var(--alive); stroke-width: 2; }
|
||||||
@@ -106,6 +109,8 @@ main { margin-top: 14px; }
|
|||||||
.swatch.down { background: var(--down); }
|
.swatch.down { background: var(--down); }
|
||||||
.swatch.held, .swatch.unmet, .swatch.duplicate, .swatch.general { border-radius: 6px; background: transparent; border: 2px solid; }
|
.swatch.held, .swatch.unmet, .swatch.duplicate, .swatch.general { border-radius: 6px; background: transparent; border: 2px solid; }
|
||||||
.swatch.general { border-color: var(--line); }
|
.swatch.general { border-color: var(--line); }
|
||||||
|
.swatch.oneway { width: 18px; height: 0; border-radius: 0; border: 0; border-top: 2.5px solid var(--muted); vertical-align: 3px; }
|
||||||
|
.swatch.bridge { border-radius: 4px; background: transparent; border: 2px solid var(--held); }
|
||||||
.swatch.link { width: 18px; height: 0; border-radius: 0; border: 0; border-top: 2.5px solid var(--line); vertical-align: 3px; }
|
.swatch.link { width: 18px; height: 0; border-radius: 0; border: 0; border-top: 2.5px solid var(--line); vertical-align: 3px; }
|
||||||
.swatch.held { border-color: var(--held); }
|
.swatch.held { border-color: var(--held); }
|
||||||
.swatch.unmet { border-color: var(--unmet); border-style: dashed; }
|
.swatch.unmet { border-color: var(--unmet); border-style: dashed; }
|
||||||
|
|||||||
@@ -24,6 +24,21 @@ DEFAULTS = {
|
|||||||
"foreman.acquire_delay": 10,
|
"foreman.acquire_delay": 10,
|
||||||
"foreman.proposal_stagger": 2,
|
"foreman.proposal_stagger": 2,
|
||||||
"foreman.proposal_cooldown": 30,
|
"foreman.proposal_cooldown": 30,
|
||||||
|
"gateway.client_fallback": True,
|
||||||
|
"gateway.connect_timeout": 2,
|
||||||
|
"gateway.direct_retry": 30,
|
||||||
|
"gateway.max_attempts": 4,
|
||||||
|
"gateway.forward_timeout": 8,
|
||||||
|
"gateway.max_concurrent": 16,
|
||||||
|
"gateway.max_payload_bytes": 4 * 1024 * 1024,
|
||||||
|
"gateway.allow_commands": [
|
||||||
|
"Gossip.Hello",
|
||||||
|
"Unit.ShowStats",
|
||||||
|
"Unit.ShowRoles",
|
||||||
|
"Unit.ShowHardware",
|
||||||
|
"Unit.ShowCluster",
|
||||||
|
"Unit.RequestRole",
|
||||||
|
],
|
||||||
"web.host": "0.0.0.0",
|
"web.host": "0.0.0.0",
|
||||||
"web.port": 8080,
|
"web.port": 8080,
|
||||||
"role_policy.max_roles": None,
|
"role_policy.max_roles": None,
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ from colony.settings import configuration
|
|||||||
from colony.transport.framing import send_frame, recv_frame
|
from colony.transport.framing import send_frame, recv_frame
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectFailed(OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Whisperer:
|
class Whisperer:
|
||||||
DEFAULT_TIMEOUT = 10
|
DEFAULT_TIMEOUT = 10
|
||||||
|
|
||||||
@@ -24,16 +28,22 @@ class Whisperer:
|
|||||||
context.load_cert_chain(certfile=self.cert_file, keyfile=self.keyfile)
|
context.load_cert_chain(certfile=self.cert_file, keyfile=self.keyfile)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def send(self, payload, timeout=DEFAULT_TIMEOUT):
|
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._build_ssl_context()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with socket.create_connection((self.host, self.port), timeout=timeout) as sock:
|
sock = socket.create_connection((self.host, self.port), timeout=connect_timeout or timeout)
|
||||||
|
except OSError as e:
|
||||||
|
raise ConnectFailed(f"Cannot connect to {self.host}:{self.port}: {e}") from e
|
||||||
|
|
||||||
|
with sock:
|
||||||
|
sock.settimeout(timeout)
|
||||||
|
try:
|
||||||
with context.wrap_socket(sock) as tls_sock:
|
with context.wrap_socket(sock) as tls_sock:
|
||||||
send_frame(tls_sock, data)
|
send_frame(tls_sock, data)
|
||||||
response = recv_frame(tls_sock)
|
response = recv_frame(tls_sock)
|
||||||
except socket.timeout:
|
except socket.timeout:
|
||||||
raise TimeoutError("No response before timeout. ")
|
raise TimeoutError("No response before timeout. ")
|
||||||
|
|
||||||
return json.loads(response.decode())
|
return json.loads(response.decode())
|
||||||
@@ -38,6 +38,9 @@
|
|||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 8080
|
"port": 8080
|
||||||
},
|
},
|
||||||
|
"gateway": {
|
||||||
|
"max_attempts": 4
|
||||||
|
},
|
||||||
"role_policy": {
|
"role_policy": {
|
||||||
"max_roles": null,
|
"max_roles": null,
|
||||||
"deny": [],
|
"deny": [],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from colony.core.roles.explorer.role import Explorer
|
|||||||
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
|
||||||
|
from colony.core.roles.gateway.role import Gateway
|
||||||
from colony.core.roles.gossip.role import Gossip
|
from colony.core.roles.gossip.role import Gossip
|
||||||
from colony.core.roles.vaultwarden.role import VaultWarden
|
from colony.core.roles.vaultwarden.role import VaultWarden
|
||||||
from colony.core.roles.webview.role import WebView
|
from colony.core.roles.webview.role import WebView
|
||||||
@@ -43,4 +44,8 @@ available_roles = [
|
|||||||
),
|
),
|
||||||
on_demand=True,
|
on_demand=True,
|
||||||
),
|
),
|
||||||
|
RoleStats(
|
||||||
|
cls=Gateway,
|
||||||
|
priority=RolePriority.GENERAL,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
Reference in new issue
Block a user