first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.env import read_int_env, read_bool_env, read_str_env
|
||||
from src.errors import ConfigurationError
|
||||
from src.constants import (
|
||||
DEFAULT_HEALTH_CHECK_SERVER_HOST,
|
||||
DEFAULT_HEALTH_CHECK_SERVER_PORT,
|
||||
ENV_HEALTH_CHECK_SERVER_ENABLED,
|
||||
ENV_HEALTH_CHECK_SERVER_HOST,
|
||||
ENV_HEALTH_CHECK_SERVER_PORT,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthCheckConfig:
|
||||
enabled: bool
|
||||
host: str
|
||||
port: int
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
port = read_int_env(
|
||||
ENV_HEALTH_CHECK_SERVER_PORT, DEFAULT_HEALTH_CHECK_SERVER_PORT
|
||||
)
|
||||
if port < 0 or port > 65535:
|
||||
raise ConfigurationError(f"Port must be between 0 and 65535, got {port}")
|
||||
|
||||
return cls(
|
||||
enabled=read_bool_env(ENV_HEALTH_CHECK_SERVER_ENABLED, default=False),
|
||||
host=read_str_env(
|
||||
ENV_HEALTH_CHECK_SERVER_HOST, DEFAULT_HEALTH_CHECK_SERVER_HOST
|
||||
),
|
||||
port=port,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SecurityConfig:
|
||||
stdlib_allow: set[str]
|
||||
external_allow: set[str]
|
||||
builtins_deny: set[str]
|
||||
runner_env_deny: bool
|
||||
@@ -0,0 +1,36 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.env import read_str_env, read_float_env
|
||||
from src.constants import (
|
||||
ENV_DEPLOYMENT_NAME,
|
||||
ENV_ENVIRONMENT,
|
||||
ENV_N8N_VERSION,
|
||||
ENV_SENTRY_DSN,
|
||||
ENV_SENTRY_PROFILES_SAMPLE_RATE,
|
||||
ENV_SENTRY_TRACES_SAMPLE_RATE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SentryConfig:
|
||||
dsn: str
|
||||
n8n_version: str
|
||||
environment: str
|
||||
deployment_name: str
|
||||
profiles_sample_rate: float
|
||||
traces_sample_rate: float
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self.dsn)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
return cls(
|
||||
dsn=read_str_env(ENV_SENTRY_DSN, ""),
|
||||
n8n_version=read_str_env(ENV_N8N_VERSION, ""),
|
||||
environment=read_str_env(ENV_ENVIRONMENT, ""),
|
||||
deployment_name=read_str_env(ENV_DEPLOYMENT_NAME, ""),
|
||||
profiles_sample_rate=read_float_env(ENV_SENTRY_PROFILES_SAMPLE_RATE, 0),
|
||||
traces_sample_rate=read_float_env(ENV_SENTRY_TRACES_SAMPLE_RATE, 0),
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.env import read_bool_env, read_int_env, read_str_env
|
||||
from src.errors import ConfigurationError
|
||||
from src.constants import (
|
||||
BUILTINS_DENY_DEFAULT,
|
||||
DEFAULT_MAX_CONCURRENCY,
|
||||
DEFAULT_MAX_PAYLOAD_SIZE,
|
||||
DEFAULT_TASK_BROKER_URI,
|
||||
DEFAULT_TASK_TIMEOUT,
|
||||
DEFAULT_AUTO_SHUTDOWN_TIMEOUT,
|
||||
DEFAULT_SHUTDOWN_TIMEOUT,
|
||||
ENV_BLOCK_RUNNER_ENV_ACCESS,
|
||||
ENV_BUILTINS_DENY,
|
||||
ENV_EXTERNAL_ALLOW,
|
||||
ENV_GRANT_TOKEN,
|
||||
ENV_MAX_CONCURRENCY,
|
||||
ENV_MAX_PAYLOAD_SIZE,
|
||||
ENV_STDLIB_ALLOW,
|
||||
ENV_TASK_BROKER_URI,
|
||||
ENV_TASK_TIMEOUT,
|
||||
ENV_AUTO_SHUTDOWN_TIMEOUT,
|
||||
ENV_GRACEFUL_SHUTDOWN_TIMEOUT,
|
||||
PIPE_MSG_MAX_SIZE,
|
||||
)
|
||||
|
||||
|
||||
def parse_allowlist(allowlist_str: str, list_name: str) -> set[str]:
|
||||
if not allowlist_str:
|
||||
return set()
|
||||
|
||||
modules = {
|
||||
module
|
||||
for raw_module in allowlist_str.split(",")
|
||||
if (module := raw_module.strip())
|
||||
}
|
||||
|
||||
if "*" in modules and len(modules) > 1:
|
||||
raise ConfigurationError(
|
||||
f"Wildcard '*' in {list_name} must be used alone, not with other modules. "
|
||||
f"Got: {', '.join(sorted(modules))}"
|
||||
)
|
||||
|
||||
return modules
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskRunnerConfig:
|
||||
grant_token: str
|
||||
task_broker_uri: str
|
||||
max_concurrency: int
|
||||
max_payload_size: int
|
||||
task_timeout: int
|
||||
auto_shutdown_timeout: int
|
||||
graceful_shutdown_timeout: int
|
||||
stdlib_allow: set[str]
|
||||
external_allow: set[str]
|
||||
builtins_deny: set[str]
|
||||
env_deny: bool
|
||||
|
||||
@property
|
||||
def is_auto_shutdown_enabled(self) -> bool:
|
||||
return self.auto_shutdown_timeout > 0
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
grant_token = read_str_env(ENV_GRANT_TOKEN, "")
|
||||
if not grant_token:
|
||||
raise ConfigurationError(
|
||||
"Environment variable N8N_RUNNERS_GRANT_TOKEN is required"
|
||||
)
|
||||
|
||||
task_timeout = read_int_env(ENV_TASK_TIMEOUT, DEFAULT_TASK_TIMEOUT)
|
||||
if task_timeout <= 0:
|
||||
raise ConfigurationError(
|
||||
f"Task timeout must be positive, got {task_timeout}"
|
||||
)
|
||||
|
||||
auto_shutdown_timeout = read_int_env(
|
||||
ENV_AUTO_SHUTDOWN_TIMEOUT, DEFAULT_AUTO_SHUTDOWN_TIMEOUT
|
||||
)
|
||||
if auto_shutdown_timeout < 0:
|
||||
raise ConfigurationError(
|
||||
f"Auto shutdown timeout must be non-negative, got {auto_shutdown_timeout}"
|
||||
)
|
||||
|
||||
graceful_shutdown_timeout = read_int_env(
|
||||
ENV_GRACEFUL_SHUTDOWN_TIMEOUT, DEFAULT_SHUTDOWN_TIMEOUT
|
||||
)
|
||||
if graceful_shutdown_timeout <= 0:
|
||||
raise ConfigurationError(
|
||||
f"Graceful shutdown timeout must be positive, got {graceful_shutdown_timeout}"
|
||||
)
|
||||
|
||||
max_payload_size = read_int_env(ENV_MAX_PAYLOAD_SIZE, DEFAULT_MAX_PAYLOAD_SIZE)
|
||||
if max_payload_size > PIPE_MSG_MAX_SIZE:
|
||||
raise ConfigurationError(
|
||||
f"Max payload size of {max_payload_size} bytes exceeds pipe message limit of {PIPE_MSG_MAX_SIZE} bytes. Reduce {ENV_MAX_PAYLOAD_SIZE}."
|
||||
)
|
||||
|
||||
return cls(
|
||||
grant_token=grant_token,
|
||||
task_broker_uri=read_str_env(ENV_TASK_BROKER_URI, DEFAULT_TASK_BROKER_URI),
|
||||
max_concurrency=read_int_env(ENV_MAX_CONCURRENCY, DEFAULT_MAX_CONCURRENCY),
|
||||
max_payload_size=max_payload_size,
|
||||
task_timeout=task_timeout,
|
||||
auto_shutdown_timeout=auto_shutdown_timeout,
|
||||
graceful_shutdown_timeout=graceful_shutdown_timeout,
|
||||
stdlib_allow=parse_allowlist(
|
||||
read_str_env(ENV_STDLIB_ALLOW, ""), ENV_STDLIB_ALLOW
|
||||
),
|
||||
external_allow=parse_allowlist(
|
||||
read_str_env(ENV_EXTERNAL_ALLOW, ""), ENV_EXTERNAL_ALLOW
|
||||
),
|
||||
builtins_deny=set(
|
||||
module.strip()
|
||||
for module in read_str_env(
|
||||
ENV_BUILTINS_DENY, BUILTINS_DENY_DEFAULT
|
||||
).split(",")
|
||||
),
|
||||
env_deny=read_bool_env(ENV_BLOCK_RUNNER_ENV_ACCESS, True),
|
||||
)
|
||||
@@ -0,0 +1,190 @@
|
||||
from src.errors import (
|
||||
ConfigurationError,
|
||||
TaskCancelledError,
|
||||
TaskRuntimeError,
|
||||
TaskTimeoutError,
|
||||
SecurityViolationError,
|
||||
WebsocketConnectionError,
|
||||
)
|
||||
|
||||
# Messages
|
||||
BROKER_INFO_REQUEST = "broker:inforequest"
|
||||
BROKER_RUNNER_REGISTERED = "broker:runnerregistered"
|
||||
BROKER_TASK_OFFER_ACCEPT = "broker:taskofferaccept"
|
||||
BROKER_TASK_SETTINGS = "broker:tasksettings"
|
||||
BROKER_TASK_CANCEL = "broker:taskcancel"
|
||||
BROKER_RPC_RESPONSE = "broker:rpcresponse"
|
||||
BROKER_DRAIN = "broker:drain"
|
||||
RUNNER_INFO = "runner:info"
|
||||
RUNNER_TASK_OFFER = "runner:taskoffer"
|
||||
RUNNER_TASK_ACCEPTED = "runner:taskaccepted"
|
||||
RUNNER_TASK_REJECTED = "runner:taskrejected"
|
||||
RUNNER_TASK_DONE = "runner:taskdone"
|
||||
RUNNER_TASK_ERROR = "runner:taskerror"
|
||||
RUNNER_RPC_CALL = "runner:rpc"
|
||||
|
||||
# Runner
|
||||
TASK_TYPE_PYTHON = "python"
|
||||
RUNNER_NAME = "Python Task Runner"
|
||||
DEFAULT_MAX_CONCURRENCY = 5 # tasks
|
||||
DEFAULT_MAX_PAYLOAD_SIZE = 1024 * 1024 * 1024 # 1 GiB
|
||||
DEFAULT_TASK_TIMEOUT = 60 # seconds
|
||||
DEFAULT_AUTO_SHUTDOWN_TIMEOUT = 0 # seconds
|
||||
DEFAULT_SHUTDOWN_TIMEOUT = 10 # seconds
|
||||
OFFER_INTERVAL = 0.25 # 250ms
|
||||
OFFER_VALIDITY = 5000 # ms
|
||||
OFFER_VALIDITY_MAX_JITTER = 500 # ms
|
||||
OFFER_VALIDITY_LATENCY_BUFFER = 0.1 # 100ms
|
||||
MAX_VALIDATION_CACHE_SIZE = 500 # cached validation results
|
||||
|
||||
# Executor
|
||||
EXECUTOR_USER_OUTPUT_KEY = "__n8n_internal_user_output__"
|
||||
EXECUTOR_CIRCULAR_REFERENCE_KEY = "__n8n_internal_circular_ref__"
|
||||
EXECUTOR_ALL_ITEMS_FILENAME = "<all_items_task_execution>"
|
||||
EXECUTOR_PER_ITEM_FILENAME = "<per_item_task_execution>"
|
||||
EXECUTOR_FILENAMES = {EXECUTOR_ALL_ITEMS_FILENAME, EXECUTOR_PER_ITEM_FILENAME}
|
||||
SIGTERM_EXIT_CODE = -15
|
||||
SIGKILL_EXIT_CODE = -9
|
||||
PIPE_MSG_PREFIX_LENGTH = 4 # bytes
|
||||
PIPE_MSG_MAX_SIZE = (
|
||||
2 ** (PIPE_MSG_PREFIX_LENGTH * 8) - 1
|
||||
) # bytes (~4 GiB with 4-byte prefix)
|
||||
|
||||
# Broker
|
||||
DEFAULT_TASK_BROKER_URI = "http://127.0.0.1:5679"
|
||||
TASK_BROKER_WS_PATH = "/runners/_ws"
|
||||
|
||||
# Health check
|
||||
DEFAULT_HEALTH_CHECK_SERVER_HOST = "127.0.0.1"
|
||||
DEFAULT_HEALTH_CHECK_SERVER_PORT = 5681
|
||||
|
||||
# Env vars
|
||||
ENV_TASK_BROKER_URI = "N8N_RUNNERS_TASK_BROKER_URI"
|
||||
ENV_GRANT_TOKEN = "N8N_RUNNERS_GRANT_TOKEN"
|
||||
ENV_MAX_CONCURRENCY = "N8N_RUNNERS_MAX_CONCURRENCY"
|
||||
ENV_MAX_PAYLOAD_SIZE = "N8N_RUNNERS_MAX_PAYLOAD"
|
||||
ENV_TASK_TIMEOUT = "N8N_RUNNERS_TASK_TIMEOUT"
|
||||
ENV_AUTO_SHUTDOWN_TIMEOUT = "N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT"
|
||||
ENV_GRACEFUL_SHUTDOWN_TIMEOUT = "N8N_RUNNERS_GRACEFUL_SHUTDOWN_TIMEOUT"
|
||||
ENV_STDLIB_ALLOW = "N8N_RUNNERS_STDLIB_ALLOW"
|
||||
ENV_EXTERNAL_ALLOW = "N8N_RUNNERS_EXTERNAL_ALLOW"
|
||||
ENV_BUILTINS_DENY = "N8N_RUNNERS_BUILTINS_DENY"
|
||||
ENV_HEALTH_CHECK_SERVER_ENABLED = "N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED"
|
||||
ENV_HEALTH_CHECK_SERVER_HOST = "N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST"
|
||||
ENV_HEALTH_CHECK_SERVER_PORT = "N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT"
|
||||
ENV_LAUNCHER_LOG_LEVEL = "N8N_RUNNERS_LAUNCHER_LOG_LEVEL"
|
||||
ENV_BLOCK_RUNNER_ENV_ACCESS = "N8N_BLOCK_RUNNER_ENV_ACCESS"
|
||||
ENV_SENTRY_DSN = "N8N_SENTRY_DSN"
|
||||
ENV_N8N_VERSION = "N8N_VERSION"
|
||||
ENV_ENVIRONMENT = "ENVIRONMENT"
|
||||
ENV_DEPLOYMENT_NAME = "DEPLOYMENT_NAME"
|
||||
ENV_SENTRY_PROFILES_SAMPLE_RATE = "N8N_SENTRY_PROFILES_SAMPLE_RATE"
|
||||
ENV_SENTRY_TRACES_SAMPLE_RATE = "N8N_SENTRY_TRACES_SAMPLE_RATE"
|
||||
|
||||
# Sentry
|
||||
SENTRY_TAG_SERVER_TYPE_KEY = "server_type"
|
||||
SENTRY_TAG_SERVER_TYPE_VALUE = "task_runner_python"
|
||||
IGNORED_ERROR_TYPES = (
|
||||
ConfigurationError,
|
||||
TaskRuntimeError,
|
||||
TaskCancelledError,
|
||||
TaskTimeoutError,
|
||||
SecurityViolationError,
|
||||
WebsocketConnectionError,
|
||||
SyntaxError,
|
||||
)
|
||||
|
||||
# Logging
|
||||
LOG_FORMAT = "%(asctime)s.%(msecs)03d\t%(levelname)s\t%(message)s"
|
||||
LOG_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
LOG_TASK_COMPLETE = 'Completed task {task_id} in {duration} ({result_size}) for node "{node_name}" ({node_id}) in workflow "{workflow_name}" ({workflow_id})'
|
||||
LOG_TASK_CANCEL = 'Cancelled task {task_id} for node "{node_name}" ({node_id}) in workflow "{workflow_name}" ({workflow_id})'
|
||||
LOG_TASK_CANCEL_UNKNOWN = (
|
||||
"Received cancel for unknown task: {task_id}. Discarding message."
|
||||
)
|
||||
LOG_TASK_CANCEL_WAITING = "Cancelled task {task_id} (waiting for settings)"
|
||||
LOG_SENTRY_MISSING = "Sentry is enabled but sentry-sdk is not installed. Install with: uv sync --all-extras"
|
||||
|
||||
# RPC
|
||||
RPC_BROWSER_CONSOLE_LOG_METHOD = "logNodeOutput"
|
||||
|
||||
# Rejection reasons
|
||||
TASK_REJECTED_REASON_OFFER_EXPIRED = (
|
||||
"Offer expired - not accepted within validity window"
|
||||
)
|
||||
TASK_REJECTED_REASON_AT_CAPACITY = "No open task slots - runner already at capacity"
|
||||
|
||||
# Security
|
||||
BUILTINS_DENY_DEFAULT = "eval,exec,compile,open,input,breakpoint,getattr,object,type,vars,setattr,delattr,hasattr,dir,memoryview,__build_class__,globals,locals,license,help,credits,copyright"
|
||||
BLOCKED_NAMES = {
|
||||
"__loader__",
|
||||
"__builtins__",
|
||||
"__globals__",
|
||||
"__spec__",
|
||||
"__name__",
|
||||
}
|
||||
BLOCKED_ATTRIBUTES = {
|
||||
# runtime attributes
|
||||
"__subclasses__",
|
||||
"__globals__",
|
||||
"__builtins__",
|
||||
"__traceback__",
|
||||
"tb_frame",
|
||||
"tb_next",
|
||||
"f_back",
|
||||
"f_globals",
|
||||
"f_locals",
|
||||
"f_code",
|
||||
"f_builtins",
|
||||
"__getattribute__",
|
||||
"__qualname__",
|
||||
"__module__",
|
||||
"gi_frame",
|
||||
"gi_code",
|
||||
"gi_yieldfrom",
|
||||
"cr_frame",
|
||||
"cr_code",
|
||||
"ag_frame",
|
||||
"ag_code",
|
||||
"obj",
|
||||
"__thisclass__",
|
||||
"__self_class__",
|
||||
"__objclass__",
|
||||
# introspection attributes
|
||||
"__base__",
|
||||
"__class__",
|
||||
"__bases__",
|
||||
"__code__",
|
||||
"__closure__",
|
||||
"__loader__",
|
||||
"__cached__",
|
||||
"__dict__",
|
||||
"__import__",
|
||||
"__mro__",
|
||||
"__init_subclass__",
|
||||
"__getattr__",
|
||||
"__setattr__",
|
||||
"__delattr__",
|
||||
"__self__",
|
||||
"__func__",
|
||||
"__wrapped__",
|
||||
"__annotations__",
|
||||
"__spec__",
|
||||
}
|
||||
|
||||
# errors
|
||||
ERROR_RELATIVE_IMPORT = "Relative imports are disallowed."
|
||||
ERROR_STDLIB_DISALLOWED = "Import of standard library module '{module}' is disallowed. Allowed stdlib modules: {allowed}"
|
||||
ERROR_EXTERNAL_DISALLOWED = "Import of external package '{module}' is disallowed. Allowed external packages: {allowed}"
|
||||
ERROR_DANGEROUS_NAME = "Access to name '{name}' is disallowed, because it can be used to bypass security restrictions."
|
||||
ERROR_DANGEROUS_ATTRIBUTE = "Access to attribute '{attr}' is disallowed, because it can be used to bypass security restrictions."
|
||||
ERROR_DANGEROUS_STRING_PATTERN = "String pattern accessing '{attr}' is disallowed, because it can be used to bypass security restrictions."
|
||||
ERROR_NAME_MANGLED_ATTRIBUTE = "Access to name-mangled attributes (pattern: _ClassName__attr) is disallowed for security reasons."
|
||||
ERROR_DYNAMIC_IMPORT = (
|
||||
"Dynamic __import__() calls are not allowed for security reasons."
|
||||
)
|
||||
ERROR_MATCH_PATTERN_ATTRIBUTE = "Match pattern extracting attribute '{attr}' is disallowed, because it can be used to bypass security restrictions."
|
||||
ERROR_WINDOWS_NOT_SUPPORTED = (
|
||||
"Error: This task runner is not supported on Windows. "
|
||||
"Please use a Unix-like system (Linux or macOS)."
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def read_env(env_name: str) -> str | None:
|
||||
if env_name in os.environ:
|
||||
return os.environ[env_name]
|
||||
|
||||
file_path_key = f"{env_name}_FILE"
|
||||
if file_path_key in os.environ:
|
||||
file_path = os.environ[file_path_key]
|
||||
try:
|
||||
return Path(file_path).read_text(encoding="utf-8").strip()
|
||||
except (OSError, IOError) as e:
|
||||
raise ValueError(
|
||||
f"Failed to read {env_name}_FILE from file {file_path}: {e}"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def read_str_env(env_name: str, default: str) -> str:
|
||||
value = read_env(env_name)
|
||||
if value is None:
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
def read_int_env(env_name: str, default: int) -> int:
|
||||
value = read_env(env_name)
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Environment variable {env_name} must be an integer, got '{value}'"
|
||||
)
|
||||
|
||||
|
||||
def read_bool_env(env_name: str, default: bool) -> bool:
|
||||
value = read_env(env_name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() == "true"
|
||||
|
||||
|
||||
def read_float_env(env_name: str, default: float) -> float:
|
||||
value = read_env(env_name)
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Environment variable {env_name} must be a float, got '{value}'"
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
from .configuration_error import ConfigurationError
|
||||
from .invalid_pipe_msg_content_error import InvalidPipeMsgContentError
|
||||
from .invalid_pipe_msg_length_error import InvalidPipeMsgLengthError
|
||||
from .no_idle_timeout_handler_error import NoIdleTimeoutHandlerError
|
||||
from .security_violation_error import SecurityViolationError
|
||||
from .task_cancelled_error import TaskCancelledError
|
||||
from .task_killed_error import TaskKilledError
|
||||
from .task_missing_error import TaskMissingError
|
||||
from .task_result_missing_error import TaskResultMissingError
|
||||
from .task_result_read_error import TaskResultReadError
|
||||
from .task_subprocess_failed_error import TaskSubprocessFailedError
|
||||
from .task_runtime_error import TaskRuntimeError
|
||||
from .task_timeout_error import TaskTimeoutError
|
||||
from .websocket_connection_error import WebsocketConnectionError
|
||||
|
||||
__all__ = [
|
||||
"ConfigurationError",
|
||||
"InvalidPipeMsgContentError",
|
||||
"InvalidPipeMsgLengthError",
|
||||
"NoIdleTimeoutHandlerError",
|
||||
"SecurityViolationError",
|
||||
"TaskCancelledError",
|
||||
"TaskKilledError",
|
||||
"TaskMissingError",
|
||||
"TaskSubprocessFailedError",
|
||||
"TaskResultMissingError",
|
||||
"TaskResultReadError",
|
||||
"TaskRuntimeError",
|
||||
"TaskTimeoutError",
|
||||
"WebsocketConnectionError",
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
class ConfigurationError(Exception):
|
||||
"""Raised when runner configuration set by the user is invalid."""
|
||||
|
||||
def __init__(self, message: str = "Configuration error"):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
@@ -0,0 +1,3 @@
|
||||
class InvalidPipeMsgContentError(Exception):
|
||||
def __init__(self, message: str):
|
||||
super().__init__(f"Invalid pipe message content: {message}")
|
||||
@@ -0,0 +1,3 @@
|
||||
class InvalidPipeMsgLengthError(Exception):
|
||||
def __init__(self, length: int):
|
||||
super().__init__(f"Invalid pipe message length: {length} bytes")
|
||||
@@ -0,0 +1,9 @@
|
||||
class NoIdleTimeoutHandlerError(Exception):
|
||||
"""Raised when idle timeout is reached but no shutdown handler is configured."""
|
||||
|
||||
def __init__(self, timeout: int):
|
||||
super().__init__(
|
||||
f"Idle timeout is configured ({timeout}s) but no handler is set. "
|
||||
"Set task_runner.on_idle_timeout before calling task_runner.start(). "
|
||||
"This is an internal error."
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
class SecurityViolationError(Exception):
|
||||
"""Raised when code violates security policies, typically through the use of disallowed modules or builtins."""
|
||||
|
||||
def __init__(
|
||||
self, message: str = "Security violations detected", description: str = ""
|
||||
):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.description = description
|
||||
@@ -0,0 +1,5 @@
|
||||
class TaskCancelledError(Exception):
|
||||
"""Raised when a task is cancelled by broker message, by runner shutdown, etc."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("Task was cancelled")
|
||||
@@ -0,0 +1,11 @@
|
||||
class TaskKilledError(Exception):
|
||||
"""Raised when a task process is forcefully killed (SIGKILL).
|
||||
|
||||
This usually indicates:
|
||||
- Out of memory (OOM killer)
|
||||
- Process exceeded resource limits
|
||||
- Manual operator intervention
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("Process was forcefully killed (SIGKILL)")
|
||||
@@ -0,0 +1,12 @@
|
||||
class TaskMissingError(Exception):
|
||||
"""Raised when attempting to operate on a task that does not exist.
|
||||
|
||||
This typically indicates an internal error where the task runner
|
||||
received a message referencing a task ID that is not currently
|
||||
being tracked in the runner's running tasks.
|
||||
"""
|
||||
|
||||
def __init__(self, task_id: str):
|
||||
super().__init__(
|
||||
f"Failed to find task {task_id}. This is likely an internal error."
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
class TaskResultMissingError(Exception):
|
||||
"""Raised when a task subprocess exits successfully but returns no result.
|
||||
|
||||
This typically indicates an internal error where the subprocess did not
|
||||
put any data in the communication queue.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
"Process completed but returned no result. This is likely an internal error."
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
class TaskResultReadError(Exception):
|
||||
def __init__(self, original_error: Exception | None = None):
|
||||
super().__init__("Failed to read result from child process")
|
||||
self.original_error = original_error
|
||||
@@ -0,0 +1,16 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.message_types.pipe import TaskErrorInfo
|
||||
|
||||
|
||||
class TaskRuntimeError(Exception):
|
||||
"""Raised when user code raises an exception during task execution."""
|
||||
|
||||
def __init__(self, task_error_info: "TaskErrorInfo"):
|
||||
message = task_error_info["message"]
|
||||
super().__init__(message)
|
||||
self.stack_trace = task_error_info.get("stack", "")
|
||||
self.description = task_error_info.get(
|
||||
"description", ""
|
||||
) or task_error_info.get("stderr", "")
|
||||
@@ -0,0 +1,7 @@
|
||||
class TaskSubprocessFailedError(Exception):
|
||||
"""Raised when a task subprocess exits with a non-zero exit code, excluding SIGTERM and SIGKILL."""
|
||||
|
||||
def __init__(self, exit_code: int, original_error: Exception | None = None):
|
||||
super().__init__(f"Task subprocess exited with code {exit_code}")
|
||||
self.exit_code = exit_code
|
||||
self.original_error = original_error
|
||||
@@ -0,0 +1,7 @@
|
||||
class TaskTimeoutError(Exception):
|
||||
def __init__(self, task_timeout: int):
|
||||
"""Raised when a task execution takes longer than the timeout limit."""
|
||||
|
||||
message = f"Task execution timed out after {task_timeout} {'second' if task_timeout == 1 else 'seconds'}"
|
||||
super().__init__(message)
|
||||
self.task_timeout = task_timeout
|
||||
@@ -0,0 +1,10 @@
|
||||
class WebsocketConnectionError(ConnectionError):
|
||||
"""Raised when the task runner fails to establish a WebSocket connection to the broker.
|
||||
|
||||
Common causes include network issues, incorrect broker URI, or the broker service being unavailable.
|
||||
"""
|
||||
|
||||
def __init__(self, broker_uri: str):
|
||||
super().__init__(
|
||||
f"Failed to connect to broker. Please check if broker is reachable at: {broker_uri}"
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
import asyncio
|
||||
import errno
|
||||
import logging
|
||||
|
||||
from src.config.health_check_config import HealthCheckConfig
|
||||
|
||||
HEALTH_CHECK_RESPONSE = (
|
||||
b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\nOK"
|
||||
)
|
||||
|
||||
|
||||
class HealthCheckServer:
|
||||
def __init__(self):
|
||||
self.server: asyncio.Server | None = None
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
async def start(self, config: HealthCheckConfig) -> None:
|
||||
try:
|
||||
self.server = await asyncio.start_server(
|
||||
self._handle_request, config.host, config.port
|
||||
)
|
||||
# for OS-assigned port in tests
|
||||
actual_port = self.server.sockets[0].getsockname()[1]
|
||||
self.logger.info(
|
||||
f"Health check server listening on {config.host}, port {actual_port}"
|
||||
)
|
||||
except OSError as e:
|
||||
if e.errno == errno.EADDRINUSE:
|
||||
raise OSError(f"Port {config.port} is already in use") from e
|
||||
else:
|
||||
raise
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self.server:
|
||||
self.server.close()
|
||||
await self.server.wait_closed()
|
||||
self.server = None
|
||||
self.logger.info("Health check server stopped")
|
||||
|
||||
async def _handle_request(
|
||||
self, _reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
||||
) -> None:
|
||||
try:
|
||||
writer.write(HEALTH_CHECK_RESPONSE)
|
||||
await writer.drain()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
@@ -0,0 +1,37 @@
|
||||
import sys
|
||||
|
||||
from src.config.security_config import SecurityConfig
|
||||
from src.constants import ERROR_STDLIB_DISALLOWED, ERROR_EXTERNAL_DISALLOWED
|
||||
|
||||
|
||||
def validate_module_import(
|
||||
module_path: str,
|
||||
security_config: SecurityConfig,
|
||||
) -> tuple[bool, str | None]:
|
||||
stdlib_allow = security_config.stdlib_allow
|
||||
external_allow = security_config.external_allow
|
||||
|
||||
module_name = module_path.split(".")[0]
|
||||
is_stdlib = module_name in sys.stdlib_module_names
|
||||
is_external = not is_stdlib
|
||||
|
||||
if is_stdlib and ("*" in stdlib_allow or module_name in stdlib_allow):
|
||||
return (True, None)
|
||||
|
||||
if is_external and ("*" in external_allow or module_name in external_allow):
|
||||
return (True, None)
|
||||
|
||||
if is_stdlib:
|
||||
stdlib_allowed_str = ", ".join(sorted(stdlib_allow)) if stdlib_allow else "none"
|
||||
error_msg = ERROR_STDLIB_DISALLOWED.format(
|
||||
module=module_path, allowed=stdlib_allowed_str
|
||||
)
|
||||
else:
|
||||
external_allowed_str = (
|
||||
", ".join(sorted(external_allow)) if external_allow else "none"
|
||||
)
|
||||
error_msg = ERROR_EXTERNAL_DISALLOWED.format(
|
||||
module=module_path, allowed=external_allowed_str
|
||||
)
|
||||
|
||||
return (False, error_msg)
|
||||
@@ -0,0 +1,66 @@
|
||||
import sys
|
||||
import logging
|
||||
import os
|
||||
from src.constants import LOG_FORMAT, LOG_TIMESTAMP_FORMAT, ENV_LAUNCHER_LOG_LEVEL
|
||||
|
||||
COLORS = {
|
||||
"DEBUG": "\033[34m", # blue
|
||||
"INFO": "\033[32m", # green
|
||||
"WARNING": "\033[33m", # yellow
|
||||
"ERROR": "\033[31m", # red
|
||||
"CRITICAL": "\033[31m", # red
|
||||
}
|
||||
|
||||
RESET = "\033[0m"
|
||||
|
||||
|
||||
class ColorFormatter(logging.Formatter):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.use_colors = os.getenv("NO_COLOR") is None
|
||||
|
||||
# When started by launcher, log level and timestamp are handled by launcher.
|
||||
self.short_form = not sys.stdout.isatty()
|
||||
|
||||
def format(self, record):
|
||||
if self.short_form:
|
||||
return record.getMessage()
|
||||
|
||||
formatted = super().format(record)
|
||||
|
||||
if not self.use_colors:
|
||||
return formatted
|
||||
|
||||
parts = formatted.split("\t")
|
||||
|
||||
if len(parts) >= 3:
|
||||
timestamp = parts[0]
|
||||
level = parts[1]
|
||||
message = " ".join(parts[2:])
|
||||
|
||||
level_color = COLORS.get(record.levelname, "")
|
||||
if level_color:
|
||||
level = level_color + level + RESET
|
||||
message = level_color + message + RESET
|
||||
|
||||
formatted = f"{timestamp} {level} {message}"
|
||||
|
||||
return formatted
|
||||
|
||||
|
||||
def setup_logging():
|
||||
logger = logging.getLogger()
|
||||
|
||||
log_level_str = os.getenv(ENV_LAUNCHER_LOG_LEVEL, "INFO").upper()
|
||||
log_level = getattr(logging, log_level_str, logging.INFO)
|
||||
logger.setLevel(log_level)
|
||||
|
||||
stream_handler = logging.StreamHandler(sys.stdout)
|
||||
stream_handler.setFormatter(ColorFormatter(LOG_FORMAT, LOG_TIMESTAMP_FORMAT))
|
||||
logger.addHandler(stream_handler)
|
||||
|
||||
# Hardcoded to INFO as websocket logs are too verbose
|
||||
logging.getLogger("websockets.client").setLevel(logging.INFO)
|
||||
logging.getLogger("websockets.server").setLevel(logging.INFO)
|
||||
logging.getLogger("websockets").setLevel(logging.INFO)
|
||||
@@ -0,0 +1,72 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import platform
|
||||
|
||||
from src.constants import ERROR_WINDOWS_NOT_SUPPORTED
|
||||
from src.config.health_check_config import HealthCheckConfig
|
||||
from src.config.sentry_config import SentryConfig
|
||||
from src.config.task_runner_config import TaskRunnerConfig
|
||||
from src.errors import ConfigurationError
|
||||
from src.logs import setup_logging
|
||||
from src.task_runner import TaskRunner
|
||||
from src.shutdown import Shutdown
|
||||
|
||||
|
||||
async def main():
|
||||
setup_logging()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
sentry = None
|
||||
sentry_config = SentryConfig.from_env()
|
||||
|
||||
if sentry_config.enabled:
|
||||
from src.sentry import setup_sentry
|
||||
|
||||
sentry = setup_sentry(sentry_config)
|
||||
|
||||
try:
|
||||
health_check_config = HealthCheckConfig.from_env()
|
||||
except ConfigurationError as e:
|
||||
logger.error(f"Invalid health check configuration: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
health_check_server: "HealthCheckServer | None" = None
|
||||
if health_check_config.enabled:
|
||||
from src.health_check_server import HealthCheckServer
|
||||
|
||||
health_check_server = HealthCheckServer()
|
||||
try:
|
||||
await health_check_server.start(health_check_config)
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to start health check server: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
task_runner_config = TaskRunnerConfig.from_env()
|
||||
except ConfigurationError as e:
|
||||
logger.error(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
task_runner = TaskRunner(task_runner_config)
|
||||
logger.info("Starting runner...")
|
||||
|
||||
shutdown = Shutdown(task_runner, health_check_server, sentry)
|
||||
task_runner.on_idle_timeout = shutdown.start_auto_shutdown
|
||||
|
||||
try:
|
||||
await task_runner.start()
|
||||
except Exception:
|
||||
logger.error("Unexpected error", exc_info=True)
|
||||
await shutdown.start_shutdown()
|
||||
|
||||
exit_code = await shutdown.wait_for_shutdown()
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if platform.system() == "Windows":
|
||||
print(ERROR_WINDOWS_NOT_SUPPORTED, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,141 @@
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from typing import cast
|
||||
|
||||
from src.message_types.broker import NodeMode, TaskSettings
|
||||
from src.constants import (
|
||||
BROKER_INFO_REQUEST,
|
||||
BROKER_RUNNER_REGISTERED,
|
||||
BROKER_TASK_CANCEL,
|
||||
BROKER_TASK_OFFER_ACCEPT,
|
||||
BROKER_TASK_SETTINGS,
|
||||
BROKER_RPC_RESPONSE,
|
||||
BROKER_DRAIN,
|
||||
)
|
||||
from src.message_types import (
|
||||
BrokerMessage,
|
||||
RunnerMessage,
|
||||
BrokerInfoRequest,
|
||||
BrokerRunnerRegistered,
|
||||
BrokerTaskOfferAccept,
|
||||
BrokerTaskSettings,
|
||||
BrokerTaskCancel,
|
||||
BrokerRpcResponse,
|
||||
BrokerDrain,
|
||||
)
|
||||
|
||||
|
||||
NODE_MODE_MAP = {
|
||||
"runOnceForAllItems": "all_items",
|
||||
"runOnceForEachItem": "per_item",
|
||||
}
|
||||
|
||||
|
||||
def _get_node_mode(node_mode_str: str) -> NodeMode:
|
||||
if node_mode_str not in NODE_MODE_MAP:
|
||||
raise ValueError(f"Unknown nodeMode: {node_mode_str}")
|
||||
return cast(NodeMode, NODE_MODE_MAP[node_mode_str])
|
||||
|
||||
|
||||
def _parse_task_settings(d: dict) -> BrokerTaskSettings:
|
||||
try:
|
||||
# required
|
||||
task_id = d["taskId"]
|
||||
settings_dict = d["settings"]
|
||||
code = settings_dict["code"]
|
||||
node_mode = _get_node_mode(settings_dict["nodeMode"])
|
||||
items = settings_dict["items"]
|
||||
|
||||
# optional
|
||||
continue_on_fail = settings_dict.get("continueOnFail", False)
|
||||
workflow_name = settings_dict.get("workflowName", "Unknown")
|
||||
workflow_id = settings_dict.get("workflowId", "Unknown")
|
||||
node_name = settings_dict.get("nodeName", "Unknown")
|
||||
node_id = settings_dict.get("nodeId", "Unknown")
|
||||
query = settings_dict.get("query")
|
||||
except KeyError as e:
|
||||
raise ValueError(f"Missing field in task settings message: {e}")
|
||||
|
||||
return BrokerTaskSettings(
|
||||
task_id=task_id,
|
||||
settings=TaskSettings(
|
||||
code=code,
|
||||
node_mode=node_mode,
|
||||
continue_on_fail=continue_on_fail,
|
||||
items=items,
|
||||
workflow_name=workflow_name,
|
||||
workflow_id=workflow_id,
|
||||
node_name=node_name,
|
||||
node_id=node_id,
|
||||
query=query,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _parse_task_offer_accept(d: dict) -> BrokerTaskOfferAccept:
|
||||
try:
|
||||
task_id = d["taskId"]
|
||||
offer_id = d["offerId"]
|
||||
except KeyError as e:
|
||||
raise ValueError(f"Missing field in task offer acceptance message: {e}")
|
||||
|
||||
return BrokerTaskOfferAccept(task_id=task_id, offer_id=offer_id)
|
||||
|
||||
|
||||
def _parse_task_cancel(d: dict) -> BrokerTaskCancel:
|
||||
try:
|
||||
task_id = d["taskId"]
|
||||
reason = d["reason"]
|
||||
except KeyError as e:
|
||||
raise ValueError(f"Missing field in task cancel message: {e}")
|
||||
|
||||
return BrokerTaskCancel(task_id=task_id, reason=reason)
|
||||
|
||||
|
||||
def _parse_rpc_response(d: dict) -> BrokerRpcResponse:
|
||||
try:
|
||||
call_id = d["callId"]
|
||||
task_id = d["taskId"]
|
||||
status = d["status"]
|
||||
except KeyError as e:
|
||||
raise ValueError(f"Missing field in RPC response message: {e}")
|
||||
|
||||
return BrokerRpcResponse(call_id, task_id, status)
|
||||
|
||||
|
||||
MESSAGE_TYPE_MAP = {
|
||||
BROKER_INFO_REQUEST: lambda _: BrokerInfoRequest(),
|
||||
BROKER_RUNNER_REGISTERED: lambda _: BrokerRunnerRegistered(),
|
||||
BROKER_TASK_OFFER_ACCEPT: _parse_task_offer_accept,
|
||||
BROKER_TASK_SETTINGS: _parse_task_settings,
|
||||
BROKER_TASK_CANCEL: _parse_task_cancel,
|
||||
BROKER_RPC_RESPONSE: _parse_rpc_response,
|
||||
BROKER_DRAIN: lambda _: BrokerDrain(),
|
||||
}
|
||||
|
||||
|
||||
class MessageSerde:
|
||||
"""Responsible for deserializing incoming messages and serializing outgoing messages."""
|
||||
|
||||
@staticmethod
|
||||
def deserialize_broker_message(data: str) -> BrokerMessage:
|
||||
message_dict = json.loads(data)
|
||||
message_type = message_dict.get("type")
|
||||
|
||||
if message_type not in MESSAGE_TYPE_MAP:
|
||||
raise ValueError(f"Unknown message type: {message_type}")
|
||||
|
||||
return MESSAGE_TYPE_MAP[message_type](message_dict)
|
||||
|
||||
@staticmethod
|
||||
def serialize_runner_message(message: RunnerMessage) -> str:
|
||||
data = asdict(message)
|
||||
camel_case_data = {
|
||||
MessageSerde._snake_to_camel_case(k): v for k, v in data.items()
|
||||
}
|
||||
return json.dumps(camel_case_data)
|
||||
|
||||
@staticmethod
|
||||
def _snake_to_camel_case(snake_case_str: str) -> str:
|
||||
parts = snake_case_str.split("_")
|
||||
return parts[0] + "".join(word.capitalize() for word in parts[1:])
|
||||
@@ -0,0 +1,39 @@
|
||||
from .broker import (
|
||||
BrokerMessage,
|
||||
BrokerInfoRequest,
|
||||
BrokerRunnerRegistered,
|
||||
BrokerTaskOfferAccept,
|
||||
BrokerTaskSettings,
|
||||
BrokerTaskCancel,
|
||||
BrokerRpcResponse,
|
||||
BrokerDrain,
|
||||
)
|
||||
from .runner import (
|
||||
RunnerMessage,
|
||||
RunnerInfo,
|
||||
RunnerTaskOffer,
|
||||
RunnerTaskAccepted,
|
||||
RunnerTaskRejected,
|
||||
RunnerTaskDone,
|
||||
RunnerTaskError,
|
||||
RunnerRpcCall,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BrokerMessage",
|
||||
"BrokerInfoRequest",
|
||||
"BrokerRunnerRegistered",
|
||||
"BrokerTaskOfferAccept",
|
||||
"BrokerTaskSettings",
|
||||
"BrokerTaskCancel",
|
||||
"BrokerRpcResponse",
|
||||
"BrokerDrain",
|
||||
"RunnerMessage",
|
||||
"RunnerInfo",
|
||||
"RunnerTaskOffer",
|
||||
"RunnerTaskAccepted",
|
||||
"RunnerTaskRejected",
|
||||
"RunnerTaskDone",
|
||||
"RunnerTaskError",
|
||||
"RunnerRpcCall",
|
||||
]
|
||||
@@ -0,0 +1,87 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Any
|
||||
|
||||
from src.constants import (
|
||||
BROKER_INFO_REQUEST,
|
||||
BROKER_RUNNER_REGISTERED,
|
||||
BROKER_TASK_CANCEL,
|
||||
BROKER_TASK_OFFER_ACCEPT,
|
||||
BROKER_TASK_SETTINGS,
|
||||
BROKER_RPC_RESPONSE,
|
||||
BROKER_DRAIN,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrokerInfoRequest:
|
||||
type: Literal["broker:inforequest"] = BROKER_INFO_REQUEST
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrokerRunnerRegistered:
|
||||
type: Literal["broker:runnerregistered"] = BROKER_RUNNER_REGISTERED
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrokerTaskOfferAccept:
|
||||
task_id: str
|
||||
offer_id: str
|
||||
type: Literal["broker:taskofferaccept"] = BROKER_TASK_OFFER_ACCEPT
|
||||
|
||||
|
||||
NodeMode = Literal["all_items", "per_item"]
|
||||
|
||||
Items = list[dict[str, Any]] # INodeExecutionData[]
|
||||
|
||||
Query = str | dict[str, Any] | None # tool input
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskSettings:
|
||||
code: str
|
||||
node_mode: NodeMode
|
||||
continue_on_fail: bool
|
||||
items: Items
|
||||
workflow_name: str
|
||||
workflow_id: str
|
||||
node_name: str
|
||||
node_id: str
|
||||
query: Query = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrokerTaskSettings:
|
||||
task_id: str
|
||||
settings: TaskSettings
|
||||
type: Literal["broker:tasksettings"] = BROKER_TASK_SETTINGS
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrokerTaskCancel:
|
||||
task_id: str
|
||||
reason: str
|
||||
type: Literal["broker:taskcancel"] = BROKER_TASK_CANCEL
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrokerRpcResponse:
|
||||
call_id: str
|
||||
task_id: str
|
||||
status: str
|
||||
type: Literal["broker:rpcresponse"] = BROKER_RPC_RESPONSE
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrokerDrain:
|
||||
type: Literal["broker:drain"] = BROKER_DRAIN
|
||||
|
||||
|
||||
BrokerMessage = (
|
||||
BrokerInfoRequest
|
||||
| BrokerRunnerRegistered
|
||||
| BrokerTaskOfferAccept
|
||||
| BrokerTaskSettings
|
||||
| BrokerTaskCancel
|
||||
| BrokerRpcResponse
|
||||
| BrokerDrain
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from src.message_types.broker import Items
|
||||
|
||||
PrintArgs = list[list[Any]] # Args to all `print()` calls in a Python code task
|
||||
|
||||
|
||||
class TaskErrorInfo(TypedDict):
|
||||
message: str
|
||||
description: str
|
||||
stack: str
|
||||
stderr: str
|
||||
|
||||
|
||||
class PipeResultMessage(TypedDict):
|
||||
result: Items
|
||||
print_args: PrintArgs
|
||||
|
||||
|
||||
class PipeErrorMessage(TypedDict):
|
||||
error: TaskErrorInfo
|
||||
print_args: PrintArgs
|
||||
|
||||
|
||||
PipeMessage = PipeResultMessage | PipeErrorMessage
|
||||
@@ -0,0 +1,74 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Any
|
||||
|
||||
from src.constants import (
|
||||
RUNNER_INFO,
|
||||
RUNNER_RPC_CALL,
|
||||
RUNNER_TASK_ACCEPTED,
|
||||
RUNNER_TASK_DONE,
|
||||
RUNNER_TASK_ERROR,
|
||||
RUNNER_TASK_OFFER,
|
||||
RUNNER_TASK_REJECTED,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunnerInfo:
|
||||
name: str
|
||||
types: list[str]
|
||||
type: Literal["runner:info"] = RUNNER_INFO
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunnerTaskOffer:
|
||||
offer_id: str
|
||||
task_type: str
|
||||
valid_for: int
|
||||
type: Literal["runner:taskoffer"] = RUNNER_TASK_OFFER
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunnerTaskAccepted:
|
||||
task_id: str
|
||||
type: Literal["runner:taskaccepted"] = RUNNER_TASK_ACCEPTED
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunnerTaskRejected:
|
||||
task_id: str
|
||||
reason: str
|
||||
type: Literal["runner:taskrejected"] = RUNNER_TASK_REJECTED
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunnerTaskDone:
|
||||
task_id: str
|
||||
data: dict[str, Any]
|
||||
type: Literal["runner:taskdone"] = RUNNER_TASK_DONE
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunnerTaskError:
|
||||
task_id: str
|
||||
error: dict[str, Any]
|
||||
type: Literal["runner:taskerror"] = RUNNER_TASK_ERROR
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunnerRpcCall:
|
||||
call_id: str
|
||||
task_id: str
|
||||
name: str
|
||||
params: list[Any]
|
||||
type: Literal["runner:rpc"] = RUNNER_RPC_CALL
|
||||
|
||||
|
||||
RunnerMessage = (
|
||||
RunnerInfo
|
||||
| RunnerTaskOffer
|
||||
| RunnerTaskAccepted
|
||||
| RunnerTaskRejected
|
||||
| RunnerTaskDone
|
||||
| RunnerTaskError
|
||||
| RunnerRpcCall
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
import secrets
|
||||
import string
|
||||
|
||||
|
||||
NANOID_CHARSET = string.ascii_uppercase + string.ascii_lowercase + string.digits
|
||||
TARGET_NANOID_LEN = 22
|
||||
CHARSET_LEN = len(NANOID_CHARSET)
|
||||
|
||||
# Collision probability is roughly k^2/(2n) where k=IDs generated, n=possibilities
|
||||
# At 10^12 IDs generated with 62^22 possibilities -> ~1.8e-16 chance of collision
|
||||
|
||||
|
||||
def nanoid() -> str:
|
||||
chars = []
|
||||
|
||||
while len(chars) < TARGET_NANOID_LEN:
|
||||
index = secrets.randbits(6)
|
||||
if index < CHARSET_LEN:
|
||||
chars.append(NANOID_CHARSET[index])
|
||||
|
||||
return "".join(chars)
|
||||
@@ -0,0 +1,85 @@
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from typing import cast
|
||||
|
||||
from multiprocessing.connection import Connection
|
||||
|
||||
from src.errors import (
|
||||
InvalidPipeMsgContentError,
|
||||
InvalidPipeMsgLengthError,
|
||||
)
|
||||
from src.message_types.pipe import PipeMessage
|
||||
from src.constants import PIPE_MSG_PREFIX_LENGTH
|
||||
|
||||
type PipeConnection = Connection
|
||||
|
||||
|
||||
class PipeReader(threading.Thread):
|
||||
"""Background thread that reads result from pipe."""
|
||||
|
||||
def __init__(self, read_fd: int, read_conn: PipeConnection):
|
||||
super().__init__()
|
||||
self.read_fd = read_fd
|
||||
self.read_conn = read_conn
|
||||
self.pipe_message: PipeMessage | None = None
|
||||
self.message_size: int | None = None # bytes
|
||||
self.error: Exception | None = None
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
length_bytes = PipeReader._read_exact_bytes(
|
||||
self.read_fd, PIPE_MSG_PREFIX_LENGTH
|
||||
)
|
||||
length_int = int.from_bytes(length_bytes, "big")
|
||||
if length_int <= 0:
|
||||
raise InvalidPipeMsgLengthError(length_int)
|
||||
self.message_size = length_int
|
||||
data = PipeReader._read_exact_bytes(self.read_fd, length_int)
|
||||
parsed_msg = json.loads(data.decode("utf-8"))
|
||||
self.pipe_message = self._validate_pipe_message(parsed_msg)
|
||||
except Exception as e:
|
||||
self.error = e
|
||||
finally:
|
||||
self.read_conn.close()
|
||||
|
||||
@staticmethod
|
||||
def _read_exact_bytes(fd: int, n: int) -> bytes:
|
||||
"""Read exactly n bytes from file descriptor.
|
||||
|
||||
Uses os.read() instead of Connection.recv() because recv() pickles.
|
||||
Preallocates bytearray to avoid repeated reallocation.
|
||||
"""
|
||||
result = bytearray(n)
|
||||
offset = 0
|
||||
while offset < n:
|
||||
chunk = os.read(fd, n - offset)
|
||||
if not chunk:
|
||||
raise EOFError("Pipe closed before reading all data")
|
||||
result[offset : offset + len(chunk)] = chunk
|
||||
offset += len(chunk)
|
||||
return bytes(result)
|
||||
|
||||
def _validate_pipe_message(self, msg) -> PipeMessage:
|
||||
if not isinstance(msg, dict):
|
||||
raise InvalidPipeMsgContentError(f"Expected dict, got {type(msg).__name__}")
|
||||
|
||||
if "print_args" not in msg:
|
||||
raise InvalidPipeMsgContentError("Message missing 'print_args' key")
|
||||
|
||||
if not isinstance(msg["print_args"], list):
|
||||
raise InvalidPipeMsgContentError("'print_args' must be a list")
|
||||
|
||||
has_result = "result" in msg
|
||||
has_error = "error" in msg
|
||||
|
||||
if not has_result and not has_error:
|
||||
raise InvalidPipeMsgContentError("Msg is missing 'result' or 'error' key")
|
||||
|
||||
if has_result and has_error:
|
||||
raise InvalidPipeMsgContentError("Msg has both 'result' and 'error' keys")
|
||||
|
||||
if has_error and not isinstance(msg["error"], dict):
|
||||
raise InvalidPipeMsgContentError("'error' must be a dict")
|
||||
|
||||
return cast(PipeMessage, msg)
|
||||
@@ -0,0 +1,118 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from src.config.sentry_config import SentryConfig
|
||||
from src.constants import (
|
||||
EXECUTOR_FILENAMES,
|
||||
IGNORED_ERROR_TYPES,
|
||||
LOG_SENTRY_MISSING,
|
||||
SENTRY_TAG_SERVER_TYPE_KEY,
|
||||
SENTRY_TAG_SERVER_TYPE_VALUE,
|
||||
)
|
||||
|
||||
|
||||
class TaskRunnerSentry:
|
||||
def __init__(self, config: SentryConfig):
|
||||
self.config = config
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def init(self) -> None:
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
|
||||
integrations = [LoggingIntegration(level=logging.ERROR)]
|
||||
|
||||
is_profiling_enabled = self.config.profiles_sample_rate > 0
|
||||
|
||||
if is_profiling_enabled:
|
||||
try:
|
||||
# Import profiling integration lazily to avoid hard dependency
|
||||
import sentry_sdk.profiler # noqa: F401
|
||||
|
||||
self.logger.info("Sentry profiling integration loaded")
|
||||
except ImportError:
|
||||
self.logger.warning(
|
||||
"Sentry profiling is enabled but sentry-sdk profiling is not available. "
|
||||
"Install with: uv sync --all-extras"
|
||||
)
|
||||
|
||||
is_tracing_enabled = self.config.traces_sample_rate > 0
|
||||
if is_profiling_enabled and not is_tracing_enabled:
|
||||
self.logger.warning(
|
||||
"Profiling is enabled but tracing is disabled. Profiling will not work."
|
||||
)
|
||||
|
||||
init_options = {
|
||||
"dsn": self.config.dsn,
|
||||
"release": f"n8n@{self.config.n8n_version}",
|
||||
"environment": self.config.environment,
|
||||
"server_name": self.config.deployment_name,
|
||||
"before_send": self._filter_out_ignored_errors,
|
||||
"attach_stacktrace": True,
|
||||
"send_default_pii": False,
|
||||
"auto_enabling_integrations": False,
|
||||
"default_integrations": True,
|
||||
"integrations": integrations,
|
||||
}
|
||||
|
||||
if self.config.traces_sample_rate > 0:
|
||||
init_options["traces_sample_rate"] = self.config.traces_sample_rate
|
||||
|
||||
if is_profiling_enabled:
|
||||
init_options["profiles_sample_rate"] = self.config.profiles_sample_rate
|
||||
|
||||
sentry_sdk.init(**init_options)
|
||||
sentry_sdk.set_tag(SENTRY_TAG_SERVER_TYPE_KEY, SENTRY_TAG_SERVER_TYPE_VALUE)
|
||||
self.logger.info("Sentry ready")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
import sentry_sdk
|
||||
|
||||
sentry_sdk.flush(timeout=2.0)
|
||||
self.logger.info("Sentry stopped")
|
||||
|
||||
def _filter_out_ignored_errors(self, event: Any, hint: Any) -> Any | None:
|
||||
if "exc_info" in hint:
|
||||
exc_type, _, _ = hint["exc_info"]
|
||||
for ignored_type in IGNORED_ERROR_TYPES:
|
||||
if (
|
||||
isinstance(exc_type, type)
|
||||
and isinstance(ignored_type, type)
|
||||
and issubclass(exc_type, ignored_type)
|
||||
):
|
||||
return None
|
||||
|
||||
for exception in event.get("exception", {}).get("values", []):
|
||||
if self._is_from_user_code(exception):
|
||||
return None
|
||||
|
||||
exc_type_name = exception.get("type", "")
|
||||
for ignored_type in IGNORED_ERROR_TYPES:
|
||||
if ignored_type.__name__ == exc_type_name:
|
||||
return None
|
||||
|
||||
return event
|
||||
|
||||
def _is_from_user_code(self, exception: dict[str, Any]):
|
||||
for frame in exception.get("stacktrace", {}).get("frames", []):
|
||||
if frame.get("filename", "") in EXECUTOR_FILENAMES:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def setup_sentry(sentry_config: SentryConfig) -> TaskRunnerSentry | None:
|
||||
if not sentry_config.enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
sentry = TaskRunnerSentry(sentry_config)
|
||||
sentry.init()
|
||||
return sentry
|
||||
except ImportError:
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(LOG_SENTRY_MISSING)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"Failed to initialize Sentry: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,86 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import signal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.task_runner import TaskRunner
|
||||
from src.health_check_server import HealthCheckServer
|
||||
from src.sentry import TaskRunnerSentry
|
||||
|
||||
|
||||
class Shutdown:
|
||||
"""Responsible for managing the shutdown routine of the task runner."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
task_runner: "TaskRunner",
|
||||
health_check_server: "HealthCheckServer | None" = None,
|
||||
sentry: "TaskRunnerSentry | None" = None,
|
||||
):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.is_shutting_down = False
|
||||
self.shutdown_complete = asyncio.Event()
|
||||
self.exit_code = 0
|
||||
|
||||
self.task_runner = task_runner
|
||||
self.health_check_server = health_check_server
|
||||
self.sentry = sentry
|
||||
|
||||
self._register_handler(signal.SIGINT)
|
||||
self._register_handler(signal.SIGTERM)
|
||||
|
||||
async def start_shutdown(self, custom_timeout: int | None = None):
|
||||
if self.is_shutting_down:
|
||||
return
|
||||
|
||||
self.is_shutting_down = True
|
||||
|
||||
timeout = (
|
||||
custom_timeout
|
||||
if custom_timeout is not None
|
||||
else self.task_runner.config.graceful_shutdown_timeout
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(self._perform_shutdown(), timeout=timeout)
|
||||
self.exit_code = 0
|
||||
except asyncio.TimeoutError:
|
||||
self.logger.warning(f"Shutdown timed out after {timeout}s, forcing exit...")
|
||||
self.exit_code = 1
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during shutdown: {e}", exc_info=True)
|
||||
self.exit_code = 1
|
||||
finally:
|
||||
self.shutdown_complete.set()
|
||||
|
||||
async def wait_for_shutdown(self) -> int:
|
||||
await self.shutdown_complete.wait()
|
||||
return self.exit_code
|
||||
|
||||
def _register_handler(self, sig: signal.Signals):
|
||||
async def handler():
|
||||
self.logger.info(f"Received {sig.name} signal, starting shutdown...")
|
||||
await self.start_shutdown()
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop().add_signal_handler(
|
||||
sig, lambda: asyncio.create_task(handler())
|
||||
)
|
||||
except NotImplementedError:
|
||||
self.logger.warning(
|
||||
f"Signal handler for {sig.name} not supported on this platform"
|
||||
) # e.g. Windows
|
||||
|
||||
async def start_auto_shutdown(self):
|
||||
self.logger.info("Reached idle timeout, starting shutdown...")
|
||||
await self.start_shutdown(3) # no tasks so no grace period
|
||||
|
||||
async def _perform_shutdown(self):
|
||||
await self.task_runner.stop()
|
||||
|
||||
if self.health_check_server:
|
||||
await self.health_check_server.stop()
|
||||
|
||||
if self.sentry:
|
||||
self.sentry.shutdown()
|
||||
@@ -0,0 +1,261 @@
|
||||
import ast
|
||||
import hashlib
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
|
||||
from src.errors import SecurityViolationError
|
||||
from src.import_validation import validate_module_import
|
||||
from src.config.security_config import SecurityConfig
|
||||
from src.constants import (
|
||||
MAX_VALIDATION_CACHE_SIZE,
|
||||
ERROR_RELATIVE_IMPORT,
|
||||
ERROR_DANGEROUS_NAME,
|
||||
ERROR_DANGEROUS_ATTRIBUTE,
|
||||
ERROR_NAME_MANGLED_ATTRIBUTE,
|
||||
ERROR_DYNAMIC_IMPORT,
|
||||
ERROR_DANGEROUS_STRING_PATTERN,
|
||||
ERROR_MATCH_PATTERN_ATTRIBUTE,
|
||||
BLOCKED_ATTRIBUTES,
|
||||
BLOCKED_NAMES,
|
||||
)
|
||||
|
||||
CacheKey = tuple[str, tuple] # (code_hash, allowlists_tuple)
|
||||
CachedViolations = list[str]
|
||||
ValidationCache = OrderedDict[CacheKey, CachedViolations]
|
||||
|
||||
FORMAT_FIELD_PATTERN = re.compile(r"\{([^}]*)\}")
|
||||
|
||||
|
||||
class SecurityValidator(ast.NodeVisitor):
|
||||
"""AST visitor that enforces import allowlists and blocks dangerous attribute access."""
|
||||
|
||||
def __init__(self, security_config: SecurityConfig):
|
||||
self.checked_modules: set[str] = set()
|
||||
self.violations: list[str] = []
|
||||
self.security_config = security_config
|
||||
|
||||
# ========== Detection ==========
|
||||
|
||||
def visit_Import(self, node: ast.Import) -> None:
|
||||
"""Detect bare import statements (e.g., import os), including aliased (e.g., import numpy as np)."""
|
||||
|
||||
for alias in node.names:
|
||||
module_name = alias.name
|
||||
self._validate_import(module_name, node.lineno)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
||||
"""Detect from import statements (e.g., from os import path)."""
|
||||
|
||||
if node.level > 0:
|
||||
self._add_violation(node.lineno, ERROR_RELATIVE_IMPORT)
|
||||
elif node.module:
|
||||
self._validate_import(node.module, node.lineno)
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Name(self, node: ast.Name) -> None:
|
||||
if node.id in BLOCKED_NAMES:
|
||||
self._add_violation(node.lineno, ERROR_DANGEROUS_NAME.format(name=node.id))
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Attribute(self, node: ast.Attribute) -> None:
|
||||
"""Detect access to unsafe attributes that could bypass security restrictions."""
|
||||
|
||||
if node.attr in BLOCKED_ATTRIBUTES:
|
||||
self._add_violation(
|
||||
node.lineno, ERROR_DANGEROUS_ATTRIBUTE.format(attr=node.attr)
|
||||
)
|
||||
|
||||
if node.attr.startswith("_") and "__" in node.attr:
|
||||
parts = node.attr.split("__", 1)
|
||||
if len(parts) == 2 and parts[0].startswith("_"):
|
||||
self._add_violation(node.lineno, ERROR_NAME_MANGLED_ATTRIBUTE)
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Call(self, node: ast.Call) -> None:
|
||||
"""Detect calls to __import__() that could bypass security restrictions."""
|
||||
|
||||
is_import_call = (
|
||||
# __import__()
|
||||
(isinstance(node.func, ast.Name) and node.func.id == "__import__")
|
||||
or
|
||||
# builtins.__import__() or __builtins__.__import__()
|
||||
(
|
||||
isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "__import__"
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
and node.func.value.id in {"builtins", "__builtins__"}
|
||||
)
|
||||
)
|
||||
|
||||
if is_import_call:
|
||||
if (
|
||||
node.args
|
||||
and isinstance(node.args[0], ast.Constant)
|
||||
and isinstance(node.args[0].value, str)
|
||||
):
|
||||
module_name = node.args[0].value
|
||||
self._validate_import(module_name, node.lineno)
|
||||
else:
|
||||
self._add_violation(node.lineno, ERROR_DYNAMIC_IMPORT)
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Subscript(self, node: ast.Subscript) -> None:
|
||||
"""Detect dict access to blocked attributes, e.g. __builtins__['__spec__']"""
|
||||
|
||||
is_builtins_access = (
|
||||
# __builtins__['__spec__']
|
||||
(
|
||||
isinstance(node.value, ast.Name)
|
||||
and node.value.id in {"__builtins__", "builtins"}
|
||||
)
|
||||
# obj.__builtins__['__spec__']
|
||||
or (
|
||||
isinstance(node.value, ast.Attribute)
|
||||
and node.value.attr in {"__builtins__", "builtins"}
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
is_builtins_access
|
||||
and isinstance(node.slice, ast.Constant)
|
||||
and isinstance(node.slice.value, str)
|
||||
):
|
||||
key = node.slice.value
|
||||
if key in BLOCKED_ATTRIBUTES:
|
||||
self._add_violation(
|
||||
node.lineno, ERROR_DANGEROUS_ATTRIBUTE.format(attr=key)
|
||||
)
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Constant(self, node: ast.Constant) -> None:
|
||||
"""Detect string constants containing dangerous format patterns."""
|
||||
|
||||
if isinstance(node.value, str):
|
||||
self._check_format_string(node.value, node.lineno)
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_MatchClass(self, node: ast.MatchClass) -> None:
|
||||
"""Detect match patterns that extract blocked attributes, e.g. `case AttributeError(obj=x)`"""
|
||||
|
||||
for attr in node.kwd_attrs:
|
||||
if attr in BLOCKED_ATTRIBUTES:
|
||||
self._add_violation(
|
||||
node.lineno, ERROR_MATCH_PATTERN_ATTRIBUTE.format(attr=attr)
|
||||
)
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def _check_format_string(self, s: str, lineno: int) -> None:
|
||||
"""Check if a string contains format patterns that access blocked attributes."""
|
||||
|
||||
# escaped braces produce literal braces, not format fields
|
||||
s = s.replace("{{", "").replace("}}", "")
|
||||
|
||||
for match in FORMAT_FIELD_PATTERN.finditer(s):
|
||||
field = match.group(1)
|
||||
|
||||
# attribute access
|
||||
for attr_match in re.finditer(r"\.(\w+)", field):
|
||||
attr = attr_match.group(1)
|
||||
if attr in BLOCKED_ATTRIBUTES or attr in BLOCKED_NAMES:
|
||||
self._add_violation(
|
||||
lineno, ERROR_DANGEROUS_STRING_PATTERN.format(attr=attr)
|
||||
)
|
||||
|
||||
# subscript access
|
||||
for subscript_match in re.finditer(r"\[(['\"]?)(\w+)\1\]", field):
|
||||
key = subscript_match.group(2)
|
||||
if key in BLOCKED_ATTRIBUTES or key in BLOCKED_NAMES:
|
||||
self._add_violation(
|
||||
lineno, ERROR_DANGEROUS_STRING_PATTERN.format(attr=key)
|
||||
)
|
||||
|
||||
# ========== Validation ==========
|
||||
|
||||
def _validate_import(self, module_path: str, lineno: int) -> None:
|
||||
"""Validate that a module import is allowed based on allowlists. Also disallow relative imports."""
|
||||
|
||||
if module_path.startswith("."):
|
||||
self._add_violation(lineno, ERROR_RELATIVE_IMPORT)
|
||||
return
|
||||
|
||||
module_name = module_path.split(".")[0] # e.g., os.path -> os
|
||||
|
||||
if module_name in self.checked_modules:
|
||||
return
|
||||
|
||||
self.checked_modules.add(module_name)
|
||||
|
||||
is_allowed, error_msg = validate_module_import(
|
||||
module_path, self.security_config
|
||||
)
|
||||
|
||||
if not is_allowed:
|
||||
assert error_msg is not None
|
||||
self._add_violation(lineno, error_msg)
|
||||
|
||||
def _add_violation(self, lineno: int, message: str) -> None:
|
||||
self.violations.append(f"Line {lineno}: {message}")
|
||||
|
||||
|
||||
class TaskAnalyzer:
|
||||
_cache: ValidationCache = OrderedDict()
|
||||
|
||||
def __init__(self, security_config: SecurityConfig):
|
||||
self._security_config = security_config
|
||||
self._allowlists = (
|
||||
tuple(sorted(security_config.stdlib_allow)),
|
||||
tuple(sorted(security_config.external_allow)),
|
||||
)
|
||||
self._allow_all = (
|
||||
"*" in security_config.stdlib_allow
|
||||
and "*" in security_config.external_allow
|
||||
)
|
||||
|
||||
def validate(self, code: str) -> None:
|
||||
if self._allow_all:
|
||||
return
|
||||
|
||||
cache_key = self._to_cache_key(code)
|
||||
cached_violations = self._cache.get(cache_key)
|
||||
|
||||
if cached_violations is not None:
|
||||
self._cache.move_to_end(cache_key)
|
||||
|
||||
if len(cached_violations) == 0:
|
||||
return
|
||||
|
||||
self._raise_security_error(cached_violations)
|
||||
|
||||
tree = ast.parse(code)
|
||||
|
||||
security_validator = SecurityValidator(self._security_config)
|
||||
security_validator.visit(tree)
|
||||
|
||||
self._set_in_cache(cache_key, security_validator.violations)
|
||||
|
||||
if security_validator.violations:
|
||||
self._raise_security_error(security_validator.violations)
|
||||
|
||||
def _raise_security_error(self, violations: CachedViolations) -> None:
|
||||
raise SecurityViolationError(
|
||||
message="Security violations detected", description="\n".join(violations)
|
||||
)
|
||||
|
||||
def _to_cache_key(self, code: str) -> CacheKey:
|
||||
code_hash = hashlib.sha256(code.encode()).hexdigest()
|
||||
return (code_hash, self._allowlists)
|
||||
|
||||
def _set_in_cache(self, cache_key: CacheKey, violations: CachedViolations) -> None:
|
||||
if len(self._cache) >= MAX_VALIDATION_CACHE_SIZE:
|
||||
self._cache.popitem(last=False) # FIFO
|
||||
|
||||
self._cache[cache_key] = violations.copy()
|
||||
self._cache.move_to_end(cache_key)
|
||||
@@ -0,0 +1,505 @@
|
||||
import multiprocessing
|
||||
import traceback
|
||||
import textwrap
|
||||
import json
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from src.errors import (
|
||||
TaskCancelledError,
|
||||
TaskKilledError,
|
||||
TaskResultMissingError,
|
||||
TaskResultReadError,
|
||||
TaskRuntimeError,
|
||||
TaskTimeoutError,
|
||||
TaskSubprocessFailedError,
|
||||
SecurityViolationError,
|
||||
)
|
||||
from src.import_validation import validate_module_import
|
||||
from src.config.security_config import SecurityConfig
|
||||
|
||||
from src.message_types.broker import NodeMode, Items, Query
|
||||
from src.message_types.pipe import (
|
||||
PipeResultMessage,
|
||||
PipeErrorMessage,
|
||||
TaskErrorInfo,
|
||||
PrintArgs,
|
||||
)
|
||||
from src.pipe_reader import PipeReader
|
||||
from src.constants import (
|
||||
EXECUTOR_CIRCULAR_REFERENCE_KEY,
|
||||
EXECUTOR_USER_OUTPUT_KEY,
|
||||
EXECUTOR_ALL_ITEMS_FILENAME,
|
||||
EXECUTOR_PER_ITEM_FILENAME,
|
||||
SIGTERM_EXIT_CODE,
|
||||
SIGKILL_EXIT_CODE,
|
||||
PIPE_MSG_PREFIX_LENGTH,
|
||||
)
|
||||
|
||||
from multiprocessing.context import ForkServerProcess
|
||||
from multiprocessing.connection import Connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MULTIPROCESSING_CONTEXT = multiprocessing.get_context("forkserver")
|
||||
MAX_PRINT_ARGS_ALLOWED = 100
|
||||
|
||||
type PipeConnection = Connection
|
||||
|
||||
|
||||
class TaskExecutor:
|
||||
"""Responsible for executing Python code tasks in isolated subprocesses."""
|
||||
|
||||
@staticmethod
|
||||
def create_process(
|
||||
code: str,
|
||||
node_mode: NodeMode,
|
||||
items: Items,
|
||||
security_config: SecurityConfig,
|
||||
query: Query = None,
|
||||
) -> tuple[ForkServerProcess, PipeConnection, PipeConnection]:
|
||||
"""Create a subprocess for executing a Python code task and a pipe for communication."""
|
||||
|
||||
fn = (
|
||||
TaskExecutor._all_items
|
||||
if node_mode == "all_items"
|
||||
else TaskExecutor._per_item
|
||||
)
|
||||
|
||||
# thread in runner process reads, subprocess writes
|
||||
read_conn, write_conn = MULTIPROCESSING_CONTEXT.Pipe(duplex=False)
|
||||
|
||||
process = MULTIPROCESSING_CONTEXT.Process(
|
||||
target=fn,
|
||||
args=(
|
||||
code,
|
||||
items,
|
||||
write_conn,
|
||||
security_config,
|
||||
query,
|
||||
),
|
||||
)
|
||||
|
||||
return process, read_conn, write_conn
|
||||
|
||||
@staticmethod
|
||||
def execute_process(
|
||||
process: ForkServerProcess,
|
||||
read_conn: PipeConnection,
|
||||
write_conn: PipeConnection,
|
||||
task_timeout: int,
|
||||
continue_on_fail: bool,
|
||||
) -> tuple[Items, PrintArgs, int]:
|
||||
"""Execute a subprocess for a Python code task."""
|
||||
|
||||
print_args: PrintArgs = []
|
||||
|
||||
pipe_reader = PipeReader(read_conn.fileno(), read_conn)
|
||||
pipe_reader.start()
|
||||
|
||||
try:
|
||||
try:
|
||||
process.start()
|
||||
except Exception as e:
|
||||
raise TaskSubprocessFailedError(-1, e)
|
||||
finally:
|
||||
write_conn.close()
|
||||
|
||||
process.join(timeout=task_timeout)
|
||||
|
||||
if process.is_alive():
|
||||
TaskExecutor.stop_process(process)
|
||||
raise TaskTimeoutError(task_timeout)
|
||||
|
||||
if process.exitcode == SIGTERM_EXIT_CODE:
|
||||
raise TaskCancelledError()
|
||||
|
||||
if process.exitcode == SIGKILL_EXIT_CODE:
|
||||
raise TaskKilledError()
|
||||
|
||||
if process.exitcode != 0:
|
||||
assert process.exitcode is not None
|
||||
raise TaskSubprocessFailedError(process.exitcode)
|
||||
|
||||
pipe_reader.join(timeout=task_timeout)
|
||||
|
||||
if pipe_reader.is_alive():
|
||||
try:
|
||||
read_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
raise TaskResultReadError(
|
||||
TimeoutError(f"Pipe reader timed out after {task_timeout}s")
|
||||
)
|
||||
|
||||
if pipe_reader.error:
|
||||
raise TaskResultReadError(pipe_reader.error)
|
||||
|
||||
if pipe_reader.pipe_message is None:
|
||||
raise TaskResultMissingError()
|
||||
|
||||
returned = pipe_reader.pipe_message
|
||||
|
||||
if "error" in returned:
|
||||
error_msg = cast(PipeErrorMessage, returned)
|
||||
raise TaskRuntimeError(error_msg["error"])
|
||||
|
||||
if "result" not in returned:
|
||||
raise TaskResultMissingError()
|
||||
|
||||
result_msg = cast(PipeResultMessage, returned)
|
||||
result = result_msg["result"]
|
||||
print_args = result_msg.get("print_args", [])
|
||||
assert pipe_reader.message_size is not None
|
||||
result_size_bytes = pipe_reader.message_size
|
||||
|
||||
return result, print_args, result_size_bytes
|
||||
|
||||
except Exception as e:
|
||||
if continue_on_fail:
|
||||
return [{"json": {"error": str(e)}}], print_args, 0
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def stop_process(process: ForkServerProcess | None):
|
||||
"""Stop a running subprocess, gracefully else force-killing."""
|
||||
|
||||
if process is None or not process.is_alive():
|
||||
return
|
||||
|
||||
try:
|
||||
process.terminate()
|
||||
process.join(timeout=1) # 1s grace period
|
||||
|
||||
if process.is_alive():
|
||||
process.kill()
|
||||
process.join()
|
||||
except (ProcessLookupError, ConnectionError, BrokenPipeError):
|
||||
# subprocess is dead or unreachable
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _all_items(
|
||||
raw_code: str,
|
||||
items: Items,
|
||||
write_conn,
|
||||
security_config: SecurityConfig,
|
||||
query: Query = None,
|
||||
):
|
||||
"""Execute a Python code task in all-items mode."""
|
||||
|
||||
if security_config.runner_env_deny:
|
||||
os.environ.clear()
|
||||
|
||||
TaskExecutor._sanitize_sys_modules(security_config)
|
||||
|
||||
print_args: PrintArgs = []
|
||||
sys.stderr = stderr_capture = io.StringIO()
|
||||
|
||||
try:
|
||||
wrapped_code = TaskExecutor._wrap_code(raw_code)
|
||||
compiled_code = compile(wrapped_code, EXECUTOR_ALL_ITEMS_FILENAME, "exec")
|
||||
|
||||
globals = {
|
||||
"__builtins__": TaskExecutor._filter_builtins(security_config),
|
||||
"_items": items,
|
||||
"_query": query,
|
||||
"print": TaskExecutor._create_custom_print(print_args),
|
||||
}
|
||||
|
||||
exec(compiled_code, globals)
|
||||
|
||||
result = cast(Items, globals[EXECUTOR_USER_OUTPUT_KEY])
|
||||
TaskExecutor._put_result(write_conn.fileno(), result, print_args)
|
||||
|
||||
except BaseException as e:
|
||||
TaskExecutor._put_error(
|
||||
write_conn.fileno(), e, stderr_capture.getvalue(), print_args
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _per_item(
|
||||
raw_code: str,
|
||||
items: Items,
|
||||
write_conn,
|
||||
security_config: SecurityConfig,
|
||||
_query: Query = None, # unused, only to keep signatures consistent across modes
|
||||
):
|
||||
"""Execute a Python code task in per-item mode."""
|
||||
|
||||
if security_config.runner_env_deny:
|
||||
os.environ.clear()
|
||||
|
||||
TaskExecutor._sanitize_sys_modules(security_config)
|
||||
|
||||
print_args: PrintArgs = []
|
||||
sys.stderr = stderr_capture = io.StringIO()
|
||||
|
||||
try:
|
||||
wrapped_code = TaskExecutor._wrap_code(raw_code)
|
||||
compiled_code = compile(wrapped_code, EXECUTOR_PER_ITEM_FILENAME, "exec")
|
||||
|
||||
filtered_builtins = TaskExecutor._filter_builtins(security_config)
|
||||
custom_print = TaskExecutor._create_custom_print(print_args)
|
||||
|
||||
result: Items = []
|
||||
for index, item in enumerate(items):
|
||||
globals = {
|
||||
"__builtins__": filtered_builtins,
|
||||
"_item": item,
|
||||
"print": custom_print,
|
||||
}
|
||||
|
||||
exec(compiled_code, globals)
|
||||
|
||||
user_output = globals[EXECUTOR_USER_OUTPUT_KEY]
|
||||
|
||||
if user_output is None:
|
||||
continue
|
||||
|
||||
json_data = TaskExecutor._extract_json_data_per_item(user_output)
|
||||
|
||||
output_item = {"json": json_data, "pairedItem": {"item": index}}
|
||||
|
||||
if isinstance(user_output, dict) and "binary" in user_output:
|
||||
output_item["binary"] = user_output["binary"]
|
||||
|
||||
result.append(output_item)
|
||||
|
||||
TaskExecutor._put_result(write_conn.fileno(), result, print_args)
|
||||
|
||||
except BaseException as e:
|
||||
TaskExecutor._put_error(
|
||||
write_conn.fileno(), e, stderr_capture.getvalue(), print_args
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _wrap_code(raw_code: str) -> str:
|
||||
indented_code = textwrap.indent(raw_code, " ")
|
||||
return f"def _user_function():\n{indented_code}\n\n{EXECUTOR_USER_OUTPUT_KEY} = _user_function()"
|
||||
|
||||
@staticmethod
|
||||
def _extract_json_data_per_item(user_output):
|
||||
if not isinstance(user_output, dict):
|
||||
return user_output
|
||||
|
||||
if "json" in user_output:
|
||||
return user_output["json"]
|
||||
|
||||
if "binary" in user_output:
|
||||
return {k: v for k, v in user_output.items() if k != "binary"}
|
||||
|
||||
return user_output
|
||||
|
||||
@staticmethod
|
||||
def _put_result(write_fd: int, result: Items, print_args: PrintArgs):
|
||||
message: PipeResultMessage = {
|
||||
"result": result,
|
||||
"print_args": TaskExecutor._truncate_print_args(print_args),
|
||||
}
|
||||
|
||||
data = json.dumps(message, default=str, ensure_ascii=False).encode("utf-8")
|
||||
length_bytes = len(data).to_bytes(PIPE_MSG_PREFIX_LENGTH, "big")
|
||||
|
||||
try:
|
||||
TaskExecutor._write_bytes(write_fd, length_bytes)
|
||||
TaskExecutor._write_bytes(write_fd, data)
|
||||
finally:
|
||||
try:
|
||||
os.close(write_fd)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _put_error(
|
||||
write_fd: int,
|
||||
e: BaseException,
|
||||
stderr: str = "",
|
||||
print_args: PrintArgs | None = None,
|
||||
):
|
||||
if print_args is None:
|
||||
print_args = []
|
||||
|
||||
task_error_info: TaskErrorInfo = {
|
||||
"message": f"Process exited with code {e.code}"
|
||||
if isinstance(e, SystemExit)
|
||||
else str(e),
|
||||
"description": getattr(e, "description", ""),
|
||||
"stack": traceback.format_exc(),
|
||||
"stderr": stderr,
|
||||
}
|
||||
|
||||
message: PipeErrorMessage = {
|
||||
"error": task_error_info,
|
||||
"print_args": TaskExecutor._truncate_print_args(print_args),
|
||||
}
|
||||
|
||||
data = json.dumps(message, default=str, ensure_ascii=False).encode("utf-8")
|
||||
length_bytes = len(data).to_bytes(PIPE_MSG_PREFIX_LENGTH, "big")
|
||||
|
||||
try:
|
||||
TaskExecutor._write_bytes(write_fd, length_bytes)
|
||||
TaskExecutor._write_bytes(write_fd, data)
|
||||
finally:
|
||||
try:
|
||||
os.close(write_fd)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ========== print() ==========
|
||||
|
||||
@staticmethod
|
||||
def _create_custom_print(print_args: PrintArgs):
|
||||
def custom_print(*args):
|
||||
serializable_args = []
|
||||
|
||||
for arg in args:
|
||||
try:
|
||||
json.dumps(arg, default=str, ensure_ascii=False)
|
||||
serializable_args.append(arg)
|
||||
except Exception as _:
|
||||
# Ensure args are serializable so they are transmissible
|
||||
# through the multiprocessing queue and via websockets.
|
||||
serializable_args.append(
|
||||
{
|
||||
EXECUTOR_CIRCULAR_REFERENCE_KEY: repr(arg),
|
||||
"__type__": type(arg).__name__,
|
||||
}
|
||||
)
|
||||
|
||||
formatted = TaskExecutor._format_print_args(*serializable_args)
|
||||
print_args.append(formatted)
|
||||
print("[user code]", *args)
|
||||
|
||||
return custom_print
|
||||
|
||||
@staticmethod
|
||||
def _format_print_args(*args) -> list[str]:
|
||||
"""
|
||||
Takes the args passed to a `print()` call in user code and converts them
|
||||
to string representations suitable for display in a browser console.
|
||||
|
||||
Expects all args to be serializable.
|
||||
"""
|
||||
|
||||
formatted = []
|
||||
|
||||
for arg in args:
|
||||
if isinstance(arg, str):
|
||||
formatted.append(f"'{arg}'")
|
||||
|
||||
elif arg is None or isinstance(arg, (int, float, bool)):
|
||||
formatted.append(str(arg))
|
||||
|
||||
elif isinstance(arg, dict) and EXECUTOR_CIRCULAR_REFERENCE_KEY in arg:
|
||||
formatted.append(f"[Circular {arg.get('__type__', 'Object')}]")
|
||||
|
||||
else:
|
||||
formatted.append(json.dumps(arg, default=str, ensure_ascii=False))
|
||||
|
||||
return formatted
|
||||
|
||||
@staticmethod
|
||||
def _truncate_print_args(print_args: PrintArgs) -> PrintArgs:
|
||||
"""Truncate print_args to prevent pipe buffer overflow."""
|
||||
|
||||
if not print_args or len(print_args) <= MAX_PRINT_ARGS_ALLOWED:
|
||||
return print_args
|
||||
|
||||
truncated = print_args[:MAX_PRINT_ARGS_ALLOWED]
|
||||
truncated.append(
|
||||
[
|
||||
f"[Output truncated - {len(print_args) - MAX_PRINT_ARGS_ALLOWED} more print statements]"
|
||||
]
|
||||
)
|
||||
|
||||
return truncated
|
||||
|
||||
# ========== security ==========
|
||||
|
||||
@staticmethod
|
||||
def _filter_builtins(security_config: SecurityConfig):
|
||||
"""Get __builtins__ with denied ones removed."""
|
||||
|
||||
if len(security_config.builtins_deny) == 0:
|
||||
filtered = dict(__builtins__)
|
||||
else:
|
||||
filtered = {
|
||||
k: v
|
||||
for k, v in __builtins__.items()
|
||||
if k not in security_config.builtins_deny
|
||||
}
|
||||
|
||||
filtered["__import__"] = TaskExecutor._create_safe_import(security_config)
|
||||
|
||||
return filtered
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_sys_modules(security_config: SecurityConfig):
|
||||
safe_modules = {
|
||||
"builtins",
|
||||
"__main__",
|
||||
"sys",
|
||||
"traceback",
|
||||
"linecache",
|
||||
"importlib",
|
||||
"importlib.machinery",
|
||||
}
|
||||
|
||||
if "*" in security_config.stdlib_allow:
|
||||
safe_modules.update(sys.stdlib_module_names)
|
||||
else:
|
||||
safe_modules.update(security_config.stdlib_allow)
|
||||
|
||||
if "*" in security_config.external_allow:
|
||||
safe_modules.update(
|
||||
name
|
||||
for name in sys.modules.keys()
|
||||
if name not in sys.stdlib_module_names
|
||||
)
|
||||
else:
|
||||
safe_modules.update(security_config.external_allow)
|
||||
|
||||
# keep modules marked as safe and submodules of those
|
||||
safe_prefixes = [safe + "." for safe in safe_modules]
|
||||
modules_to_remove = [
|
||||
name
|
||||
for name in sys.modules.keys()
|
||||
if name not in safe_modules
|
||||
and not any(name.startswith(prefix) for prefix in safe_prefixes)
|
||||
]
|
||||
|
||||
for module_name in modules_to_remove:
|
||||
del sys.modules[module_name]
|
||||
|
||||
@staticmethod
|
||||
def _create_safe_import(security_config: SecurityConfig):
|
||||
original_import = __builtins__["__import__"]
|
||||
|
||||
def safe_import(name, *args, **kwargs):
|
||||
is_allowed, error_msg = validate_module_import(name, security_config)
|
||||
|
||||
if not is_allowed:
|
||||
assert error_msg is not None
|
||||
raise SecurityViolationError(
|
||||
message="Security violation detected",
|
||||
description=error_msg,
|
||||
)
|
||||
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
return safe_import
|
||||
|
||||
# ========== pipe I/O ==========
|
||||
|
||||
@staticmethod
|
||||
def _write_bytes(fd: int, data: bytes):
|
||||
total_written = 0
|
||||
while total_written < len(data):
|
||||
written = os.write(fd, data[total_written:])
|
||||
if written == 0:
|
||||
raise OSError("Write failed")
|
||||
total_written += written
|
||||
@@ -0,0 +1,510 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Callable, Awaitable
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
import websockets
|
||||
from websockets.exceptions import InvalidStatus
|
||||
from websockets.asyncio.client import ClientConnection
|
||||
import random
|
||||
from src.errors import TaskCancelledError
|
||||
|
||||
|
||||
from src.config.task_runner_config import TaskRunnerConfig
|
||||
from src.errors import (
|
||||
NoIdleTimeoutHandlerError,
|
||||
TaskMissingError,
|
||||
WebsocketConnectionError,
|
||||
)
|
||||
from src.message_types.broker import TaskSettings
|
||||
from src.nanoid import nanoid
|
||||
|
||||
from src.constants import (
|
||||
RUNNER_NAME,
|
||||
TASK_REJECTED_REASON_AT_CAPACITY,
|
||||
TASK_REJECTED_REASON_OFFER_EXPIRED,
|
||||
TASK_TYPE_PYTHON,
|
||||
OFFER_INTERVAL,
|
||||
OFFER_VALIDITY,
|
||||
OFFER_VALIDITY_MAX_JITTER,
|
||||
OFFER_VALIDITY_LATENCY_BUFFER,
|
||||
TASK_BROKER_WS_PATH,
|
||||
RPC_BROWSER_CONSOLE_LOG_METHOD,
|
||||
LOG_TASK_COMPLETE,
|
||||
LOG_TASK_CANCEL,
|
||||
LOG_TASK_CANCEL_UNKNOWN,
|
||||
LOG_TASK_CANCEL_WAITING,
|
||||
)
|
||||
from src.message_types import (
|
||||
BrokerMessage,
|
||||
RunnerMessage,
|
||||
BrokerInfoRequest,
|
||||
BrokerRunnerRegistered,
|
||||
BrokerTaskOfferAccept,
|
||||
BrokerTaskSettings,
|
||||
BrokerTaskCancel,
|
||||
BrokerRpcResponse,
|
||||
BrokerDrain,
|
||||
RunnerInfo,
|
||||
RunnerTaskOffer,
|
||||
RunnerTaskAccepted,
|
||||
RunnerTaskRejected,
|
||||
RunnerTaskDone,
|
||||
RunnerTaskError,
|
||||
RunnerRpcCall,
|
||||
)
|
||||
from src.message_serde import MessageSerde
|
||||
from src.task_state import TaskState, TaskStatus
|
||||
from src.task_executor import TaskExecutor
|
||||
from src.task_analyzer import TaskAnalyzer
|
||||
from src.config.security_config import SecurityConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskOffer:
|
||||
offer_id: str
|
||||
valid_until: float
|
||||
|
||||
@property
|
||||
def has_expired(self) -> bool:
|
||||
return time.time() > self.valid_until
|
||||
|
||||
|
||||
class TaskRunner:
|
||||
def __init__(
|
||||
self,
|
||||
config: TaskRunnerConfig,
|
||||
):
|
||||
self.runner_id = nanoid()
|
||||
self.name = RUNNER_NAME
|
||||
self.config = config
|
||||
|
||||
self.websocket_connection: ClientConnection | None = None
|
||||
self.can_send_offers = False
|
||||
|
||||
self.open_offers: dict[str, TaskOffer] = {}
|
||||
self.running_tasks: dict[str, TaskState] = {}
|
||||
|
||||
self.offers_coroutine: asyncio.Task | None = None
|
||||
self.serde = MessageSerde()
|
||||
self.executor = TaskExecutor()
|
||||
self.security_config = SecurityConfig(
|
||||
stdlib_allow=config.stdlib_allow,
|
||||
external_allow=config.external_allow,
|
||||
builtins_deny=config.builtins_deny,
|
||||
runner_env_deny=config.env_deny,
|
||||
)
|
||||
self.analyzer = TaskAnalyzer(self.security_config)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
self.idle_coroutine: asyncio.Task | None = None
|
||||
self.on_idle_timeout: Callable[[], Awaitable[None]] | None = None
|
||||
self.last_activity_time = time.time()
|
||||
self.is_shutting_down = False
|
||||
|
||||
self.task_broker_uri = config.task_broker_uri
|
||||
websocket_host = urlparse(config.task_broker_uri).netloc
|
||||
self.websocket_url = (
|
||||
f"ws://{websocket_host}{TASK_BROKER_WS_PATH}?id={self.runner_id}"
|
||||
)
|
||||
|
||||
@property
|
||||
def running_tasks_count(self) -> int:
|
||||
return len(self.running_tasks)
|
||||
|
||||
async def start(self) -> None:
|
||||
if self.config.is_auto_shutdown_enabled and not self.on_idle_timeout:
|
||||
raise NoIdleTimeoutHandlerError(self.config.auto_shutdown_timeout)
|
||||
|
||||
headers = {"Authorization": f"Bearer {self.config.grant_token}"}
|
||||
|
||||
while not self.is_shutting_down:
|
||||
try:
|
||||
self.websocket_connection = await websockets.connect(
|
||||
self.websocket_url,
|
||||
additional_headers=headers,
|
||||
max_size=self.config.max_payload_size,
|
||||
)
|
||||
self.logger.info("Connected to broker")
|
||||
await self._listen_for_messages()
|
||||
|
||||
except InvalidStatus as e:
|
||||
if e.response.status_code == 403:
|
||||
self.logger.error(
|
||||
f"Authentication failed with status {e.response.status_code}: {e}"
|
||||
)
|
||||
raise
|
||||
self.logger.warning(f"Failed to connect to broker: {e} - retrying...")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to connect to broker: {e} - retrying...")
|
||||
|
||||
if not self.is_shutting_down:
|
||||
self.websocket_connection = None
|
||||
self.can_send_offers = False
|
||||
await self._cancel_coroutine(self.offers_coroutine)
|
||||
await self._cancel_coroutine(self.idle_coroutine)
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _cancel_coroutine(self, coroutine: asyncio.Task | None) -> None:
|
||||
if coroutine and not coroutine.done():
|
||||
coroutine.cancel()
|
||||
try:
|
||||
await coroutine
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# ========== Shutdown ==========
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.is_shutting_down = True
|
||||
self.can_send_offers = False
|
||||
|
||||
await self._cancel_coroutine(self.offers_coroutine)
|
||||
await self._cancel_coroutine(self.idle_coroutine)
|
||||
|
||||
await self._wait_for_tasks()
|
||||
await self._terminate_tasks()
|
||||
|
||||
if self.websocket_connection:
|
||||
await self.websocket_connection.close()
|
||||
self.logger.info("Disconnected from broker")
|
||||
|
||||
self.logger.info("Runner stopped")
|
||||
|
||||
async def _wait_for_tasks(self):
|
||||
if not self.running_tasks:
|
||||
return
|
||||
|
||||
timeout = self.config.graceful_shutdown_timeout
|
||||
self.logger.debug(
|
||||
f"Waiting for {self.running_tasks_count} tasks to complete (timeout: {timeout}s)..."
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
while self.running_tasks and (time.time() - start_time) < timeout:
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
if self.running_tasks:
|
||||
self.logger.warning(
|
||||
f"Timed out waiting for {self.running_tasks_count} tasks to complete"
|
||||
)
|
||||
|
||||
async def _terminate_tasks(self):
|
||||
if not self.running_tasks:
|
||||
return
|
||||
|
||||
self.logger.warning(f"Terminating {self.running_tasks_count} tasks...")
|
||||
|
||||
tasks_to_terminate = [
|
||||
asyncio.to_thread(self.executor.stop_process, task_state.process)
|
||||
for task_state in self.running_tasks.values()
|
||||
if task_state.process
|
||||
]
|
||||
|
||||
if tasks_to_terminate:
|
||||
await asyncio.gather(*tasks_to_terminate, return_exceptions=True)
|
||||
|
||||
self.running_tasks.clear()
|
||||
|
||||
self.logger.warning("Terminated tasks")
|
||||
|
||||
# ========== Messages ==========
|
||||
|
||||
async def _listen_for_messages(self) -> None:
|
||||
if self.websocket_connection is None:
|
||||
raise WebsocketConnectionError(self.task_broker_uri)
|
||||
|
||||
async for raw_message in self.websocket_connection:
|
||||
try:
|
||||
if isinstance(raw_message, bytes):
|
||||
raw_message = raw_message.decode("utf-8")
|
||||
message = self.serde.deserialize_broker_message(raw_message)
|
||||
await self._handle_message(message)
|
||||
except websockets.ConnectionClosedOK:
|
||||
break
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling message: {e}")
|
||||
|
||||
async def _handle_message(self, message: BrokerMessage) -> None:
|
||||
match message:
|
||||
case BrokerInfoRequest():
|
||||
await self._handle_info_request()
|
||||
case BrokerRunnerRegistered():
|
||||
await self._handle_runner_registered()
|
||||
case BrokerTaskOfferAccept():
|
||||
await self._handle_task_offer_accept(message)
|
||||
case BrokerTaskSettings():
|
||||
await self._handle_task_settings(message)
|
||||
case BrokerTaskCancel():
|
||||
await self._handle_task_cancel(message)
|
||||
case BrokerRpcResponse():
|
||||
pass # currently only logging, already handled by browser
|
||||
case BrokerDrain():
|
||||
await self._handle_drain()
|
||||
case _:
|
||||
self.logger.warning(f"Unhandled message type: {type(message)}")
|
||||
|
||||
async def _handle_info_request(self) -> None:
|
||||
response = RunnerInfo(name=self.name, types=[TASK_TYPE_PYTHON])
|
||||
await self._send_message(response)
|
||||
|
||||
async def _handle_drain(self) -> None:
|
||||
self.can_send_offers = False
|
||||
await self._cancel_coroutine(self.offers_coroutine)
|
||||
self.logger.info("Received drain signal, stopped accepting new tasks")
|
||||
|
||||
async def _handle_runner_registered(self) -> None:
|
||||
self.can_send_offers = True
|
||||
self.offers_coroutine = asyncio.create_task(self._send_offers_loop())
|
||||
self.logger.info("Registered with broker")
|
||||
self._reset_idle_timer()
|
||||
|
||||
async def _handle_task_offer_accept(self, message: BrokerTaskOfferAccept) -> None:
|
||||
offer = self.open_offers.get(message.offer_id)
|
||||
|
||||
if offer is None or offer.has_expired:
|
||||
response = RunnerTaskRejected(
|
||||
task_id=message.task_id,
|
||||
reason=TASK_REJECTED_REASON_OFFER_EXPIRED,
|
||||
)
|
||||
await self._send_message(response)
|
||||
return
|
||||
|
||||
if self.running_tasks_count >= self.config.max_concurrency:
|
||||
response = RunnerTaskRejected(
|
||||
task_id=message.task_id,
|
||||
reason=TASK_REJECTED_REASON_AT_CAPACITY,
|
||||
)
|
||||
await self._send_message(response)
|
||||
return
|
||||
|
||||
del self.open_offers[message.offer_id]
|
||||
|
||||
task_state = TaskState(message.task_id)
|
||||
self.running_tasks[message.task_id] = task_state
|
||||
|
||||
response = RunnerTaskAccepted(task_id=message.task_id)
|
||||
await self._send_message(response)
|
||||
self.logger.info(f"Accepted task {message.task_id}")
|
||||
self._reset_idle_timer()
|
||||
|
||||
async def _handle_task_settings(self, message: BrokerTaskSettings) -> None:
|
||||
task_state = self.running_tasks.get(message.task_id)
|
||||
if task_state is None:
|
||||
raise TaskMissingError(message.task_id)
|
||||
|
||||
if task_state.status != TaskStatus.WAITING_FOR_SETTINGS:
|
||||
self.logger.warning(
|
||||
f"Received settings for task but it is already {task_state.status}. Discarding message."
|
||||
)
|
||||
return
|
||||
|
||||
task_state.workflow_name = message.settings.workflow_name
|
||||
task_state.workflow_id = message.settings.workflow_id
|
||||
task_state.node_name = message.settings.node_name
|
||||
task_state.node_id = message.settings.node_id
|
||||
|
||||
task_state.status = TaskStatus.RUNNING
|
||||
asyncio.create_task(self._execute_task(message.task_id, message.settings))
|
||||
self.logger.info(f"Received task {message.task_id}")
|
||||
|
||||
async def _execute_task(self, task_id: str, task_settings: TaskSettings) -> None:
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
task_state = self.running_tasks.get(task_id)
|
||||
|
||||
if task_state is None:
|
||||
raise TaskMissingError(task_id)
|
||||
|
||||
self.analyzer.validate(task_settings.code)
|
||||
|
||||
process, read_conn, write_conn = self.executor.create_process(
|
||||
code=task_settings.code,
|
||||
node_mode=task_settings.node_mode,
|
||||
items=task_settings.items,
|
||||
security_config=self.security_config,
|
||||
query=task_settings.query,
|
||||
)
|
||||
|
||||
task_state.process = process
|
||||
|
||||
result, print_args, result_size_bytes = await asyncio.to_thread(
|
||||
self.executor.execute_process,
|
||||
process=process,
|
||||
read_conn=read_conn,
|
||||
write_conn=write_conn,
|
||||
task_timeout=self.config.task_timeout,
|
||||
continue_on_fail=task_settings.continue_on_fail,
|
||||
)
|
||||
|
||||
for print_args_per_call in print_args:
|
||||
await self._send_rpc_message(
|
||||
task_id, RPC_BROWSER_CONSOLE_LOG_METHOD, print_args_per_call
|
||||
)
|
||||
|
||||
response = RunnerTaskDone(task_id=task_id, data={"result": result})
|
||||
await self._send_message(response)
|
||||
|
||||
self.logger.info(
|
||||
LOG_TASK_COMPLETE.format(
|
||||
task_id=task_id,
|
||||
duration=self._get_duration(start_time),
|
||||
result_size=self._get_result_size(result_size_bytes),
|
||||
**task_state.context(),
|
||||
)
|
||||
)
|
||||
|
||||
except TaskCancelledError as e:
|
||||
response = RunnerTaskError(task_id=task_id, error={"message": str(e)})
|
||||
await self._send_message(response)
|
||||
|
||||
except SyntaxError as e:
|
||||
self.logger.warning(f"Task {task_id} failed syntax validation")
|
||||
error = {"message": str(e)}
|
||||
response = RunnerTaskError(task_id=task_id, error=error)
|
||||
await self._send_message(response)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Task {task_id} failed", exc_info=True)
|
||||
error = {
|
||||
"message": getattr(e, "message", str(e)),
|
||||
"description": getattr(e, "description", ""),
|
||||
}
|
||||
response = RunnerTaskError(task_id=task_id, error=error)
|
||||
await self._send_message(response)
|
||||
|
||||
finally:
|
||||
self.running_tasks.pop(task_id, None)
|
||||
self._reset_idle_timer()
|
||||
|
||||
async def _handle_task_cancel(self, message: BrokerTaskCancel) -> None:
|
||||
task_id = message.task_id
|
||||
task_state = self.running_tasks.get(task_id)
|
||||
|
||||
if task_state is None:
|
||||
self.logger.warning(LOG_TASK_CANCEL_UNKNOWN.format(task_id=task_id))
|
||||
return
|
||||
|
||||
if task_state.status == TaskStatus.WAITING_FOR_SETTINGS:
|
||||
self.running_tasks.pop(task_id, None)
|
||||
self.logger.info(LOG_TASK_CANCEL_WAITING.format(task_id=task_id))
|
||||
await self._send_offers()
|
||||
return
|
||||
|
||||
if task_state.status == TaskStatus.RUNNING:
|
||||
task_state.status = TaskStatus.ABORTING
|
||||
await asyncio.to_thread(self.executor.stop_process, task_state.process)
|
||||
self.logger.info(
|
||||
LOG_TASK_CANCEL.format(task_id=task_id, **task_state.context())
|
||||
)
|
||||
|
||||
async def _send_rpc_message(self, task_id: str, method_name: str, params: list):
|
||||
message = RunnerRpcCall(
|
||||
call_id=nanoid(), task_id=task_id, name=method_name, params=params
|
||||
)
|
||||
|
||||
await self._send_message(message)
|
||||
|
||||
async def _send_message(self, message: RunnerMessage) -> None:
|
||||
if self.websocket_connection is None:
|
||||
raise WebsocketConnectionError(self.task_broker_uri)
|
||||
|
||||
serialized = self.serde.serialize_runner_message(message)
|
||||
await self.websocket_connection.send(serialized)
|
||||
|
||||
# ========== Formatting ==========
|
||||
|
||||
def _get_duration(self, start_time: float) -> str:
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
if elapsed < 1:
|
||||
return f"{int(elapsed * 1000)}ms"
|
||||
|
||||
if elapsed < 60:
|
||||
return f"{int(elapsed)}s"
|
||||
|
||||
return f"{int(elapsed) // 60}m"
|
||||
|
||||
def _get_result_size(self, size_bytes: int) -> str:
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} bytes"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.1f} KB"
|
||||
else:
|
||||
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
||||
|
||||
# ========== Offers ==========
|
||||
|
||||
async def _send_offers_loop(self) -> None:
|
||||
while self.can_send_offers:
|
||||
try:
|
||||
await self._send_offers()
|
||||
await asyncio.sleep(OFFER_INTERVAL)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error sending offers: {e}")
|
||||
|
||||
async def _send_offers(self) -> None:
|
||||
if not self.can_send_offers:
|
||||
return
|
||||
|
||||
expired_offer_ids = [
|
||||
offer_id
|
||||
for offer_id, offer in self.open_offers.items()
|
||||
if offer.has_expired
|
||||
]
|
||||
|
||||
for offer_id in expired_offer_ids:
|
||||
self.open_offers.pop(offer_id, None)
|
||||
|
||||
offers_to_send = self.config.max_concurrency - (
|
||||
len(self.open_offers) + self.running_tasks_count
|
||||
)
|
||||
|
||||
for _ in range(offers_to_send):
|
||||
offer_id = nanoid()
|
||||
|
||||
valid_for_ms = OFFER_VALIDITY + random.randint(0, OFFER_VALIDITY_MAX_JITTER)
|
||||
|
||||
valid_until = (
|
||||
time.time() + (valid_for_ms / 1000) + OFFER_VALIDITY_LATENCY_BUFFER
|
||||
)
|
||||
|
||||
self.open_offers[offer_id] = TaskOffer(offer_id, valid_until)
|
||||
|
||||
message = RunnerTaskOffer(
|
||||
offer_id=offer_id, task_type=TASK_TYPE_PYTHON, valid_for=valid_for_ms
|
||||
)
|
||||
|
||||
await self._send_message(message)
|
||||
|
||||
# ========== Inactivity ==========
|
||||
|
||||
def _reset_idle_timer(self):
|
||||
"""Reset idle timer when key event occurs, namely runner registration, task acceptance, and task completion or failure."""
|
||||
|
||||
if not self.config.is_auto_shutdown_enabled:
|
||||
return
|
||||
|
||||
self.last_activity_time = time.time()
|
||||
|
||||
if self.idle_coroutine and not self.idle_coroutine.done():
|
||||
self.idle_coroutine.cancel()
|
||||
|
||||
self.idle_coroutine = asyncio.create_task(self._idle_timer_coroutine())
|
||||
|
||||
async def _idle_timer_coroutine(self):
|
||||
try:
|
||||
await asyncio.sleep(self.config.auto_shutdown_timeout)
|
||||
|
||||
if self.running_tasks_count > 0:
|
||||
return
|
||||
|
||||
assert self.on_idle_timeout is not None # validated at start()
|
||||
|
||||
await self.on_idle_timeout()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
@@ -0,0 +1,37 @@
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
from multiprocessing.context import ForkServerProcess
|
||||
|
||||
|
||||
class TaskStatus(Enum):
|
||||
WAITING_FOR_SETTINGS = "waiting_for_settings"
|
||||
RUNNING = "running"
|
||||
ABORTING = "aborting"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskState:
|
||||
task_id: str
|
||||
status: TaskStatus
|
||||
process: ForkServerProcess | None = None
|
||||
workflow_name: str | None = None
|
||||
workflow_id: str | None = None
|
||||
node_name: str | None = None
|
||||
node_id: str | None = None
|
||||
|
||||
def __init__(self, task_id: str):
|
||||
self.task_id = task_id
|
||||
self.status = TaskStatus.WAITING_FOR_SETTINGS
|
||||
self.process = None
|
||||
self.workflow_name = None
|
||||
self.workflow_id = None
|
||||
self.node_name = None
|
||||
self.node_id = None
|
||||
|
||||
def context(self):
|
||||
return {
|
||||
"node_name": self.node_name,
|
||||
"node_id": self.node_id,
|
||||
"workflow_name": self.workflow_name,
|
||||
"workflow_id": self.workflow_id,
|
||||
}
|
||||
Reference in New Issue
Block a user