From b18fb74fa438274408fc7726a18ab1ea9629af07 Mon Sep 17 00:00:00 2001 From: wanyunSu Date: Tue, 14 Apr 2026 16:25:40 +0200 Subject: [PATCH 01/73] keep both endpoints and hostnames --- src/drunc/controller/interface/shell_utils.py | 55 ++++++++++++++----- src/drunc/unified_shell/context.py | 46 ++++++++++++++++ 2 files changed, 87 insertions(+), 14 deletions(-) diff --git a/src/drunc/controller/interface/shell_utils.py b/src/drunc/controller/interface/shell_utils.py index 8280b3576..7ab6e21a2 100644 --- a/src/drunc/controller/interface/shell_utils.py +++ b/src/drunc/controller/interface/shell_utils.py @@ -71,7 +71,7 @@ def match_children( def get_status_table( - status_response: StatusResponse, describe_response: DescribeResponse + status_response: StatusResponse, describe_response: DescribeResponse, display_host_overrides: dict[str, str] | None = None, ): status = status_response.status description = describe_response.description @@ -102,7 +102,7 @@ def add_status_to_table( if status is None or description is None: return - def update_endpoint(endpoint: str) -> str: + def update_endpoint(endpoint: str, proc_name: str) -> str: """ Parses endpoint to a human readable hostname @@ -115,11 +115,34 @@ def update_endpoint(endpoint: str) -> str: if not endpoint: return "" - ip_address = urlparse(endpoint).hostname - if not ip_address: + parsed = urlparse(endpoint) + raw_host = parsed.hostname + if not raw_host: return "" - resolved_host = get_hostname_smart(ip_address) - return endpoint.replace(ip_address, resolved_host) + + scheme = parsed.scheme + port = parsed.port + + if display_host_overrides and proc_name in display_host_overrides: + display_host = display_host_overrides[proc_name] + pretty = ( + f"{scheme}://{display_host}:{port}" + if port is not None + else f"{scheme}://{display_host}" + ) + if display_host != raw_host: + return f"{pretty} [dim](actual: {raw_host})[/dim]" + return pretty + + resolved = get_hostname_smart(raw_host) + if resolved != raw_host: + return ( + f"{scheme}://{resolved}:{port}" + if port is not None + else f"{scheme}://{resolved}" + ) + + return endpoint table.add_row( prefix + status_response.name, @@ -128,7 +151,7 @@ def update_endpoint(endpoint: str) -> str: status.sub_state, format_bool(status.in_error, false_is_good=True), format_bool(status.included), - update_endpoint(description.endpoint), + update_endpoint(description.endpoint, status_response.name), ) children = match_children(status_response.children, describe_response.children) @@ -179,6 +202,15 @@ def add_runinfo_to_table(table: Table, status: Status): return t +def render_status_table(ctx: UnifiedShellContext): + statuses = ctx.get_driver("controller").status() + descriptions = ctx.get_driver("controller").describe() + display_host_overrides = ctx.get_endpoint_display_host_overrides() + return get_status_table( + statuses, + descriptions, + display_host_overrides=display_host_overrides, + ) class StatusTableUpdater(Progress): def __init__(self, ctx, refresh_per_second=2, *args, **kwargs) -> None: @@ -187,9 +219,7 @@ def __init__(self, ctx, refresh_per_second=2, *args, **kwargs) -> None: super().__init__(*args, refresh_per_second=refresh_per_second, **kwargs) def update_table(self): - statuses = self.ctx.get_driver("controller").status() - descriptions = self.ctx.get_driver("controller").describe() - self.table = get_status_table(statuses, descriptions) + self.table = render_status_table(self.ctx) def get_renderable(self) -> ConsoleRenderable | RichCast | str: renderable = Group(self.table, *self.get_renderables()) @@ -648,10 +678,7 @@ def add_to_table(table, response, prefix=""): add_to_table(t, result) obj.print(t) # rich tables require console printing - statuses = obj.get_driver("controller").status() - descriptions = obj.get_driver("controller").describe() - t = get_status_table(statuses, descriptions) - obj.print(t) + obj.print(render_status_table(obj)) obj.print_status_summary() diff --git a/src/drunc/unified_shell/context.py b/src/drunc/unified_shell/context.py index 318983739..531d01bab 100644 --- a/src/drunc/unified_shell/context.py +++ b/src/drunc/unified_shell/context.py @@ -95,6 +95,52 @@ def start_listening_controller(self, broadcaster_conf) -> None: ) self.status_receiver_controller = BroadcastHandler(broadcast_configuration=bcch) + + def get_endpoint_display_host_overrides(self) -> dict[str, str]: + """ + Return a mapping of process name -> preferred display hostname for endpoint + rendering in the UI. + + These values are cosmetic only. The controller's advertised endpoint remains + the authoritative connect address. + + Returns: + dict[str, str]: Mapping from process name to preferred display hostname. + """ + pm_driver = self.get_driver("process_manager", quiet_fail=True) + if not pm_driver: + return {} + + from druncschema.process_manager_pb2 import ProcessQuery + + try: + query = ( + ProcessQuery(names=[".*"], session=self.session_name) + if self.session_name + else ProcessQuery(names=[".*"]) + ) + proc_list = pm_driver.ps(query) + except Exception: + return {} + + overrides: dict[str, str] = {} + + for proc in proc_list.values: + try: + metadata = proc.process_description.metadata + except Exception: + continue + + proc_name = getattr(metadata, "name", "") + host_name = getattr(metadata, "hostname", "") + + if not proc_name or not host_name: + continue + + overrides[proc_name] = host_name + + return overrides + def terminate(self) -> None: if self.status_receiver_pm: self.status_receiver_pm.stop() From 395ecf0044d2d46f461be89b05f4b92dfbd9b34e Mon Sep 17 00:00:00 2001 From: wanyunSu Date: Tue, 14 Apr 2026 18:07:14 +0200 Subject: [PATCH 02/73] make status command work again --- src/drunc/controller/interface/commands.py | 22 ++++++++----------- src/drunc/controller/interface/context.py | 10 +++++++++ src/drunc/controller/interface/shell_utils.py | 22 ++++++++++++++++--- 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/src/drunc/controller/interface/commands.py b/src/drunc/controller/interface/commands.py index 4d26c00f3..1f8ab28e5 100644 --- a/src/drunc/controller/interface/commands.py +++ b/src/drunc/controller/interface/commands.py @@ -4,7 +4,7 @@ import click from drunc.controller.interface.context import ControllerContext -from drunc.controller.interface.shell_utils import controller_setup, get_status_table +from drunc.controller.interface.shell_utils import controller_setup, render_status_table from drunc.utils.utils import get_logger log = get_logger("controller.iface", rich_handler=True) @@ -76,18 +76,14 @@ def status( execute_along_path: bool, execute_on_all_subsequent_children_in_path: bool, ) -> None: - statuses = obj.get_driver("controller").status( - target=target, - execute_along_path=execute_along_path, - execute_on_all_subsequent_children_in_path=execute_on_all_subsequent_children_in_path, - ) # Get the dynamic system information - descriptions = obj.get_driver("controller").describe( - target=target, - execute_along_path=execute_along_path, - execute_on_all_subsequent_children_in_path=execute_on_all_subsequent_children_in_path, - ) # Get the static system information - t = get_status_table(statuses, descriptions) - obj.print(t) + obj.print( + render_status_table( + obj, + target=target, + execute_along_path=execute_along_path, + execute_on_all_subsequent_children_in_path=execute_on_all_subsequent_children_in_path, + ) + ) obj.print_status_summary() diff --git a/src/drunc/controller/interface/context.py b/src/drunc/controller/interface/context.py index 082c42a00..351db8624 100644 --- a/src/drunc/controller/interface/context.py +++ b/src/drunc/controller/interface/context.py @@ -39,6 +39,16 @@ def start_listening_controller(self, broadcaster_conf): ) self.status_receiver = BroadcastHandler(broadcast_configuration=bcch) + def get_endpoint_display_host_overrides(self) -> dict[str, str]: + """ + Return UI-only endpoint host overrides. + + The controller's advertised endpoint remains the authoritative connect + address. This method only provides cosmetic host substitutions for + status-table rendering. + """ + return {} + def terminate(self): if self.status_receiver: self.status_receiver.stop() diff --git a/src/drunc/controller/interface/shell_utils.py b/src/drunc/controller/interface/shell_utils.py index 7ab6e21a2..a7c92ad88 100644 --- a/src/drunc/controller/interface/shell_utils.py +++ b/src/drunc/controller/interface/shell_utils.py @@ -202,9 +202,25 @@ def add_runinfo_to_table(table: Table, status: Status): return t -def render_status_table(ctx: UnifiedShellContext): - statuses = ctx.get_driver("controller").status() - descriptions = ctx.get_driver("controller").describe() +from drunc.controller.interface.context import ControllerContext + + +def render_status_table( + ctx: ControllerContext, + target: str = "", + execute_along_path: bool = True, + execute_on_all_subsequent_children_in_path: bool = True, +): + statuses = ctx.get_driver("controller").status( + target=target, + execute_along_path=execute_along_path, + execute_on_all_subsequent_children_in_path=execute_on_all_subsequent_children_in_path, + ) + descriptions = ctx.get_driver("controller").describe( + target=target, + execute_along_path=execute_along_path, + execute_on_all_subsequent_children_in_path=execute_on_all_subsequent_children_in_path, + ) display_host_overrides = ctx.get_endpoint_display_host_overrides() return get_status_table( statuses, From 474bd108a7a3c61dfbdfd7a3dfb7bb60425dceaf Mon Sep 17 00:00:00 2001 From: wanyunSu Date: Tue, 12 May 2026 13:41:04 +0200 Subject: [PATCH 03/73] add status --full and resolve localhost --- src/drunc/controller/interface/commands.py | 8 +++ src/drunc/controller/interface/shell_utils.py | 71 +++++++++++-------- 2 files changed, 49 insertions(+), 30 deletions(-) diff --git a/src/drunc/controller/interface/commands.py b/src/drunc/controller/interface/commands.py index 5c8c46c99..ac3a88f3f 100644 --- a/src/drunc/controller/interface/commands.py +++ b/src/drunc/controller/interface/commands.py @@ -70,12 +70,19 @@ def wait(obj: ControllerContext, sleep_time: int) -> None: help="Execute the command on all subsequent children in the path", default=True, ) +@click.option( + "--full", + is_flag=True, + default=False, + help="Show additional columns, including the actual endpoint IP address.", +) @click.pass_obj def status( obj: ControllerContext, target: str, execute_along_path: bool, execute_on_all_subsequent_children_in_path: bool, + full: bool, ) -> None: obj.print( render_status_table( @@ -83,6 +90,7 @@ def status( target=target, execute_along_path=execute_along_path, execute_on_all_subsequent_children_in_path=execute_on_all_subsequent_children_in_path, + show_actual_endpoint=full, ) ) obj.print_status_summary() diff --git a/src/drunc/controller/interface/shell_utils.py b/src/drunc/controller/interface/shell_utils.py index f2e741d3f..c1d0193a0 100644 --- a/src/drunc/controller/interface/shell_utils.py +++ b/src/drunc/controller/interface/shell_utils.py @@ -71,7 +71,10 @@ def match_children( def get_status_table( - status_response: StatusResponse, describe_response: DescribeResponse, display_host_overrides: dict[str, str] | None = None, + status_response: StatusResponse, + describe_response: DescribeResponse, + display_host_overrides: dict[str, str] | None = None, + show_actual_endpoint: bool = False, ): status = status_response.status description = describe_response.description @@ -90,6 +93,8 @@ def get_status_table( t.add_column("In error") t.add_column("Included") t.add_column("Endpoint") + if show_actual_endpoint: + t.add_column("Actual Endpoint") def add_status_to_table( table: Table, @@ -102,57 +107,55 @@ def add_status_to_table( if status is None or description is None: return - def update_endpoint(endpoint: str, proc_name: str) -> str: + def update_endpoint(endpoint: str, proc_name: str) -> tuple[str, str]: """ - Parses endpoint to a human readable hostname - - Args: - endpoint: Process URI + Parses endpoint to a human readable hostname. Returns: - str: URI with human readable hostname + tuple[str, str]: (display_endpoint, actual_endpoint) + display_endpoint: URI with human readable hostname + actual_endpoint: raw URI with actual IP/host (empty if same as display) """ if not endpoint: - return "" + return "", "" parsed = urlparse(endpoint) raw_host = parsed.hostname if not raw_host: - return "" + return "", "" scheme = parsed.scheme port = parsed.port + def make_uri(host: str) -> str: + return f"{scheme}://{host}:{port}" if port is not None else f"{scheme}://{host}" + if display_host_overrides and proc_name in display_host_overrides: - display_host = display_host_overrides[proc_name] - pretty = ( - f"{scheme}://{display_host}:{port}" - if port is not None - else f"{scheme}://{display_host}" - ) + display_host = get_hostname_smart(display_host_overrides[proc_name]) + pretty = make_uri(display_host) if display_host != raw_host: - return f"{pretty} [dim](actual: {raw_host})[/dim]" - return pretty + return pretty, make_uri(raw_host) + return pretty, "" resolved = get_hostname_smart(raw_host) if resolved != raw_host: - return ( - f"{scheme}://{resolved}:{port}" - if port is not None - else f"{scheme}://{resolved}" - ) + return make_uri(resolved), endpoint - return endpoint + return endpoint, "" - table.add_row( + display_ep, actual_ep = update_endpoint(description.endpoint, status_response.name) + row = [ prefix + status_response.name, description.info, status.state, status.sub_state, format_bool(status.in_error, false_is_good=True), format_bool(status.included), - update_endpoint(description.endpoint, status_response.name), - ) + display_ep, + ] + if show_actual_endpoint: + row.append(actual_ep) + table.add_row(*row) children = match_children(status_response.children, describe_response.children) children_list = sorted(list(children.keys())) @@ -210,6 +213,7 @@ def render_status_table( target: str = "", execute_along_path: bool = True, execute_on_all_subsequent_children_in_path: bool = True, + show_actual_endpoint: bool = False, ): statuses = ctx.get_driver("controller").status( target=target, @@ -226,6 +230,7 @@ def render_status_table( statuses, descriptions, display_host_overrides=display_host_overrides, + show_actual_endpoint=show_actual_endpoint, ) class StatusTableUpdater(Progress): @@ -807,20 +812,26 @@ def generate_fsm_command(ctx, transition: FSMCommandDescription, controller_name @functools.lru_cache(maxsize=4096) def get_hostname_smart(ip_or_host: str, timeout_seconds: float = 0.2) -> str: """ - Resolves an IP to a hostname, with optimizations: + Resolves an IP or hostname to a human-readable hostname, with optimizations: 1. Caches all results. - 2. Immediately skips private/internal IPs (like K8s). - 3. Uses a short timeout for public IPs. + 2. Replaces localhost/loopback with the machine's actual hostname. + 3. Uses a short timeout for reverse DNS lookups on other IPs. """ if not ip_or_host: return "" + if ip_or_host == "localhost": + return socket.gethostname() + try: ip_address = ipaddress.ip_address(ip_or_host) except ValueError: return ip_or_host - # If public IP, try to resolve it. + + if ip_address.is_loopback: + return socket.gethostname() + original_timeout = socket.getdefaulttimeout() try: socket.setdefaulttimeout(timeout_seconds) From a1cec1fb60d5edd4c76d4205077cae9fb8f5287f Mon Sep 17 00:00:00 2001 From: wanyunSu Date: Tue, 12 May 2026 14:50:43 +0200 Subject: [PATCH 04/73] address comments --- src/drunc/controller/interface/shell_utils.py | 9 ++++++- src/drunc/unified_shell/context.py | 24 ++++++++++--------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/drunc/controller/interface/shell_utils.py b/src/drunc/controller/interface/shell_utils.py index c1d0193a0..3b31ae288 100644 --- a/src/drunc/controller/interface/shell_utils.py +++ b/src/drunc/controller/interface/shell_utils.py @@ -111,6 +111,10 @@ def update_endpoint(endpoint: str, proc_name: str) -> tuple[str, str]: """ Parses endpoint to a human readable hostname. + Args: + endpoint: The endpoint to parse + proc_name: The name of the process to parse the endpoint for + Returns: tuple[str, str]: (display_endpoint, actual_endpoint) display_endpoint: URI with human readable hostname @@ -128,7 +132,10 @@ def update_endpoint(endpoint: str, proc_name: str) -> tuple[str, str]: port = parsed.port def make_uri(host: str) -> str: - return f"{scheme}://{host}:{port}" if port is not None else f"{scheme}://{host}" + uri = f"{scheme}://{host}" + if port is not None: + uri = f"{uri}:{port}" + return uri if display_host_overrides and proc_name in display_host_overrides: display_host = get_hostname_smart(display_host_overrides[proc_name]) diff --git a/src/drunc/unified_shell/context.py b/src/drunc/unified_shell/context.py index 08ba00360..264d21374 100644 --- a/src/drunc/unified_shell/context.py +++ b/src/drunc/unified_shell/context.py @@ -1,8 +1,10 @@ from collections.abc import Mapping from enum import Enum +import grpc from druncschema.token_pb2 import Token +from drunc.utils.grpc_utils import ServerTimeout, ServerUnreachable from drunc.utils.shell_utils import ShellContext @@ -109,30 +111,30 @@ def get_endpoint_display_host_overrides(self) -> dict[str, str]: Returns: dict[str, str]: Mapping from process name to preferred display hostname. """ + # The PM driver may not be registered if the user connected directly to a + # controller without going through the process manager (e.g. standalone boot). + # In that case hostname overrides are unavailable and we fall back to + # get_hostname_smart in the endpoint rendering path. pm_driver = self.get_driver("process_manager", quiet_fail=True) if not pm_driver: return {} from druncschema.process_manager_pb2 import ProcessQuery + query = ( + ProcessQuery(names=[".*"], session=self.session_name) + if self.session_name + else ProcessQuery(names=[".*"]) + ) try: - query = ( - ProcessQuery(names=[".*"], session=self.session_name) - if self.session_name - else ProcessQuery(names=[".*"]) - ) proc_list = pm_driver.ps(query) - except Exception: + except (ServerUnreachable, ServerTimeout, grpc.RpcError): return {} overrides: dict[str, str] = {} for proc in proc_list.values: - try: - metadata = proc.process_description.metadata - except Exception: - continue - + metadata = proc.process_description.metadata proc_name = getattr(metadata, "name", "") host_name = getattr(metadata, "hostname", "") From 127f50a8e594565761700f26b98a398f420fb31f Mon Sep 17 00:00:00 2001 From: wanyunSu Date: Tue, 19 May 2026 13:34:06 +0200 Subject: [PATCH 05/73] clearer lcs usage --- .../process_manager/k8s_process_manager.py | 57 ++++++++++--------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/src/drunc/process_manager/k8s_process_manager.py b/src/drunc/process_manager/k8s_process_manager.py index 152da18c6..e12880da2 100644 --- a/src/drunc/process_manager/k8s_process_manager.py +++ b/src/drunc/process_manager/k8s_process_manager.py @@ -208,7 +208,13 @@ def __init__(self, configuration: ProcessManagerConfHandler, **kwargs) -> None: self.uuids_pending_deletion = set() self.termination_complete_event = threading.Event() self.final_exit_codes = {} - self.local_connection_server_is_booted = False + # Per-session LCS state: each key is a session (k8s namespace) name. + # Using dicts instead of scalars allows multiple concurrent sessions, + # some with an LCS and some without, without cross-session interference. + self.local_connection_server_podname: dict[str, str | None] = {} + self.local_connection_server_port: dict[str, int | None] = {} + self.local_connection_server_node_port: dict[str, int | None] = {} + self.local_connection_server_is_booted: dict[str, bool] = {} # Host verification cache: {hostname: (is_valid, timestamp)} self._host_cache = {} @@ -229,10 +235,6 @@ def __init__(self, configuration: ProcessManagerConfHandler, **kwargs) -> None: # Readout app selector self.perf_selector = settings.get("readout_app_selector", "runp").lower() - # CONFIGURATION - connection server connection port numbers - self.connection_server_port = None - self.connection_server_node_port = None - # CONFIGURATION - per-pod service port number service = settings.get("service", {}) self.headless_discovery_port = service.get("headless_discovery_port", 80) @@ -808,9 +810,9 @@ def _create_nodeport_service(self, podname, session, pod_uid) -> None: ports=[ client.V1ServicePort( protocol="TCP", - port=self.connection_server_port, - target_port=self.connection_server_port, - node_port=self.connection_server_node_port, + port=self.local_connection_server_port[session], + target_port=self.local_connection_server_port[session], + node_port=self.local_connection_server_node_port[session], ) ], ), @@ -820,8 +822,8 @@ def _create_nodeport_service(self, podname, session, pod_uid) -> None: namespace=session, body=service_manifest ) self.log.info( - f'Created NodePort service "{session}.{podname}" on port {self.connection_server_port} ' - f"(NodePort: {self.connection_server_node_port} for external access)" + f'Created NodePort service "{session}.{podname}" on port {self.local_connection_server_port[session]} ' + f"(NodePort: {self.local_connection_server_node_port[session]} for external access)" ) except self._api_error_v1_api as e: @@ -836,7 +838,7 @@ def _create_nodeport_service(self, podname, session, pod_uid) -> None: is_port_conflict = True if is_port_conflict: - port = self.connection_server_node_port + port = self.local_connection_server_node_port[session] error_message = ( f"NodePort {port} is already in use by another service. " f"Cannot start '{podname}'." @@ -1318,7 +1320,6 @@ def _build_pod_main_container( self._is_local_connection_server(tree_labels, podname) and lcs_port is not None ): - self.connection_server_name = podname container_ports.append( client.V1ContainerPort(container_port=lcs_port, name="http-port") ) @@ -1397,7 +1398,7 @@ def _get_pod_host_aliases( host_aliases = None if ( not self._is_local_connection_server(tree_labels, podname) - and self.local_connection_server_is_booted + and self.local_connection_server_is_booted.get(session, False) ): connection_server_ip = None retry_count = 0 @@ -1625,20 +1626,17 @@ def _create_associated_service( raise DruncK8sException( "LCS service creation failed: port was not extracted." ) - # This call uses class variables set in _create_pod self._create_nodeport_service(podname, session, pod_uid) elif self._is_root_controller(tree_labels): self.log.info( f"'{podname}' is the root controller, checking for NodePort service." ) - # This call also relies on class variables, so we must set them - # here, just as the original logic did. port = self._extract_port_from_cmd(boot_request) if port: self.log.info(f"Extracted port {port} for '{podname}' NodePort.") - self.connection_server_port = port - self.connection_server_node_port = port + self.local_connection_server_port[session] = port + self.local_connection_server_node_port[session] = port self._create_nodeport_service(podname, session, pod_uid) else: # This case should be caught by _determine_service_type, @@ -1672,12 +1670,13 @@ def _create_pod( """ try: lcs_port = None - # Early Port Extraction and Class Variable Setup for LCS + # Early Port Extraction and Per-Session State Setup for LCS if self._is_local_connection_server(tree_labels, podname): lcs_port = self._extract_port_from_cmd(boot_request) if lcs_port: - self.connection_server_port = lcs_port - self.connection_server_node_port = lcs_port + self.local_connection_server_podname[session] = podname + self.local_connection_server_port[session] = lcs_port + self.local_connection_server_node_port[session] = lcs_port else: raise DruncK8sException( f"Could not extract port for LCS '{podname}'." @@ -1760,8 +1759,8 @@ def _get_connection_server_cluster_ip(self, session: str) -> str: Get the ClusterIP of the connection server's Kubernetes Service. Reads the named service from the session namespace and returns its - clusterIP. Returns None if the service cannot be found or an API - error occurs. + clusterIP. Returns None if the session has no LCS, the service cannot + be found, or an API error occurs. Args: session - the Kubernetes namespace (session) containing the service @@ -1769,9 +1768,15 @@ def _get_connection_server_cluster_ip(self, session: str) -> str: Returns: cluster_ip - the ClusterIP string, or None on failure """ + podname = self.local_connection_server_podname.get(session) + if podname is None: + self.log.warning( + f"No local connection server registered for session '{session}'" + ) + return None try: service = self._core_v1_api.read_namespaced_service( - name=self.connection_server_name, namespace=session + name=podname, namespace=session ) return service.spec.cluster_ip except self._api_error_v1_api as e: @@ -2147,7 +2152,7 @@ def _wait_for_lcs_readiness(self, podname: str, session: str) -> None: node_name = self._wait_for_pod_api_ready(podname, session, total_timeout) # --- STAGE 2: Wait for NodePort to be externally reachable (using HTTP urllib) --- - url = f"http://{node_name}:{self.connection_server_node_port}" + url = f"http://{node_name}:{self.local_connection_server_node_port[session]}" # Calculate remaining time for stage 2, preserving original logic elapsed_stage1 = time() - start_time @@ -2160,7 +2165,7 @@ def _wait_for_lcs_readiness(self, podname: str, session: str) -> None: self._wait_for_nodeport_http_ready(url, remaining_time) - self.local_connection_server_is_booted = True + self.local_connection_server_is_booted[session] = True self.log.info(f"Connection server '{podname}' is fully ready.") def _wait_for_controller_readiness( From b25d8c5a3df54c771bed0814fb60dbd0dc50fc2b Mon Sep 17 00:00:00 2001 From: wanyunSu Date: Tue, 19 May 2026 15:58:15 +0200 Subject: [PATCH 06/73] use datacleass for LCS states, safer nodeport service --- .../process_manager/k8s_process_manager.py | 121 ++++++++++++------ 1 file changed, 83 insertions(+), 38 deletions(-) diff --git a/src/drunc/process_manager/k8s_process_manager.py b/src/drunc/process_manager/k8s_process_manager.py index e12880da2..de9ec657b 100644 --- a/src/drunc/process_manager/k8s_process_manager.py +++ b/src/drunc/process_manager/k8s_process_manager.py @@ -9,6 +9,7 @@ import urllib.error import urllib.request import uuid +from dataclasses import dataclass, field from time import sleep, time # Local Application Imports @@ -49,6 +50,22 @@ from drunc.utils.utils import get_logger, resolve_localhost_to_hostname +@dataclass +class _LcsSessionState: + """ + Holds all Local Connection Server (LCS) state for a single session (k8s namespace). + + Using a dataclass instead of parallel dicts prevents the fields from drifting + out of sync and makes it obvious that they all describe the same LCS instance. + A session that has no LCS simply has no entry in K8sProcessManager._lcs_state. + """ + + podname: str | None = None + port: int | None = None + node_port: int | None = None + is_booted: bool = False + + class K8sPodWatcherThread(threading.Thread): def __init__(self, pm) -> None: """ @@ -208,13 +225,10 @@ def __init__(self, configuration: ProcessManagerConfHandler, **kwargs) -> None: self.uuids_pending_deletion = set() self.termination_complete_event = threading.Event() self.final_exit_codes = {} - # Per-session LCS state: each key is a session (k8s namespace) name. - # Using dicts instead of scalars allows multiple concurrent sessions, - # some with an LCS and some without, without cross-session interference. - self.local_connection_server_podname: dict[str, str | None] = {} - self.local_connection_server_port: dict[str, int | None] = {} - self.local_connection_server_node_port: dict[str, int | None] = {} - self.local_connection_server_is_booted: dict[str, bool] = {} + # Per-session LCS state. Keyed by session (k8s namespace) name. + # Sessions that have no LCS simply have no entry here. + # Use _lcs_state_for(session) to read and _lcs_state.pop(session) to clean up. + self._lcs_state: dict[str, _LcsSessionState] = {} # Host verification cache: {hostname: (is_valid, timestamp)} self._host_cache = {} @@ -362,6 +376,17 @@ def notify_termination( self.log.info(end_str) self.broadcast(end_str, BroadcastType.SUBPROCESS_STATUS_UPDATE) + # If the terminated pod was the LCS for its session, mark it as down. + # This prevents other pods booting later from trying to resolve a + # localhost alias that no longer exists. + lcs = self._lcs_state.get(session) + if lcs is not None and lcs.podname == meta.name: + self.log.info( + f"LCS pod '{meta.name}' for session '{session}' has terminated; " + "clearing LCS booted state." + ) + lcs.is_booted = False + # Clear the list of processes being removed if proc_uuid in self.uuids_pending_deletion: self.uuids_pending_deletion.remove(proc_uuid) @@ -507,6 +532,16 @@ def _is_root_controller(self, tree_labels: dict[str, str]) -> bool: """ return tree_labels.get(f"role.{self.drunc_label}") == "root-controller" + def _lcs_state_for(self, session: str) -> _LcsSessionState: + """ + Return the LCS state for *session*, creating a fresh entry if absent. + + Centralises dict access so callers never deal with missing-key logic. + """ + if session not in self._lcs_state: + self._lcs_state[session] = _LcsSessionState() + return self._lcs_state[session] + def _is_host_cached(self, host: str) -> None | bool: """ Check if host is cached and not expired. @@ -767,20 +802,26 @@ def _create_headless_service(self, podname, session, pod_uid) -> None: if e.status != 409: self.log.error(f"Failed to create headless service for {podname}: {e}") - def _create_nodeport_service(self, podname, session, pod_uid) -> None: + def _create_nodeport_service( + self, + podname: str, + session: str, + pod_uid: str, + port: int, + node_port: int, + ) -> None: """ Create a NodePort Kubernetes Service for external access. - Builds and creates a NodePort Service with externalTrafficPolicy=Local, - mapping the connection_server_port to a fixed NodePort - (connection_server_node_port). The service is owned by the pod via an - OwnerReference. Raises a DruncK8sException if the NodePort is already - allocated or another API error occurs. + Builds and creates a NodePort Service with externalTrafficPolicy=Local. + The service is owned by the pod via an OwnerReference. Args: - podname - the name of the pod (also used as the service name) - session - the Kubernetes namespace (session) to create the service in - pod_uid - the UID of the owning pod for the OwnerReference + podname - the name of the pod (also used as the service name) + session - the Kubernetes namespace (session) to create the service in + pod_uid - the UID of the owning pod for the OwnerReference + port - the container port to expose + node_port - the fixed NodePort to allocate on cluster nodes Raises: DruncK8sException - if the NodePort is already in use or another API error occurs @@ -810,9 +851,9 @@ def _create_nodeport_service(self, podname, session, pod_uid) -> None: ports=[ client.V1ServicePort( protocol="TCP", - port=self.local_connection_server_port[session], - target_port=self.local_connection_server_port[session], - node_port=self.local_connection_server_node_port[session], + port=port, + target_port=port, + node_port=node_port, ) ], ), @@ -822,8 +863,8 @@ def _create_nodeport_service(self, podname, session, pod_uid) -> None: namespace=session, body=service_manifest ) self.log.info( - f'Created NodePort service "{session}.{podname}" on port {self.local_connection_server_port[session]} ' - f"(NodePort: {self.local_connection_server_node_port[session]} for external access)" + f'Created NodePort service "{session}.{podname}" on port {port} ' + f"(NodePort: {node_port} for external access)" ) except self._api_error_v1_api as e: @@ -838,9 +879,8 @@ def _create_nodeport_service(self, podname, session, pod_uid) -> None: is_port_conflict = True if is_port_conflict: - port = self.local_connection_server_node_port[session] error_message = ( - f"NodePort {port} is already in use by another service. " + f"NodePort {node_port} is already in use by another service. " f"Cannot start '{podname}'." ) self.log.error(error_message) @@ -1398,7 +1438,7 @@ def _get_pod_host_aliases( host_aliases = None if ( not self._is_local_connection_server(tree_labels, podname) - and self.local_connection_server_is_booted.get(session, False) + and self._lcs_state.get(session, _LcsSessionState()).is_booted ): connection_server_ip = None retry_count = 0 @@ -1626,7 +1666,9 @@ def _create_associated_service( raise DruncK8sException( "LCS service creation failed: port was not extracted." ) - self._create_nodeport_service(podname, session, pod_uid) + self._create_nodeport_service( + podname, session, pod_uid, port=lcs_port, node_port=lcs_port + ) elif self._is_root_controller(tree_labels): self.log.info( @@ -1635,9 +1677,9 @@ def _create_associated_service( port = self._extract_port_from_cmd(boot_request) if port: self.log.info(f"Extracted port {port} for '{podname}' NodePort.") - self.local_connection_server_port[session] = port - self.local_connection_server_node_port[session] = port - self._create_nodeport_service(podname, session, pod_uid) + self._create_nodeport_service( + podname, session, pod_uid, port=port, node_port=port + ) else: # This case should be caught by _determine_service_type, # but we handle it just in case. @@ -1670,13 +1712,14 @@ def _create_pod( """ try: lcs_port = None - # Early Port Extraction and Per-Session State Setup for LCS + # Early Port Extraction and Per-Session LCS State Setup if self._is_local_connection_server(tree_labels, podname): lcs_port = self._extract_port_from_cmd(boot_request) if lcs_port: - self.local_connection_server_podname[session] = podname - self.local_connection_server_port[session] = lcs_port - self.local_connection_server_node_port[session] = lcs_port + lcs = self._lcs_state_for(session) + lcs.podname = podname + lcs.port = lcs_port + lcs.node_port = lcs_port else: raise DruncK8sException( f"Could not extract port for LCS '{podname}'." @@ -1768,15 +1811,15 @@ def _get_connection_server_cluster_ip(self, session: str) -> str: Returns: cluster_ip - the ClusterIP string, or None on failure """ - podname = self.local_connection_server_podname.get(session) - if podname is None: + lcs = self._lcs_state.get(session) + if lcs is None or lcs.podname is None: self.log.warning( f"No local connection server registered for session '{session}'" ) return None try: service = self._core_v1_api.read_namespaced_service( - name=podname, namespace=session + name=lcs.podname, namespace=session ) return service.spec.cluster_ip except self._api_error_v1_api as e: @@ -2135,7 +2178,7 @@ def _wait_for_lcs_readiness(self, podname: str, session: str) -> None: Stage 1: waits for the pod to be Running and Ready in the Kubernetes API. Stage 2: waits for the NodePort to be externally reachable via HTTP. - Sets local_connection_server_is_booted to True on success. + Sets _lcs_state[session].is_booted to True on success. Args: podname - the name of the LCS pod to wait for @@ -2152,7 +2195,8 @@ def _wait_for_lcs_readiness(self, podname: str, session: str) -> None: node_name = self._wait_for_pod_api_ready(podname, session, total_timeout) # --- STAGE 2: Wait for NodePort to be externally reachable (using HTTP urllib) --- - url = f"http://{node_name}:{self.local_connection_server_node_port[session]}" + lcs = self._lcs_state_for(session) + url = f"http://{node_name}:{lcs.node_port}" # Calculate remaining time for stage 2, preserving original logic elapsed_stage1 = time() - start_time @@ -2165,7 +2209,7 @@ def _wait_for_lcs_readiness(self, podname: str, session: str) -> None: self._wait_for_nodeport_http_ready(url, remaining_time) - self.local_connection_server_is_booted[session] = True + lcs.is_booted = True self.log.info(f"Connection server '{podname}' is fully ready.") def _wait_for_controller_readiness( @@ -2621,6 +2665,7 @@ def kill_and_wait(uuids, grace_period=None) -> None: self.log.info(f'Session "{session}" is empty, deleting namespace.') self._core_v1_api.delete_namespace(session) self.managed_sessions.remove(session) + self._lcs_state.pop(session, None) except self._api_error_v1_api as e: self.log.warning(f"Failed during namespace cleanup: {e}") From 5b7be1aca55dd4d257f22196692d476a62954d20 Mon Sep 17 00:00:00 2001 From: wanyunSu Date: Tue, 19 May 2026 16:24:26 +0200 Subject: [PATCH 07/73] better use host alias --- .../process_manager/k8s_process_manager.py | 51 ++++++++++--------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/src/drunc/process_manager/k8s_process_manager.py b/src/drunc/process_manager/k8s_process_manager.py index de9ec657b..60eee88b3 100644 --- a/src/drunc/process_manager/k8s_process_manager.py +++ b/src/drunc/process_manager/k8s_process_manager.py @@ -9,7 +9,7 @@ import urllib.error import urllib.request import uuid -from dataclasses import dataclass, field +from dataclasses import dataclass from time import sleep, time # Local Application Imports @@ -1435,32 +1435,33 @@ def _get_pod_host_aliases( host_aliases - a list containing a single V1HostAlias mapping localhost to the connection server IP, or None if not applicable """ - host_aliases = None + lcs = self._lcs_state.get(session) if ( - not self._is_local_connection_server(tree_labels, podname) - and self._lcs_state.get(session, _LcsSessionState()).is_booted + self._is_local_connection_server(tree_labels, podname) + or lcs is None + or not lcs.is_booted ): - connection_server_ip = None - retry_count = 0 - max_retries = 10 - while not connection_server_ip and retry_count < max_retries: - connection_server_ip = self._get_connection_server_cluster_ip(session) - if not connection_server_ip: - sleep(1) - retry_count += 1 - - if connection_server_ip: - host_aliases = [ - client.V1HostAlias(ip=connection_server_ip, hostnames=["localhost"]) - ] - self.log.info( - f"Pod '{podname}' will resolve localhost to connection server IP {connection_server_ip}" - ) - else: - self.log.warning( - f"Could not get connection server ClusterIP for pod '{podname}'" - ) - return host_aliases + return None + + connection_server_ip = None + retry_count = 0 + max_retries = 10 + while not connection_server_ip and retry_count < max_retries: + connection_server_ip = self._get_connection_server_cluster_ip(session) + if not connection_server_ip: + sleep(1) + retry_count += 1 + + if connection_server_ip: + self.log.info( + f"Pod '{podname}' will resolve localhost to connection server IP {connection_server_ip}" + ) + return [client.V1HostAlias(ip=connection_server_ip, hostnames=["localhost"])] + + self.log.warning( + f"Could not get connection server ClusterIP for pod '{podname}'" + ) + return None def _determine_service_type( self, podname: str, boot_request: BootRequest, tree_labels: dict[str, str] From 9c16e4e0c92ace182dd7092fe836781e42ab1cbe Mon Sep 17 00:00:00 2001 From: wanyunSu Date: Tue, 26 May 2026 13:18:40 +0200 Subject: [PATCH 08/73] resolve ip to fqdn --- src/drunc/controller/interface/shell_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/drunc/controller/interface/shell_utils.py b/src/drunc/controller/interface/shell_utils.py index 3b31ae288..9bf0b6ebf 100644 --- a/src/drunc/controller/interface/shell_utils.py +++ b/src/drunc/controller/interface/shell_utils.py @@ -834,7 +834,8 @@ def get_hostname_smart(ip_or_host: str, timeout_seconds: float = 0.2) -> str: try: ip_address = ipaddress.ip_address(ip_or_host) except ValueError: - return ip_or_host + fqdn = socket.getfqdn(ip_or_host) + return fqdn if fqdn else ip_or_host if ip_address.is_loopback: return socket.gethostname() From 35aa59b0e6670826a783b0a77f8d158ce065c8d5 Mon Sep 17 00:00:00 2001 From: wanyunSu Date: Tue, 26 May 2026 15:15:44 +0200 Subject: [PATCH 09/73] fix for ssh --- src/drunc/controller/interface/shell_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/drunc/controller/interface/shell_utils.py b/src/drunc/controller/interface/shell_utils.py index 9bf0b6ebf..3c7810d13 100644 --- a/src/drunc/controller/interface/shell_utils.py +++ b/src/drunc/controller/interface/shell_utils.py @@ -829,7 +829,7 @@ def get_hostname_smart(ip_or_host: str, timeout_seconds: float = 0.2) -> str: return "" if ip_or_host == "localhost": - return socket.gethostname() + return socket.getfqdn() try: ip_address = ipaddress.ip_address(ip_or_host) @@ -838,7 +838,7 @@ def get_hostname_smart(ip_or_host: str, timeout_seconds: float = 0.2) -> str: return fqdn if fqdn else ip_or_host if ip_address.is_loopback: - return socket.gethostname() + return socket.getfqdn() original_timeout = socket.getdefaulttimeout() try: From ad3ef532e4113e56f9d68dc2cfca0185496f09df Mon Sep 17 00:00:00 2001 From: James Paul Turner Date: Thu, 11 Jun 2026 16:05:04 +0100 Subject: [PATCH 10/73] Temp rename of broadcast dir. --- src/drunc/{broadcast => REMOVE_ME_broadcast}/__init__.py | 0 src/drunc/{broadcast => REMOVE_ME_broadcast}/client/__init__.py | 0 .../client/broadcast_handler.py | 0 .../client/broadcast_handler_implementation.py | 0 .../{broadcast => REMOVE_ME_broadcast}/client/configuration.py | 0 .../client/grpc_stdout_broadcast_handler.py | 0 .../client/kafka_stdout_broadcast_handler.py | 0 src/drunc/{broadcast => REMOVE_ME_broadcast}/server/__init__.py | 0 .../server/broadcast_sender.py | 0 .../server/broadcast_sender_implementation.py | 0 .../{broadcast => REMOVE_ME_broadcast}/server/configuration.py | 0 .../{broadcast => REMOVE_ME_broadcast}/server/decorators.py | 2 -- .../{broadcast => REMOVE_ME_broadcast}/server/grpc_servicer.py | 0 .../{broadcast => REMOVE_ME_broadcast}/server/kafka_sender.py | 0 src/drunc/{broadcast => REMOVE_ME_broadcast}/types.py | 0 src/drunc/{broadcast => REMOVE_ME_broadcast}/utils.py | 0 16 files changed, 2 deletions(-) rename src/drunc/{broadcast => REMOVE_ME_broadcast}/__init__.py (100%) rename src/drunc/{broadcast => REMOVE_ME_broadcast}/client/__init__.py (100%) rename src/drunc/{broadcast => REMOVE_ME_broadcast}/client/broadcast_handler.py (100%) rename src/drunc/{broadcast => REMOVE_ME_broadcast}/client/broadcast_handler_implementation.py (100%) rename src/drunc/{broadcast => REMOVE_ME_broadcast}/client/configuration.py (100%) rename src/drunc/{broadcast => REMOVE_ME_broadcast}/client/grpc_stdout_broadcast_handler.py (100%) rename src/drunc/{broadcast => REMOVE_ME_broadcast}/client/kafka_stdout_broadcast_handler.py (100%) rename src/drunc/{broadcast => REMOVE_ME_broadcast}/server/__init__.py (100%) rename src/drunc/{broadcast => REMOVE_ME_broadcast}/server/broadcast_sender.py (100%) rename src/drunc/{broadcast => REMOVE_ME_broadcast}/server/broadcast_sender_implementation.py (100%) rename src/drunc/{broadcast => REMOVE_ME_broadcast}/server/configuration.py (100%) rename src/drunc/{broadcast => REMOVE_ME_broadcast}/server/decorators.py (99%) rename src/drunc/{broadcast => REMOVE_ME_broadcast}/server/grpc_servicer.py (100%) rename src/drunc/{broadcast => REMOVE_ME_broadcast}/server/kafka_sender.py (100%) rename src/drunc/{broadcast => REMOVE_ME_broadcast}/types.py (100%) rename src/drunc/{broadcast => REMOVE_ME_broadcast}/utils.py (100%) diff --git a/src/drunc/broadcast/__init__.py b/src/drunc/REMOVE_ME_broadcast/__init__.py similarity index 100% rename from src/drunc/broadcast/__init__.py rename to src/drunc/REMOVE_ME_broadcast/__init__.py diff --git a/src/drunc/broadcast/client/__init__.py b/src/drunc/REMOVE_ME_broadcast/client/__init__.py similarity index 100% rename from src/drunc/broadcast/client/__init__.py rename to src/drunc/REMOVE_ME_broadcast/client/__init__.py diff --git a/src/drunc/broadcast/client/broadcast_handler.py b/src/drunc/REMOVE_ME_broadcast/client/broadcast_handler.py similarity index 100% rename from src/drunc/broadcast/client/broadcast_handler.py rename to src/drunc/REMOVE_ME_broadcast/client/broadcast_handler.py diff --git a/src/drunc/broadcast/client/broadcast_handler_implementation.py b/src/drunc/REMOVE_ME_broadcast/client/broadcast_handler_implementation.py similarity index 100% rename from src/drunc/broadcast/client/broadcast_handler_implementation.py rename to src/drunc/REMOVE_ME_broadcast/client/broadcast_handler_implementation.py diff --git a/src/drunc/broadcast/client/configuration.py b/src/drunc/REMOVE_ME_broadcast/client/configuration.py similarity index 100% rename from src/drunc/broadcast/client/configuration.py rename to src/drunc/REMOVE_ME_broadcast/client/configuration.py diff --git a/src/drunc/broadcast/client/grpc_stdout_broadcast_handler.py b/src/drunc/REMOVE_ME_broadcast/client/grpc_stdout_broadcast_handler.py similarity index 100% rename from src/drunc/broadcast/client/grpc_stdout_broadcast_handler.py rename to src/drunc/REMOVE_ME_broadcast/client/grpc_stdout_broadcast_handler.py diff --git a/src/drunc/broadcast/client/kafka_stdout_broadcast_handler.py b/src/drunc/REMOVE_ME_broadcast/client/kafka_stdout_broadcast_handler.py similarity index 100% rename from src/drunc/broadcast/client/kafka_stdout_broadcast_handler.py rename to src/drunc/REMOVE_ME_broadcast/client/kafka_stdout_broadcast_handler.py diff --git a/src/drunc/broadcast/server/__init__.py b/src/drunc/REMOVE_ME_broadcast/server/__init__.py similarity index 100% rename from src/drunc/broadcast/server/__init__.py rename to src/drunc/REMOVE_ME_broadcast/server/__init__.py diff --git a/src/drunc/broadcast/server/broadcast_sender.py b/src/drunc/REMOVE_ME_broadcast/server/broadcast_sender.py similarity index 100% rename from src/drunc/broadcast/server/broadcast_sender.py rename to src/drunc/REMOVE_ME_broadcast/server/broadcast_sender.py diff --git a/src/drunc/broadcast/server/broadcast_sender_implementation.py b/src/drunc/REMOVE_ME_broadcast/server/broadcast_sender_implementation.py similarity index 100% rename from src/drunc/broadcast/server/broadcast_sender_implementation.py rename to src/drunc/REMOVE_ME_broadcast/server/broadcast_sender_implementation.py diff --git a/src/drunc/broadcast/server/configuration.py b/src/drunc/REMOVE_ME_broadcast/server/configuration.py similarity index 100% rename from src/drunc/broadcast/server/configuration.py rename to src/drunc/REMOVE_ME_broadcast/server/configuration.py diff --git a/src/drunc/broadcast/server/decorators.py b/src/drunc/REMOVE_ME_broadcast/server/decorators.py similarity index 99% rename from src/drunc/broadcast/server/decorators.py rename to src/drunc/REMOVE_ME_broadcast/server/decorators.py index ece3f95dd..e8407748c 100644 --- a/src/drunc/broadcast/server/decorators.py +++ b/src/drunc/REMOVE_ME_broadcast/server/decorators.py @@ -1,5 +1,3 @@ - - from drunc.utils.utils import get_logger diff --git a/src/drunc/broadcast/server/grpc_servicer.py b/src/drunc/REMOVE_ME_broadcast/server/grpc_servicer.py similarity index 100% rename from src/drunc/broadcast/server/grpc_servicer.py rename to src/drunc/REMOVE_ME_broadcast/server/grpc_servicer.py diff --git a/src/drunc/broadcast/server/kafka_sender.py b/src/drunc/REMOVE_ME_broadcast/server/kafka_sender.py similarity index 100% rename from src/drunc/broadcast/server/kafka_sender.py rename to src/drunc/REMOVE_ME_broadcast/server/kafka_sender.py diff --git a/src/drunc/broadcast/types.py b/src/drunc/REMOVE_ME_broadcast/types.py similarity index 100% rename from src/drunc/broadcast/types.py rename to src/drunc/REMOVE_ME_broadcast/types.py diff --git a/src/drunc/broadcast/utils.py b/src/drunc/REMOVE_ME_broadcast/utils.py similarity index 100% rename from src/drunc/broadcast/utils.py rename to src/drunc/REMOVE_ME_broadcast/utils.py From 054df89bed500019c10c950611c5e43af58ed3f0 Mon Sep 17 00:00:00 2001 From: James Paul Turner Date: Thu, 11 Jun 2026 16:28:42 +0100 Subject: [PATCH 11/73] Remove broadcasting from controller. --- src/drunc/controller/controller.py | 53 ------------------------------ 1 file changed, 53 deletions(-) diff --git a/src/drunc/controller/controller.py b/src/drunc/controller/controller.py index c73f8174b..69ffef1fa 100644 --- a/src/drunc/controller/controller.py +++ b/src/drunc/controller/controller.py @@ -6,7 +6,6 @@ from daqpytools.logging import LogHandlerConf, setup_daq_ers_logger from druncschema.authoriser_pb2 import ActionType, SystemType -from druncschema.broadcast_pb2 import BroadcastType from druncschema.controller_pb2 import ( DescribeFSMRequest, DescribeFSMResponse, @@ -47,9 +46,6 @@ from drunc.authoriser.configuration import DummyAuthoriserConfHandler from drunc.authoriser.decorators import authentified_and_authorised from drunc.authoriser.dummy_authoriser import DummyAuthoriser -from drunc.broadcast.server.broadcast_sender import BroadcastSender -from drunc.broadcast.server.configuration import BroadcastSenderConfHandler -from drunc.broadcast.server.decorators import broadcasted from drunc.connectivity_service.client import ConnectivityServiceClient from drunc.connectivity_service.exceptions import ApplicationLookupUnsuccessful from drunc.controller.children_interface.child_node import ChildNode @@ -85,7 +81,6 @@ def __init__(self, configuration, name: str, session: str, token: Token): self._previous_error_state = False self.name = name self.session = session - self.broadcast_service = None self.monitoring_metrics = ControllerMonitoringMetrics() self.handlerconf = LogHandlerConf(init_ers=True) self.log = get_logger(f"controller.core.{name}_ctrl") @@ -109,15 +104,6 @@ def __init__(self, configuration, name: str, session: str, token: Token): self.opmon_publisher = getattr(self.configuration, "opmon_publisher", None) self.stop_event: threading.Event | None = None self.thread: threading.Thread | None = None - bsch = BroadcastSenderConfHandler( - data=self.configuration.data.controller.broadcaster, - ) - - self.broadcast_service = BroadcastSender( - name=name, - session=session, - configuration=bsch, - ) self.fsm_config = FSMConfHandler( data=self.configuration.data.controller.fsm, @@ -256,28 +242,9 @@ def init_controller(self) -> None: ) self.thread.start() - self.broadcast(message="ready", btype=BroadcastType.SERVER_READY) self.stateful_node.set_ready_state(True) log_init_controller.info("Controller ready") - """ - A couple of simple pass-through functions to the broadcasting service - """ - - def broadcast(self, *args, **kwargs): - return self.broadcast_service.broadcast(*args, **kwargs) - - def can_broadcast(self, *args, **kwargs): - if self.broadcast_service: - return self.broadcast_service.can_broadcast(*args, **kwargs) - return False - - def describe_broadcast(self, *args, **kwargs): - return self.broadcast_service.describe_broadcast(*args, **kwargs) - - def interrupt_with_exception(self, *args, **kwargs): - return self.broadcast_service._interrupt_with_exception(*args, **kwargs) - def controller_publisher(self, message, custom_origin: dict | None = None): if isinstance(message, FSMStatus) and message.in_error: if message.in_error and not self._previous_error_state: @@ -394,12 +361,6 @@ def terminate(self): self.log.info("Unregistering from the connectivity service") self.connectivity_service.retract(self.name + "_control", fail_quickly=True) - if self.can_broadcast(): - self.broadcast( - btype=BroadcastType.SERVER_SHUTDOWN, - message="over_and_out", - ) - self.log.info("Stopping children") for child in self.children_nodes: self.log.debug(f"Stopping {child.name}") @@ -625,7 +586,6 @@ def _partition_connected_children( ############# Status, description commands ############# ######################################################## - @broadcasted @authentified_and_authorised(action=ActionType.READ, system=SystemType.CONTROLLER) @publish_command_time def status( @@ -688,7 +648,6 @@ def status( return response - @broadcasted @authentified_and_authorised(action=ActionType.READ, system=SystemType.CONTROLLER) @publish_command_time def describe( @@ -717,8 +676,6 @@ def describe( session=self.session, commands=None, ) - if broadcast_description := self.describe_broadcast(): - description.broadcast.Pack(broadcast_description) response.description.CopyFrom(description) # Children nodes (ignore exclusion). @@ -754,7 +711,6 @@ def describe( return response - @broadcasted @authentified_and_authorised(action=ActionType.READ, system=SystemType.CONTROLLER) @publish_command_time def describe_fsm( @@ -837,7 +793,6 @@ def describe_fsm( ############# FSM commands ############# ######################################## - @broadcasted @authentified_and_authorised(action=ActionType.UPDATE, system=SystemType.CONTROLLER) @in_control @publish_command_time @@ -1016,7 +971,6 @@ def execute_fsm_command( return response - @broadcasted @authentified_and_authorised(action=ActionType.EXPERT, system=SystemType.CONTROLLER) @in_control @publish_command_time @@ -1076,7 +1030,6 @@ def execute_expert_command( return response - @broadcasted @authentified_and_authorised(action=ActionType.UPDATE, system=SystemType.CONTROLLER) @in_control @publish_command_time @@ -1142,7 +1095,6 @@ def include( return response - @broadcasted @authentified_and_authorised(action=ActionType.UPDATE, system=SystemType.CONTROLLER) @in_control @publish_command_time @@ -1208,7 +1160,6 @@ def exclude( return response - @broadcasted @authentified_and_authorised(action=ActionType.UPDATE, system=SystemType.CONTROLLER) @in_control @publish_command_time @@ -1337,7 +1288,6 @@ def recompute_status( ############# Actor commands ############# ########################################## - @broadcasted @authentified_and_authorised(action=ActionType.UPDATE, system=SystemType.CONTROLLER) @publish_command_time def take_control( @@ -1413,7 +1363,6 @@ def take_control( return response - @broadcasted @authentified_and_authorised(action=ActionType.UPDATE, system=SystemType.CONTROLLER) @in_control @publish_command_time @@ -1490,7 +1439,6 @@ def surrender_control( return response - @broadcasted @authentified_and_authorised(action=ActionType.READ, system=SystemType.CONTROLLER) @publish_command_time def who_is_in_charge( @@ -1552,7 +1500,6 @@ def who_is_in_charge( ####### Integration test commands ######## ########################################## - @broadcasted @authentified_and_authorised(action=ActionType.UPDATE, system=SystemType.CONTROLLER) @in_control @publish_command_time From 7ad16d41d0013115ccc5fefda755669bc9f91891 Mon Sep 17 00:00:00 2001 From: James Paul Turner Date: Thu, 11 Jun 2026 16:49:19 +0100 Subject: [PATCH 12/73] Remove broadcasting from rest of controller package. --- .../controller/children_interface/grpc_child.py | 17 ++--------------- src/drunc/controller/configuration.py | 1 - src/drunc/controller/interface/context.py | 9 --------- src/drunc/controller/interface/shell_utils.py | 3 --- src/drunc/controller/stateful_node.py | 8 -------- 5 files changed, 2 insertions(+), 36 deletions(-) diff --git a/src/drunc/controller/children_interface/grpc_child.py b/src/drunc/controller/children_interface/grpc_child.py index 54c7f6f0d..c5f9910ff 100644 --- a/src/drunc/controller/children_interface/grpc_child.py +++ b/src/drunc/controller/children_interface/grpc_child.py @@ -35,15 +35,13 @@ from druncschema.token_pb2 import Token from grpc_status import rpc_status -from drunc.broadcast.client.broadcast_handler import BroadcastHandler -from drunc.broadcast.client.configuration import BroadcastClientConfHandler from drunc.connectivity_service.exceptions import ( ApplicationLookupUnsuccessful, ) from drunc.controller.children_interface.child_node import ChildNode from drunc.exceptions import DruncSetupException from drunc.grpc_settings import CONTROLLER_CLIENT_GRPC_CONFIG -from drunc.utils.configuration import ConfHandler, ConfTypes +from drunc.utils.configuration import ConfHandler from drunc.utils.grpc_utils import ( ServerUnreachable, rethrow_if_unreachable_server, @@ -116,7 +114,7 @@ def _setup_connection(self): tries_remaining -= 1 try: - response = self.stub.describe(request) + self.stub.describe(request) except grpc.RpcError as error: if tries_remaining == 0: @@ -131,7 +129,6 @@ def _setup_connection(self): else: self.log.info(f"Connected to the controller ({self.uri})!") - self.start_listening(response.description.broadcast) break def _attempt_reconnection(self, retry_call): @@ -185,9 +182,7 @@ def terminate(self) -> None: del self.channel if self.stub: del self.stub - self.channel = None - self.broadcast.stop() def check_connection(self) -> bool: """Probe child connectivity and retry once after reconnecting if needed. @@ -233,14 +228,6 @@ def check_connection(self) -> bool: return False - def start_listening(self, bdesc): - self.broadcast = BroadcastHandler( - BroadcastClientConfHandler( - data=bdesc, - type=ConfTypes.ProtobufAny, - ) - ) - def status( self, target: str = "", diff --git a/src/drunc/controller/configuration.py b/src/drunc/controller/configuration.py index 64944fa8c..18f4fab12 100644 --- a/src/drunc/controller/configuration.py +++ b/src/drunc/controller/configuration.py @@ -39,7 +39,6 @@ class cler: pass self.controller = cler() - self.controller.broadcaster = id_able() self.controller.fsm = id_able() diff --git a/src/drunc/controller/interface/context.py b/src/drunc/controller/interface/context.py index 082c42a00..8da69f38e 100644 --- a/src/drunc/controller/interface/context.py +++ b/src/drunc/controller/interface/context.py @@ -2,10 +2,7 @@ from druncschema.token_pb2 import Token -from drunc.broadcast.client.broadcast_handler import BroadcastHandler -from drunc.broadcast.client.configuration import BroadcastClientConfHandler from drunc.controller.controller_driver import ControllerDriver -from drunc.utils.configuration import ConfTypes from drunc.utils.shell_utils import ( ShellContext, create_dummy_token_from_uname, @@ -33,12 +30,6 @@ def create_drivers(self, **kwargs) -> Mapping[str, object]: def create_token(self, **kwargs) -> Token: return create_dummy_token_from_uname() - def start_listening_controller(self, broadcaster_conf): - bcch = BroadcastClientConfHandler( - data=broadcaster_conf, type=ConfTypes.ProtobufAny - ) - self.status_receiver = BroadcastHandler(broadcast_configuration=bcch) - def terminate(self): if self.status_receiver: self.status_receiver.stop() diff --git a/src/drunc/controller/interface/shell_utils.py b/src/drunc/controller/interface/shell_utils.py index 3ffba7455..190a7c305 100644 --- a/src/drunc/controller/interface/shell_utils.py +++ b/src/drunc/controller/interface/shell_utils.py @@ -210,7 +210,6 @@ def get_renderable(self) -> ConsoleRenderable | RichCast | str: def controller_cleanup_wrapper(ctx): def controller_cleanup(): log = logging.getLogger("controller.shell_utils") - # remove the shell from the controller broadcast list dead = False who = "" @@ -291,8 +290,6 @@ def controller_setup(ctx, controller_address): f"{controller_address} is '{desc.name}.{desc.session}' (name.session), starting listening..." ) ctx.get_driver("controller").name = f"{desc.name}.{desc.session}" - if desc.HasField("broadcast"): - ctx.start_listening_controller(desc.broadcast) log.debug("Connected to the controller") diff --git a/src/drunc/controller/stateful_node.py b/src/drunc/controller/stateful_node.py index 007bd200f..d503c63fa 100644 --- a/src/drunc/controller/stateful_node.py +++ b/src/drunc/controller/stateful_node.py @@ -20,14 +20,6 @@ def value(self): @value.setter def value(self, value): - # if self._broadcast_on_change is None or self._broadcast_key is None: - # self._value = value - # return - - # self._broadcast_on_change.broadcast( - # message = f'Changing {self._name} from {self._value} to {value}', - # btype = self._broadcast_key, - # ) self._value = value if self.stateful_node: self.stateful_node.log.info(f"{self._name} changed to {value}") From c4a49b58961cd10e4dcf19925f61c86bab9b4fb4 Mon Sep 17 00:00:00 2001 From: James Paul Turner Date: Thu, 11 Jun 2026 16:50:12 +0100 Subject: [PATCH 13/73] Remove all of broadcast package. --- src/drunc/REMOVE_ME_broadcast/__init__.py | 0 .../REMOVE_ME_broadcast/client/__init__.py | 0 .../client/broadcast_handler.py | 38 --- .../broadcast_handler_implementation.py | 7 - .../client/configuration.py | 43 --- .../client/grpc_stdout_broadcast_handler.py | 109 ------- .../client/kafka_stdout_broadcast_handler.py | 106 ------- .../REMOVE_ME_broadcast/server/__init__.py | 0 .../server/broadcast_sender.py | 128 -------- .../server/broadcast_sender_implementation.py | 17 -- .../server/configuration.py | 34 --- .../REMOVE_ME_broadcast/server/decorators.py | 48 --- .../server/grpc_servicer.py | 282 ------------------ .../server/kafka_sender.py | 69 ----- src/drunc/REMOVE_ME_broadcast/types.py | 15 - src/drunc/REMOVE_ME_broadcast/utils.py | 30 -- 16 files changed, 926 deletions(-) delete mode 100644 src/drunc/REMOVE_ME_broadcast/__init__.py delete mode 100644 src/drunc/REMOVE_ME_broadcast/client/__init__.py delete mode 100644 src/drunc/REMOVE_ME_broadcast/client/broadcast_handler.py delete mode 100644 src/drunc/REMOVE_ME_broadcast/client/broadcast_handler_implementation.py delete mode 100644 src/drunc/REMOVE_ME_broadcast/client/configuration.py delete mode 100644 src/drunc/REMOVE_ME_broadcast/client/grpc_stdout_broadcast_handler.py delete mode 100644 src/drunc/REMOVE_ME_broadcast/client/kafka_stdout_broadcast_handler.py delete mode 100644 src/drunc/REMOVE_ME_broadcast/server/__init__.py delete mode 100644 src/drunc/REMOVE_ME_broadcast/server/broadcast_sender.py delete mode 100644 src/drunc/REMOVE_ME_broadcast/server/broadcast_sender_implementation.py delete mode 100644 src/drunc/REMOVE_ME_broadcast/server/configuration.py delete mode 100644 src/drunc/REMOVE_ME_broadcast/server/decorators.py delete mode 100644 src/drunc/REMOVE_ME_broadcast/server/grpc_servicer.py delete mode 100644 src/drunc/REMOVE_ME_broadcast/server/kafka_sender.py delete mode 100644 src/drunc/REMOVE_ME_broadcast/types.py delete mode 100644 src/drunc/REMOVE_ME_broadcast/utils.py diff --git a/src/drunc/REMOVE_ME_broadcast/__init__.py b/src/drunc/REMOVE_ME_broadcast/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/drunc/REMOVE_ME_broadcast/client/__init__.py b/src/drunc/REMOVE_ME_broadcast/client/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/drunc/REMOVE_ME_broadcast/client/broadcast_handler.py b/src/drunc/REMOVE_ME_broadcast/client/broadcast_handler.py deleted file mode 100644 index 1f144f582..000000000 --- a/src/drunc/REMOVE_ME_broadcast/client/broadcast_handler.py +++ /dev/null @@ -1,38 +0,0 @@ -from drunc.broadcast.client.configuration import BroadcastClientConfHandler -from drunc.broadcast.types import BroadcastTypes - - -class BroadcastHandler: - def __init__(self, broadcast_configuration: BroadcastClientConfHandler): - super().__init__() - - from logging import getLogger - - self.log = getLogger("BroadcastHandler") - - self.configuration = broadcast_configuration - self.implementation = None - - match self.configuration.data.type: - # Being a bit sloppy here, having a Kafka sender doesn't mean we want to dump everything to stdout - # There could be cases where we want to do other things. - # For now, 1 server type <-> 1 client type... - # Maybe in the future some sort of callback-based functionality would be preferable. - case BroadcastTypes.Kafka: - from druncschema.broadcast_pb2 import BroadcastMessage - - from drunc.broadcast.client.kafka_stdout_broadcast_handler import ( - KafkaStdoutBroadcastHandler, - ) - - self.implementation = KafkaStdoutBroadcastHandler( - message_format=BroadcastMessage, conf=self.configuration - ) - case _: - self.log.info( - "Could not understand the BroadcastHandler technology you want to use, you will get no broadcast!" - ) - - def stop(self): - if self.implementation: - self.implementation.stop() diff --git a/src/drunc/REMOVE_ME_broadcast/client/broadcast_handler_implementation.py b/src/drunc/REMOVE_ME_broadcast/client/broadcast_handler_implementation.py deleted file mode 100644 index 99d0fa95f..000000000 --- a/src/drunc/REMOVE_ME_broadcast/client/broadcast_handler_implementation.py +++ /dev/null @@ -1,7 +0,0 @@ -import abc - - -class BroadcastHandlerImplementation(abc.ABC): - @abc.abstractmethod - def stop(self): - pass diff --git a/src/drunc/REMOVE_ME_broadcast/client/configuration.py b/src/drunc/REMOVE_ME_broadcast/client/configuration.py deleted file mode 100644 index 4b5cdd7f1..000000000 --- a/src/drunc/REMOVE_ME_broadcast/client/configuration.py +++ /dev/null @@ -1,43 +0,0 @@ -from drunc.broadcast.types import BroadcastTypes -from drunc.utils.configuration import ConfHandler - - -class BroadcastClientConfData: # OKSeroo - def __init__(self, type: BroadcastTypes, address: str, topic: str): - self.type = type - self.address = address - self.topic = topic - - -class BroadcastClientConfHandler(ConfHandler): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def get_impl_technology(self): - return self.impl_technology - - def _parse_pbany(self, data): - # potentially do something more complicated with different implementation technology here - # match data.format(): - # case KafkaBroadcastHandlerConfiguration - # ... - - from druncschema.broadcast_pb2 import KafkaBroadcastHandlerConfiguration - - from drunc.utils.grpc_utils import UnpackingError, unpack_any - - if not data.ByteSize(): - return BroadcastClientConfData(type=None, address=None, topic=None) - try: - data = unpack_any(data, KafkaBroadcastHandlerConfiguration) - return BroadcastClientConfData( - type=BroadcastTypes.Kafka, address=data.kafka_address, topic=data.topic - ) - - except UnpackingError as e: - from drunc.exceptions import DruncSetupException - - raise DruncSetupException( - f"Input configuration to configure the broadcast was not understood, could not setup the broadcast handler: {e}", - e, - ) diff --git a/src/drunc/REMOVE_ME_broadcast/client/grpc_stdout_broadcast_handler.py b/src/drunc/REMOVE_ME_broadcast/client/grpc_stdout_broadcast_handler.py deleted file mode 100644 index e0faa89b5..000000000 --- a/src/drunc/REMOVE_ME_broadcast/client/grpc_stdout_broadcast_handler.py +++ /dev/null @@ -1,109 +0,0 @@ -import grpc -from druncschema.broadcast_pb2 import BroadcastMessage, BroadcastType -from druncschema.broadcast_pb2_grpc import BroadcastReceiverServicer -from druncschema.generic_pb2 import Empty - -from drunc.controller.configuration import ControllerConfHandler - - -class gRPCStdoutBroadcastHandler(BroadcastReceiverServicer): - def __init__(self, conf: ControllerConfHandler, token, **kwargs) -> None: - super(gRPCStdoutBroadcastHandler, self).__init__(**kwargs) - from drunc.exceptions import DruncSetupException - - raise DruncSetupException( - "gRPCStdoutBroadcastHandler is not handled it needs to be reworked!" - ) - self.ready = False - self.stub = None - self.token = token - from logging import getLogger - - self._log = getLogger("BroadcastReceiver") - self._address = None # f'[::]:{port}' - self._log.debug("Broadcast receiver initialised") - - def stop_receiving(self) -> None: - self._server.stop(0) - self._log.debug("Broadcast receiver stopped") - - def connect(self) -> None: - from druncschema.broadcast_pb2 import BroadcastRequest - - from drunc.utils.grpc_utils import send_command - - self._log.info(f"Connecting to {self.stub}") - try: - send_command( - controller=self.stub, - token=self.token, - command="add_to_broadcast_list", - data=BroadcastRequest(broadcast_receiver_address=self._address), - rethrow=True, - ) - - except Exception as e: - self._log.error("Could not connect to service to receive broadcast") - self._log.error(str(e)) - raise e - - def disconnect(self) -> None: - from druncschema.broadcast_receiver_pb2 import BroadcastRequest - - from drunc.utils.grpc_utils import send_command - - try: - send_command( - controller=self.stub, - token=self.token, - command="remove_from_broadcast_list", - data=BroadcastRequest(broadcast_receiver_address=self._address), - rethrow=True, - ) - - except Exception as e: - self._log.error( - "Could not disconnect from broadcaster (maybe it' dead and you won't receive any broadcast anyway)" - ) - self._log.error(str(e)) - - def terminate(self) -> None: - self.disconnect() - self.stop_receiving() - - def serve(self) -> None: - from concurrent import futures - - self._server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) - - from druncschema.broadcast_pb2_grpc import ( - add_BroadcastReceiverServicer_to_server, - ) - - add_BroadcastReceiverServicer_to_server(self, self._server) - - self._server.add_insecure_port(self._address) - - self._server.start() - self.ready = True - self._log.debug("Broadcast receiver server started") - self._server.wait_for_termination() - self.ready = False - - def handle_broadcast( - self, bm: BroadcastMessage, context: grpc.aio.ServicerContext = None - ) -> Empty: - from druncschema.generic_pb2 import PlainText - - from drunc.utils.grpc_utils import unpack_any - - type = bm.type - if type == BroadcastType.TEXT_MESSAGE: - pt = unpack_any(bm.data, PlainText) - self._log.info(f"{bm.emitter}: {pt}") - elif type == BroadcastType.ACK: - self._log.info(f"{bm.emitter}: Ack") - elif type == BroadcastType.SERVER_SHUTDOWN: - self._log.info(f"{bm.emitter} is shutting down") - - return Empty() diff --git a/src/drunc/REMOVE_ME_broadcast/client/kafka_stdout_broadcast_handler.py b/src/drunc/REMOVE_ME_broadcast/client/kafka_stdout_broadcast_handler.py deleted file mode 100644 index 36022ee96..000000000 --- a/src/drunc/REMOVE_ME_broadcast/client/kafka_stdout_broadcast_handler.py +++ /dev/null @@ -1,106 +0,0 @@ -from drunc.broadcast.client.broadcast_handler_implementation import ( - BroadcastHandlerImplementation, -) - - -class KafkaStdoutBroadcastHandler(BroadcastHandlerImplementation): - def __init__(self, message_format, conf): - from drunc.broadcast.utils import broadcast_types_loglevels - - self.broadcast_types_loglevels = ( - broadcast_types_loglevels # in this case, we stick with default - ) - self.conf = conf - # import os - # drunc_shell_conf = os.getenv('DRUNC_SHELL_CONF', None) - # if drunc_shell_conf is not None: - - # with open(drunc_shell_conf) as f: - # import json - # self.global_kafka_stdout_conf = json.load(f).get('kafka_broadcast_handler', {}) - # if 'broadcast_types_loglevels' in self.global_kafka_stdout_conf: - # self.broadcast_types_loglevels.update(self.global_kafka_stdout_conf['broadcast_types_loglevels']) - - self.kafka_address = self.conf.data.address - self.topic = self.conf.data.topic - - # self.broadcast_types_loglevels.update(conf.data.get('broadcast_types_loglevels', {})) - - self.message_format = message_format - - import logging - - self._log = logging.getLogger("Broadcast") - - import getpass - - from drunc.utils.utils import get_random_string, now_str - - group_id = f"drunc-stdout-broadcasthandler-{getpass.getuser()}-{now_str(True)}-{get_random_string(5)}" - - from kafka import KafkaConsumer - - self.consumer = KafkaConsumer( - self.topic, - client_id="run_control", - bootstrap_servers=[self.kafka_address], - group_id=group_id, - ) - - self.run = True - import threading - - self.thread = threading.Thread(target=self.consume) - self.thread.start() - - def stop(self): - self._log.info(f"Stopping listening to '{self.topic}'") - self.run = False - self.thread.join() - - def consume(self): - from druncschema.broadcast_pb2 import BroadcastType - from druncschema.generic_pb2 import PlainText - from google.protobuf import text_format - - from drunc.utils.grpc_utils import unpack_any - - while self.run: - for messages in self.consumer.poll(timeout_ms=500).values(): - for message in messages: - decoded = "" - try: - decoded = self.message_format() - decoded.ParseFromString(message.value) - self._log.debug(f"{decoded=}, {type(decoded)=}") - except Exception as e: - self._log.error( - f"Unhandled broadcast message: {message} (error: {e!s})" - ) - pass - - try: - if decoded.data.Is(PlainText.DESCRIPTOR): - txt = unpack_any(decoded.data, PlainText).text - else: - txt = decoded.data - - from druncschema.broadcast_pb2 import BroadcastType - - from drunc.broadcast.utils import ( - get_broadcast_level_from_broadcast_type, - ) - - bt = BroadcastType.Name(decoded.type) - - get_broadcast_level_from_broadcast_type( - decoded.type, self._log, self.broadcast_types_loglevels - )(f"'{bt}' {txt}") - - except Exception as e: - self._log.error( - f"Weird broadcast message: {message} (error: {e!s})" - ) - text_proto = text_format.MessageToString(decoded) - self._log.info(text_proto) - pass diff --git a/src/drunc/REMOVE_ME_broadcast/server/__init__.py b/src/drunc/REMOVE_ME_broadcast/server/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/drunc/REMOVE_ME_broadcast/server/broadcast_sender.py b/src/drunc/REMOVE_ME_broadcast/server/broadcast_sender.py deleted file mode 100644 index f138a3f5a..000000000 --- a/src/drunc/REMOVE_ME_broadcast/server/broadcast_sender.py +++ /dev/null @@ -1,128 +0,0 @@ -from drunc.broadcast.server.configuration import BroadcastSenderConfHandler - - -class BroadcastSender: - implementation = None - - def __init__( - self, - name: str, - configuration: BroadcastSenderConfHandler, - session: str = "no_session", - ): - super().__init__() - - self.configuration = configuration - - self.name = name - self.session = session - self.identifier = f"{self.session}.{self.name}" - - from logging import getLogger - - self.logger = getLogger("Broadcast") - - self.logger.info("Initialising broadcast") - - from drunc.broadcast.utils import broadcast_types_loglevels - - self.broadcast_types_loglevels = broadcast_types_loglevels - - # TODO - # self.broadcast_types_loglevels.update(self.configuration.get_raw('broadcast_types_loglevels', {})) - - self.impl_technology = self.configuration.get_impl_technology() - - self.implementation = None - - if self.impl_technology is None: - self.logger.info("There is no broadcasting service!") - return - - from drunc.broadcast.types import BroadcastTypes - - match self.impl_technology: - case BroadcastTypes.Kafka: - from drunc.broadcast.server.kafka_sender import KafkaSender - - self.implementation = KafkaSender( - self.configuration.data.address, - self.configuration.data.publish_timeout, - topic=f"control.{self.identifier}", - ) - case _: - from drunc.exceptions import DruncSetupException - - raise DruncSetupException( - f"Broadcaster cannot be {self.impl_technology}" - ) - - def describe_broadcast(self): - if self.implementation: - return self.implementation.describe_broadcast() - else: - return None - - def can_broadcast(self): - if not self.implementation: - return False - - return self.implementation.can_broadcast() - - def broadcast(self, message, btype): - if self.logger: - from drunc.broadcast.utils import get_broadcast_level_from_broadcast_type - - get_broadcast_level_from_broadcast_type( - btype, self.logger, self.broadcast_types_loglevels - )(message) - - if self.implementation is None: - # nice and easy case - return - - from druncschema.broadcast_pb2 import BroadcastMessage, Emitter - from druncschema.generic_pb2 import PlainText - - from drunc.utils.grpc_utils import pack_to_any - - any = pack_to_any(PlainText(text=message)) - emitter = Emitter( - process=self.name, - session=self.session, - ) - bm = BroadcastMessage( - emitter=emitter, - type=btype, - data=any, - ) - bm.type = btype - - self.implementation._send(bm) - - def _interrupt_with_exception(self, exception, context, stack=""): - from druncschema.broadcast_pb2 import BroadcastType - - txt = f"'{exception.__class__.__name__}' exception thrown: {exception}" - - from drunc.exceptions import DruncException - - self.broadcast( - btype=( - BroadcastType.DRUNC_EXCEPTION_RAISED - if isinstance(exception, DruncException) - else BroadcastType.UNHANDLED_EXCEPTION_RAISED - ), - message=txt, - ) - - if stack: - txt += "\n\n" + stack - - from google.rpc import code_pb2 - - error_code = getattr(exception, "grpc_error_code", code_pb2.INTERNAL) - context.abort( - code=error_code, - details=txt, - ) diff --git a/src/drunc/REMOVE_ME_broadcast/server/broadcast_sender_implementation.py b/src/drunc/REMOVE_ME_broadcast/server/broadcast_sender_implementation.py deleted file mode 100644 index f6ace3a2b..000000000 --- a/src/drunc/REMOVE_ME_broadcast/server/broadcast_sender_implementation.py +++ /dev/null @@ -1,17 +0,0 @@ -import abc - -from druncschema.broadcast_pb2 import BroadcastMessage - - -class BroadcastSenderImplementation(abc.ABC): - @abc.abstractmethod - def _send(self, bm: BroadcastMessage): - pass - - @abc.abstractmethod - def describe_broadcast(self): - pass - - @abc.abstractmethod - def can_broadcast(self): - pass diff --git a/src/drunc/REMOVE_ME_broadcast/server/configuration.py b/src/drunc/REMOVE_ME_broadcast/server/configuration.py deleted file mode 100644 index fe485e4fa..000000000 --- a/src/drunc/REMOVE_ME_broadcast/server/configuration.py +++ /dev/null @@ -1,34 +0,0 @@ -from drunc.utils.configuration import ConfHandler - - -class KafkaBroadcastSenderConfData: - def __init__(self, address=None, publish_timeout=None): - self.address = address - self.publish_timeout = publish_timeout - - @staticmethod - def from_dict(data: dict): - address = data.get("address") - if address is None: - address = data["kafka_address"] - - return KafkaBroadcastSenderConfData( - address=address, publish_timeout=data["publish_timeout"] - ) - - -class BroadcastSenderConfHandler(ConfHandler): - def _post_process_oks(self): - from drunc.broadcast.types import BroadcastTypes - - self.impl_technology = BroadcastTypes.Kafka if self.data else None - self.log.debug(self.data) - - def get_impl_technology(self): - return self.impl_technology - - def _parse_dict(self, data): - if data == {}: - self.impl_technology = None - return None - return KafkaBroadcastSenderConfData.from_dict(data) diff --git a/src/drunc/REMOVE_ME_broadcast/server/decorators.py b/src/drunc/REMOVE_ME_broadcast/server/decorators.py deleted file mode 100644 index e8407748c..000000000 --- a/src/drunc/REMOVE_ME_broadcast/server/decorators.py +++ /dev/null @@ -1,48 +0,0 @@ -from drunc.utils.utils import get_logger - - -def broadcasted(cmd): - import functools - - @functools.wraps( - cmd - ) # this nifty decorator of decorator (!) is nicely preserving the cmd.__name__ (i.e. signature) - def wrap(obj, request, context): - log = get_logger("broadcasted_decorator", rich_handler=True) - - # hummmm I feel like creating a level myself, but... - # https://docs.python.org/3/howto/logging.html#custom-levels - # lets not - log.debug("Entering") - from druncschema.broadcast_pb2 import BroadcastType - - msg = f"User '{request.token.user_name}' executing '{cmd.__name__}'" - - log.debug(msg) - - obj.broadcast(message=msg, btype=BroadcastType.ACK) - - ret = None - try: - log.debug("Executing wrapped function") - ret = cmd(obj, request, context) - - except Exception as e: - log.exception(e) - - obj.broadcast( - message=f"Command '{cmd.__name__}' failed", - btype=BroadcastType.UNHANDLED_EXCEPTION_RAISED, - ) - # raise the exception to the client so the interceptor can handle it - raise e - - msg = f"User '{request.token.user_name}' successfully executed '{cmd.__name__}'" - - obj.broadcast(message=msg, btype=BroadcastType.COMMAND_EXECUTION_SUCCESS) - log.debug(msg) - - log.debug("Exiting") - return ret - - return wrap diff --git a/src/drunc/REMOVE_ME_broadcast/server/grpc_servicer.py b/src/drunc/REMOVE_ME_broadcast/server/grpc_servicer.py deleted file mode 100644 index eabed6282..000000000 --- a/src/drunc/REMOVE_ME_broadcast/server/grpc_servicer.py +++ /dev/null @@ -1,282 +0,0 @@ -from queue import Queue -from threading import Lock, Thread - -import grpc -from druncschema.authoriser_pb2 import ActionType -from druncschema.broadcast_pb2 import BroadcastMessage, BroadcastRequest, BroadcastType -from druncschema.broadcast_pb2_grpc import BroadcastSenderServicer -from druncschema.generic_pb2 import PlainText, StringStringMap -from druncschema.request_response_pb2 import Request, Response -from google.protobuf.any_pb2 import Any - -import drunc.controller.exceptions as ctler_excpt -from drunc.utils.grpc_utils import unpack_any - - -class ListenerRepresentation: - def __init__(self, configuration): - self.address = configuration["address"] - self.channel = grpc.insecure_channel(self.address) - from druncschema.broadcast_pb2_grpc import BroadcastReceiverStub - - self.stub = BroadcastReceiverStub(self.channel) - - def handle_broadcast(self, message): - return self.stub.handle_broadcast(message) - - -class GRCPBroadcastSender(BroadcastSenderServicer): - def __init__(self): - from drunc.exceptions import DruncSetupException - - raise DruncSetupException("GRCPBroadcastSender not supported") - - from logging import getLogger - - self.name = "broadcast_sender" - self._log = getLogger("Broadcast Sender") - self._listeners = {} - self._listener_lock = Lock() - self._message_queue = Queue() - self._consumer_thread = Thread(target=self._consumer, name="broadcast_consumer") - self._consumer_thread.start() - self._log.info("Broadcaster started") - - def _send(self, bm: BroadcastMessage): - pass - - def get_listeners(self): - import copy as cp - - with self._listener_lock: - ret = cp.deepcopy(list(self._listeners.keys())) - return ret - - def broadcast(self, txt, type=BroadcastType.TEXT_MESSAGE): - from druncschema.broadcast_pb2 import BroadcastMessage - from google.protobuf import any_pb2 - - message = PlainText(text=txt) - data_detail = any_pb2.Any() - data_detail.Pack(message) - - bm = BroadcastMessage( - emitter=self.name, type=BroadcastType.TEXT_MESSAGE, data=data_detail - ) - - return self._message_queue.put(bm) - - def broadcast_exception(self, exception): - from druncschema.broadcast_pb2 import BroadcastMessage - from google.protobuf import any_pb2 - - message = PlainText(text=str(exception)) - data_detail = any_pb2.Any() - data_detail.Pack(message) - - bm = BroadcastMessage( - emitter=self.name, type=BroadcastType.EXCEPTION_RAISED, data=data_detail - ) - return bm - - def add_to_bl_logic(self, request: BroadcastRequest): - self.add_listener(request.broadcast_receiver_address) - - def execute_command(self, request, format, logic, action): - uname = request.token.user_name - if self.authoriser: - if not self.authoriser.is_authorised(request.token, action): - from drunc.authoriser.exceptions import Unauthorised - - raise Unauthorised(uname, action) - from drunc.utils.grpc_utils import unpack_any - - data = unpack_any(request.data, format) - - self.broadcast( - f"Executing {action} (user: {uname})", BroadcastType.COMMAND_EXECUTION_START - ) - ret = logic(data) - self.broadcast( - f"Finshed executing {action} (user: {uname})", - BroadcastType.COMMAND_EXECUTION_SUCCESS, - ) - - response = Response() - response.token.CopyFrom(request.token) - data = Any() - if ret: - data.Pack(ret) - response.data.CopyFrom(data) - - return response - - def add_to_broadcast_list(self, request: Request, context) -> Response: - try: - return self.execute_command( - request=request, - format=BroadcastRequest, - logic=self.add_to_bl_logic, - action=ActionType.CREATE, - ) - except Exception as e: - self.broadcast_exception(e) - - def remove_from_broadcast_list(self, request: Request, context) -> Response: - r = unpack_any(request, BroadcastRequest) - if not self.broadcaster.rm_listener(r.broadcast_receiver_address): - raise ctler_excpt.ControllerException( - f"Failed to remove {r.broadcast_receiver_address} from broadcast list" - ) - return PlainText( - text=f"Removed {r.broadcast_receiver_address} to broadcast list" - ) - - def get_broadcast_list(self, request: Request, context) -> Response: - # return self._generic_user_command(request, '_get_broadcast_list', context) - ret = StringStringMap() - listeners = self.broadcaster.get_listeners() - for k, v in listeners.items(): - ret[k] = v - return - - def ack(self, address): - if address not in self._listeners: - raise RuntimeError(f"Cannot send ack to {address}") - - stub = self._listeners[address] - self._log.debug(f"Ack to {address}") - - from druncschema.broadcast_pb2 import BroadcastMessage, BroadcastType - - message = BroadcastMessage(emitter=self.name, type=BroadcastType.ACK) - - try: - stub.handle_broadcast(message) - except Exception as e: - self._log.error(f"Could not Ack to {address}: {e!s}") - - def shutdown(self): - from druncschema.broadcast_pb2 import BroadcastMessage, BroadcastType - - bm = BroadcastMessage(emitter=self.name, type=BroadcastType.SERVER_SHUTDOWN) - - return self._message_queue.put(bm) - - def add_listener(self, address): - with self._listener_lock: - if address in self._listeners.keys(): - self._log.error(f"Listener {address} already exists") - self._listener_lock.release() - return False - self._log.info(f"Adding listener {address}") - self._listeners[address] = ListenerRepresentation(address) - self.ack(address) - - return True - - def rm_listener(self, address): - with self._listener_lock: - if address not in self._listeners.keys(): - self._log.error(f"Listener {address} does not exist") - self._listener_lock.release() - return False - self._log.info(f"Removing listener {address}") - del self._listeners[address] - return True - - def join(self): - return self._consumer_thread.join() - - def _consumer(self): - from druncschema.broadcast_pb2 import BroadcastType - - while True: - message = ( - self._message_queue.get() - ) # Wait for a message from the controller - self._log.debug("Received broadcast message: " + str(message)) - self._listener_lock.acquire() - for address, listener in self._listeners.items(): - self._log.debug(f"Broadcasting {message} to {address}") - try: - response = listener.handle_broadcast(message) - except Exception as e: - self._log.error(f"Could not broadcast to {address}: {e}") - self._log.debug(f"Received response from {address}: {response}") - - self._listener_lock.release() - - if message.type == BroadcastType.SERVER_SHUTDOWN: - break - - -def main(): - from druncschema.broadcast_pb2 import BroadcastMessage, BroadcastType - from druncschema.broadcast_pb2_grpc import BroadcastReceiver - from druncschema.generic_pb2 import Empty - - class StatusReceiver(BroadcastReceiver): - def __init__(self, port): - super(StatusReceiver, self).__init__() - self.port = port - - def handle_broadcast( - self, bm: BroadcastMessage, context: grpc.aio.ServicerContext = None - ): - from drunc.utils.grpc_utils import unpack_any - - if bm.type == BroadcastType.SERVER_SHUTDOWN: - print("End of broadcast") - - elif bm.type == BroadcastType.TEXT_MESSAGE: - pt = unpack_any(bm.data, PlainText) - print(f'Got broadcasted message: "{pt.text}" on port {self.port}') - - return Empty() - - def serve(port: int) -> None: - from concurrent import futures - - server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) - from druncschema.broadcast_pb2_grpc import ( - add_BroadcastReceiverServicer_to_server, - ) - - add_BroadcastReceiverServicer_to_server(StatusReceiver(port), server) - server.add_insecure_port(f"[::]:{port}") - server.start() - print(f"Status receiver started on {port}") - server.wait_for_termination() - - receiver_threads = [] - port_list = range(15000, 15010) - for port in port_list: - try: - server_thread = Thread( - target=serve, kwargs={"port": port}, name=f"serve_thread_{port}" - ) - server_thread.start() - receiver_threads.append(server_thread) - except: - pass - from drunc.broadcast.server.broadcast_sender import BroadcastSender - - broadcaster = BroadcastSender() - for port in port_list: - broadcaster.add_listener(f"[::]:{port}") - broadcaster.broadcast("Test message") - broadcaster.broadcast("How do you do?") - broadcaster.broadcast("Let's all go for coffee!") - broadcaster.broadcast("End of broadcast") - broadcaster.shutdown() - broadcaster.join() - print("Broadcasting done") - print("\n\nYou can now ctrl-c\n\n") - - for thread in receiver_threads: - thread.join() - - -if __name__ == "__main__": - main() diff --git a/src/drunc/REMOVE_ME_broadcast/server/kafka_sender.py b/src/drunc/REMOVE_ME_broadcast/server/kafka_sender.py deleted file mode 100644 index c1a861a71..000000000 --- a/src/drunc/REMOVE_ME_broadcast/server/kafka_sender.py +++ /dev/null @@ -1,69 +0,0 @@ -from druncschema.broadcast_pb2 import BroadcastMessage - -from drunc.broadcast.server.broadcast_sender_implementation import ( - BroadcastSenderImplementation, -) - - -class KafkaSender(BroadcastSenderImplementation): - def __init__(self, kafka_address: str, publish_timeout: int, topic: str, **kwargs): - super(KafkaSender, self).__init__(**kwargs) - - import logging - - self._log = logging.getLogger(f"{topic}.KafkaSender") - - from kafka import KafkaProducer - from kafka import errors as Errors - - self.topic = topic - self._can_broadcast = False - - self.kafka_address = kafka_address - self.publish_timeout = publish_timeout - - try: - self.kafka = KafkaProducer( - bootstrap_servers=[self.kafka_address], - client_id="run_control", - ) - except Errors.NoBrokersAvailable as e: - t = f"{self.kafka_address} does not seem to point to a kafka broker." - self._log.critical(t) - from drunc.exceptions import DruncSetupException - - raise DruncSetupException(t) from e - - self._log.info( - f'Broadcasting to Kafka ({self.kafka_address}) client_id: "run_control", topic: "{self.topic}"' - ) - self._can_broadcast = True - - def can_broadcast(self): - return self._can_broadcast - - def _send(self, bm: BroadcastMessage): - from kafka.errors import KafkaError - - future = self.kafka.send(self.topic, bm.SerializeToString()) - - record_metadata = None - - try: - record_metadata = future.get(timeout=self.publish_timeout) - except KafkaError as e: - # Decide what to do if produce request failed... - self._log.error(f"Kafka exception sending message {bm}: {e!s}") - except Exception as e: - # Decide what to do if produce request failed... - self._log.error(f"Unhandled exception sending message {bm}: {e!s}") - else: - self._log.debug(f"{record_metadata} published") - - def describe_broadcast(self): - from druncschema.broadcast_pb2 import KafkaBroadcastHandlerConfiguration - - return KafkaBroadcastHandlerConfiguration( - topic=self.topic, - kafka_address=self.kafka_address, - ) diff --git a/src/drunc/REMOVE_ME_broadcast/types.py b/src/drunc/REMOVE_ME_broadcast/types.py deleted file mode 100644 index d91ce34be..000000000 --- a/src/drunc/REMOVE_ME_broadcast/types.py +++ /dev/null @@ -1,15 +0,0 @@ -from enum import Enum - -from drunc.exceptions import DruncSetupException - - -class BroadcastTypes(Enum): - Unknown = 0 - Kafka = 1 - ERS = 2 - - -class BroadcastTypeNotHandled(DruncSetupException): - def __init__(self, btype): - message = f"{btype} not handled" - super(BroadcastTypeNotHandled, self).__init__(message) diff --git a/src/drunc/REMOVE_ME_broadcast/utils.py b/src/drunc/REMOVE_ME_broadcast/utils.py deleted file mode 100644 index 1492996ef..000000000 --- a/src/drunc/REMOVE_ME_broadcast/utils.py +++ /dev/null @@ -1,30 +0,0 @@ -broadcast_types_loglevels = { - "ACK": "debug", - "RECEIVER_REMOVED": "info", - "RECEIVER_ADDED": "info", - "SERVER_READY": "info", - "SERVER_SHUTDOWN": "info", - "TEXT_MESSAGE": "info", - "COMMAND_EXECUTION_START": "info", - "COMMAND_EXECUTION_SUCCESS": "info", - "EXCEPTION_RAISED": "error", - "UNHANDLED_EXCEPTION_RAISED": "critical", - "STATUS_UPDATE": "info", - "SUBPROCESS_STATUS_UPDATE": "info", - "DEBUG": "debug", - "CHILD_COMMAND_EXECUTION_START": "info", - "CHILD_COMMAND_EXECUTION_SUCCESS": "info", - "CHILD_COMMAND_EXECUTION_FAILED": "error", -} - - -def get_broadcast_level_from_broadcast_type( - btype, logger, levels=broadcast_types_loglevels -): - from druncschema.broadcast_pb2 import BroadcastType - - bt = BroadcastType.Name(btype) - if bt not in levels: - return logger.info - else: - return getattr(logger, levels[bt].lower()) From 749ade1a8d46851c56960b4a89598072f28d3dcb Mon Sep 17 00:00:00 2001 From: James Paul Turner Date: Thu, 11 Jun 2026 18:00:18 +0100 Subject: [PATCH 14/73] Fix authoriser types. --- src/drunc/authoriser/dummy_authoriser.py | 23 +++++++---------------- src/drunc/controller/controller.py | 1 - 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/src/drunc/authoriser/dummy_authoriser.py b/src/drunc/authoriser/dummy_authoriser.py index c093e8fb5..a7098f687 100644 --- a/src/drunc/authoriser/dummy_authoriser.py +++ b/src/drunc/authoriser/dummy_authoriser.py @@ -5,22 +5,21 @@ from drunc.utils.utils import get_logger -# TODO: Should be communicating over network -# The Rolls Royce of the authoriser systems class DummyAuthoriser: def __init__( - self, - system: SystemType, - configuration_handler: DummyAuthoriserConfHandler = None, + self, configuration: DummyAuthoriserConfHandler, system: SystemType.ValueType ): self.log = get_logger("utils.authorizer") self.log.debug("DummyAuthoriser ready") - self.configuration = configuration_handler - self.command_actions = {} # Dict[str, ActionType] + self.configuration = configuration self.system = system def is_authorised( - self, token: Token, action: ActionType, system: SystemType, cmd_name: str = "" + self, + token: Token, + action: ActionType.ValueType, + system: SystemType.ValueType, + cmd_name: str, ) -> bool: self.log.debug( f"Authorising {token.user_name} to {ActionType.Name(action)} ({cmd_name}) on {SystemType.Name(system)}" @@ -30,11 +29,3 @@ def is_authorised( def authorised_actions(self, token: Token) -> list[str]: self.log.info(f"Grabbing authorisations for {token.token}") return [] - - -def main(): - DummyAuthoriser() - - -if __name__ == "__main__": - main() diff --git a/src/drunc/controller/controller.py b/src/drunc/controller/controller.py index 69ffef1fa..daaa5ace3 100644 --- a/src/drunc/controller/controller.py +++ b/src/drunc/controller/controller.py @@ -121,7 +121,6 @@ def __init__(self, configuration, name: str, session: str, token: Token): dach = DummyAuthoriserConfHandler( data=self.configuration.authoriser, ) - self.authoriser = DummyAuthoriser(dach, SystemType.CONTROLLER) self.actor = ControllerActor(token) From 11b90cabbcdbb0bc5472426864b2d33d740fd2d3 Mon Sep 17 00:00:00 2001 From: James Paul Turner Date: Thu, 11 Jun 2026 18:05:34 +0100 Subject: [PATCH 15/73] Remove broadcasting from the main PM class. --- src/drunc/process_manager/process_manager.py | 93 ++------------------ 1 file changed, 5 insertions(+), 88 deletions(-) diff --git a/src/drunc/process_manager/process_manager.py b/src/drunc/process_manager/process_manager.py index 07afa8b86..b4e979608 100644 --- a/src/drunc/process_manager/process_manager.py +++ b/src/drunc/process_manager/process_manager.py @@ -6,7 +6,6 @@ from daqpytools.logging import LogHandlerConf, exceptions, setup_daq_ers_logger from druncschema.authoriser_pb2 import ActionType, SystemType -from druncschema.broadcast_pb2 import BroadcastType from druncschema.description_pb2 import CommandDescription, Description from druncschema.opmon.process_manager_pb2 import ProcessStatus from druncschema.process_manager_pb2 import ( @@ -28,9 +27,6 @@ from drunc.authoriser.configuration import DummyAuthoriserConfHandler from drunc.authoriser.decorators import authentified_and_authorised from drunc.authoriser.dummy_authoriser import DummyAuthoriser -from drunc.broadcast.server.broadcast_sender import BroadcastSender -from drunc.broadcast.server.configuration import BroadcastSenderConfHandler -from drunc.broadcast.server.decorators import broadcasted from drunc.exceptions import ( DruncCommandException, DruncNotImplementedException, @@ -50,11 +46,7 @@ def __init__(self, txt): class ProcessManager(abc.ABC, ProcessManagerServicer): def __init__( - self, - configuration: ProcessManagerConfHandler, - name: str, - session: str = None, - **kwargs, + self, configuration: ProcessManagerConfHandler, name: str, session: str ): """C'tor. Note that this takes the ERS env variables from the json files defined in data/process_manager!""" @@ -80,17 +72,14 @@ def __init__( self.configuration = configuration self.name = name self.session = session - - self._create_broadcast_service(self.name, self.session) - - dach = DummyAuthoriserConfHandler( - data=self.configuration.get_data_authoriser(), type=ConfTypes.PyObject - ) - self.opmon_publisher = getattr( self.configuration.get_data(), "opmon_publisher", None ) interval_s = getattr(self.configuration.get_data(), "interval_s", 10.0) + + dach = DummyAuthoriserConfHandler( + data=self.configuration.get_data_authoriser(), type=ConfTypes.PyObject + ) self.authoriser = DummyAuthoriser(dach, SystemType.PROCESS_MANAGER) self.process_store = {} # dict[str, sh.RunningCommand] # str = uuid @@ -154,8 +143,6 @@ def __init__( ), ] - self.broadcast(message="ready", btype=BroadcastType.SERVER_READY) - if self.opmon_publisher is not None: self.stop_event = threading.Event() self.thread = threading.Thread( @@ -168,21 +155,6 @@ def __init__( def get_log_path(self): return self.configuration.get_log_path() - def _create_broadcast_service(self, name, session): - bsch = BroadcastSenderConfHandler( - data=self.configuration.get_data_broadcaster(), type=ConfTypes.PyObject - ) - - self.broadcast_service = ( - BroadcastSender( - name=name, - session=session, - configuration=bsch, - ) - if bsch.data - else None - ) - def __del__(self): if hasattr(self, "opmon_publisher") and self.opmon_publisher is not None: self.stop_event.set() @@ -247,48 +219,10 @@ def find_by_uuid(pi_list, target_uuid: str): time.sleep(interval_s) - """ - A couple of simple pass-through functions to the broadcasting service - """ - - def broadcast(self, *args, **kwargs): - self.log.debug(f"{self.name} broadcasting") - return ( - self.broadcast_service.broadcast(*args, **kwargs) - if self.broadcast_service - else None - ) - - def can_broadcast(self, *args, **kwargs): - self.log.debug(f"Checking if {self.name} can broadcast") - return ( - self.broadcast_service.can_broadcast(*args, **kwargs) - if self.broadcast_service - else False - ) - - def describe_broadcast(self, *args, **kwargs): - self.log.debug(f"Describing {self.name} broadcast") - return ( - self.broadcast_service.describe_broadcast(*args, **kwargs) - if self.broadcast_service - else None - ) - - def interrupt_with_exception(self, *args, **kwargs): - self.log.debug(f"Interrupting {self.name} broadcast with exception") - return ( - self.broadcast_service._interrupt_with_exception(*args, **kwargs) - if self.broadcast_service - else None - ) - @abc.abstractmethod def _boot_impl(self, boot_request: BootRequest) -> ProcessInstanceList: raise NotImplementedError - # ORDER MATTERS! - @broadcasted # outer most wrapper 1st step @authentified_and_authorised( action=ActionType.CREATE, system=SystemType.PROCESS_MANAGER ) # 2nd step @@ -321,8 +255,6 @@ def boot( def _terminate_impl(self) -> ProcessInstanceList: raise NotImplementedError - # ORDER MATTERS! - @broadcasted # outer most wrapper 1st step @authentified_and_authorised( action=ActionType.DELETE, system=SystemType.PROCESS_MANAGER ) # 2nd step @@ -356,8 +288,6 @@ def terminate( def _restart_impl(self, query: ProcessQuery) -> ProcessInstanceList: raise NotImplementedError - # ORDER MATTERS! - @broadcasted # outer most wrapper 1st step @authentified_and_authorised( action=ActionType.DELETE, system=SystemType.PROCESS_MANAGER ) # 2nd step @@ -388,8 +318,6 @@ def restart( def _kill_impl(self, query: ProcessQuery) -> ProcessInstanceList: raise NotImplementedError - # ORDER MATTERS! - @broadcasted # outer most wrapper 1st step @authentified_and_authorised( action=ActionType.DELETE, system=SystemType.PROCESS_MANAGER ) # 2nd step @@ -420,8 +348,6 @@ def kill( def _ps_impl(self, query: ProcessQuery) -> ProcessInstanceList: raise NotImplementedError - # ORDER MATTERS! - @broadcasted # outer most wrapper 1st step @authentified_and_authorised( action=ActionType.READ, system=SystemType.PROCESS_MANAGER ) # 2nd step @@ -452,8 +378,6 @@ def ps( def _flush_impl(self, query: ProcessQuery) -> ProcessInstanceList: raise NotImplementedError - # ORDER MATTERS! - @broadcasted # outer most wrapper 1st step @authentified_and_authorised( action=ActionType.DELETE, system=SystemType.PROCESS_MANAGER ) # 2nd step @@ -493,8 +417,6 @@ def flush( return response - # ORDER MATTERS! - @broadcasted # outer most wrapper 1st step @authentified_and_authorised( action=ActionType.READ, system=SystemType.PROCESS_MANAGER ) # 2nd step @@ -511,17 +433,12 @@ def describe(self, request: Request, context: ServicerContext) -> Description: token=None, ) - if broadcast_description := self.describe_broadcast(): - response.broadcast.Pack(broadcast_description) - return response @abc.abstractmethod def _logs_impl(self, log_request: LogRequest) -> LogLines: raise NotImplementedError - # ORDER MATTERS! - @broadcasted # outer most wrapper 1st step @authentified_and_authorised( action=ActionType.READ, system=SystemType.PROCESS_MANAGER ) # 2nd step From 4840f9f637766c06b8e0f0170fa631f07829f1ed Mon Sep 17 00:00:00 2001 From: James Paul Turner Date: Thu, 11 Jun 2026 18:11:04 +0100 Subject: [PATCH 16/73] Remove broadcasting from PM subclasses. --- src/drunc/process_manager/k8s_process_manager.py | 6 ++---- src/drunc/process_manager/ssh_process_manager.py | 3 --- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/drunc/process_manager/k8s_process_manager.py b/src/drunc/process_manager/k8s_process_manager.py index 04116cd61..358804062 100644 --- a/src/drunc/process_manager/k8s_process_manager.py +++ b/src/drunc/process_manager/k8s_process_manager.py @@ -12,7 +12,6 @@ from time import sleep, time # Local Application Imports -from druncschema.broadcast_pb2 import BroadcastType from druncschema.process_manager_pb2 import ( BootRequest, LogLines, @@ -337,8 +336,8 @@ def notify_termination( """ Callback for when a pod terminates. - Updates the final exit code, broadcasts a status update, and signals - the termination_complete_event when all pending deletions are confirmed. + Updates the final exit code and signals the termination_complete_event + when all pending deletions are confirmed. Args: proc_uuid: The UUID string of the terminated process. @@ -359,7 +358,6 @@ def notify_termination( # Publish this information self.log.info(end_str) - self.broadcast(end_str, BroadcastType.SUBPROCESS_STATUS_UPDATE) # Clear the list of processes being removed if proc_uuid in self.uuids_pending_deletion: diff --git a/src/drunc/process_manager/ssh_process_manager.py b/src/drunc/process_manager/ssh_process_manager.py index e915ecd73..e0bfb7aa4 100644 --- a/src/drunc/process_manager/ssh_process_manager.py +++ b/src/drunc/process_manager/ssh_process_manager.py @@ -3,7 +3,6 @@ import uuid from typing import List, Optional -from druncschema.broadcast_pb2 import BroadcastType from druncschema.process_manager_pb2 import ( BootRequest, LogLines, @@ -317,10 +316,8 @@ def _logs_impl(self, log_request: LogRequest) -> LogLines: ) def notify_join(self, name, session, user, exit_status: ExitStatus): - self.log.debug(f"{self.name} sending broadcast after ssh process exit") end_str = exit_status.get_process_manager_log_message(name, session, user) self.log.info(end_str) - self.broadcast(end_str, BroadcastType.SUBPROCESS_STATUS_UPDATE) def __boot(self, boot_request: BootRequest, uuid: str) -> ProcessInstance: """ From 28f7c5ed64621cbbc5aa893a5b067a8b40594944 Mon Sep 17 00:00:00 2001 From: James Paul Turner Date: Thu, 11 Jun 2026 19:44:22 +0100 Subject: [PATCH 17/73] Remove broadcast from config and schema. --- src/drunc/data/process_manager/k8s-CERN.json | 6 ----- src/drunc/data/process_manager/k8s.json | 5 ---- .../process-manager-k8s-pocket.json | 6 ----- .../schema/process_manager.schema.json | 10 -------- .../data/process_manager/ssh-CERN-kafka.json | 6 ----- .../process_manager/ssh-pocket-kafka.json | 9 +------- src/drunc/process_manager/configuration.py | 6 ----- .../process_manager/interface/context.py | 23 ++++--------------- src/drunc/process_manager/interface/shell.py | 2 -- 9 files changed, 6 insertions(+), 67 deletions(-) diff --git a/src/drunc/data/process_manager/k8s-CERN.json b/src/drunc/data/process_manager/k8s-CERN.json index 100dc2851..b59b10fb1 100644 --- a/src/drunc/data/process_manager/k8s-CERN.json +++ b/src/drunc/data/process_manager/k8s-CERN.json @@ -7,12 +7,6 @@ "authoriser": { "type": "dummy" }, - - "broadcaster": { - "type": "kafka", - "kafka_address": "monkafka.cern.ch:30092", - "publish_timeout": 2 - }, "environment": { "DUNEDAQ_ERS_ERROR": "erstrace,throttle,lstdout,protobufstream(monkafka.cern.ch:30092)", "DUNEDAQ_ERS_FATAL": "erstrace,lstdout,protobufstream(monkafka.cern.ch:30092)", diff --git a/src/drunc/data/process_manager/k8s.json b/src/drunc/data/process_manager/k8s.json index fa94a98ea..c46412eb6 100644 --- a/src/drunc/data/process_manager/k8s.json +++ b/src/drunc/data/process_manager/k8s.json @@ -6,11 +6,6 @@ "authoriser": { "type": "dummy" }, - "broadcaster": { - "type": "kafka", - "kafka_address": "monkafka.cern.ch:30092", - "publish_timeout": 2 - }, "environment": { "DUNEDAQ_ERS_ERROR": "erstrace,throttle,lstdout", "DUNEDAQ_ERS_FATAL": "erstrace,lstdout", diff --git a/src/drunc/data/process_manager/process-manager-k8s-pocket.json b/src/drunc/data/process_manager/process-manager-k8s-pocket.json index 54c295afd..069ec89d8 100644 --- a/src/drunc/data/process_manager/process-manager-k8s-pocket.json +++ b/src/drunc/data/process_manager/process-manager-k8s-pocket.json @@ -6,12 +6,6 @@ "authoriser": { "type": "dummy" }, - - "broadcaster": { - "type": "kafka", - "kafka_address": "localhost:30092", - "publish_timeout": 2 - }, "environment": { "DUNEDAQ_ERS_ERROR": "erstrace,throttle,lstdout,protobufstream(monkafka.cern.ch:30092)", "DUNEDAQ_ERS_FATAL": "erstrace,lstdout,protobufstream(monkafka.cern.ch:30092)", diff --git a/src/drunc/data/process_manager/schema/process_manager.schema.json b/src/drunc/data/process_manager/schema/process_manager.schema.json index 0ad199b8e..bcb906a7e 100644 --- a/src/drunc/data/process_manager/schema/process_manager.schema.json +++ b/src/drunc/data/process_manager/schema/process_manager.schema.json @@ -33,16 +33,6 @@ }, "additionalProperties": true }, - "broadcaster": { - "type": "object", - "properties": { - "type": {"type": "string"}, - "kafka_address": {"type": "string"}, - "publish_timeout": {"type": ["integer", "number"]} - }, - "required": ["type", "kafka_address", "publish_timeout"], - "additionalProperties": true - }, "command_address": {"type": "string"} }, "required": ["type", "name"], diff --git a/src/drunc/data/process_manager/ssh-CERN-kafka.json b/src/drunc/data/process_manager/ssh-CERN-kafka.json index 1158be0d9..0f62572e7 100644 --- a/src/drunc/data/process_manager/ssh-CERN-kafka.json +++ b/src/drunc/data/process_manager/ssh-CERN-kafka.json @@ -7,12 +7,6 @@ "authoriser": { "type": "dummy" }, - - "broadcaster": { - "type": "kafka", - "kafka_address": "monkafka.cern.ch:30092", - "publish_timeout": 2 - }, "environment": { "DUNEDAQ_ERS_ERROR": "erstrace,throttle,lstdout,protobufstream(monkafka.cern.ch:30092)", "DUNEDAQ_ERS_FATAL": "erstrace,lstdout,protobufstream(monkafka.cern.ch:30092)", diff --git a/src/drunc/data/process_manager/ssh-pocket-kafka.json b/src/drunc/data/process_manager/ssh-pocket-kafka.json index 9208772da..950083f7b 100644 --- a/src/drunc/data/process_manager/ssh-pocket-kafka.json +++ b/src/drunc/data/process_manager/ssh-pocket-kafka.json @@ -7,13 +7,6 @@ "authoriser": { "type": "dummy" }, - - "broadcaster": { - "type": "kafka", - "kafka_address": "localhost:31014", - "publish_timeout": 2 - }, - "environment": { "GRPC_ENABLE_FORK_SUPPORT": "false", "DUNEDAQ_ERS_ERROR": "erstrace,throttle,lstdout,protobufstream(monkafka.cern.ch:30092)", @@ -30,4 +23,4 @@ "level": "debug", "interval_s": 10.0 } -} \ No newline at end of file +} diff --git a/src/drunc/process_manager/configuration.py b/src/drunc/process_manager/configuration.py index da9198722..5172fa219 100644 --- a/src/drunc/process_manager/configuration.py +++ b/src/drunc/process_manager/configuration.py @@ -12,7 +12,6 @@ from opmonlib.publisher import OpMonPublisher from opmonlib.utils import parse_opmon_conf -from drunc.broadcast.server.configuration import KafkaBroadcastSenderConfData from drunc.exceptions import DruncCommandException from drunc.process_manager.exceptions import UnknownProcessManagerType from drunc.utils.configuration import ConfHandler @@ -40,7 +39,6 @@ class ProcessManagerTypes(Enum): class ProcessManagerConfData: def __init__(self): - self.broadcaster = None self.authoriser = None self.type = ProcessManagerTypes.Unknown self.command_address = "" @@ -61,10 +59,6 @@ def get_log_path(self): def _parse_dict(self, data): new_data = ProcessManagerConfData() - if data.get("broadcaster"): - new_data.broadcaster = KafkaBroadcastSenderConfData.from_dict( - data.get("broadcaster") - ) new_data.environment = data.get("environment", {}) new_data.settings = data.get("settings", {}) diff --git a/src/drunc/process_manager/interface/context.py b/src/drunc/process_manager/interface/context.py index a81396130..d6a30f592 100644 --- a/src/drunc/process_manager/interface/context.py +++ b/src/drunc/process_manager/interface/context.py @@ -1,24 +1,21 @@ -from collections.abc import Mapping +from collections.abc import MutableMapping from druncschema.token_pb2 import Token -from drunc.broadcast.client.broadcast_handler import BroadcastHandler -from drunc.broadcast.client.configuration import BroadcastClientConfHandler from drunc.process_manager.process_manager_driver import ProcessManagerDriver -from drunc.utils.configuration import ConfTypes from drunc.utils.shell_utils import ( ShellContext, create_dummy_token_from_uname, ) -from drunc.utils.utils import get_logger, resolve_localhost_to_hostname +from drunc.utils.utils import resolve_localhost_to_hostname -class ProcessManagerContext(ShellContext): # boilerplatefest +class ProcessManagerContext(ShellContext): def __init__(self, *args, **kwargs): self.status_receiver = None super(ProcessManagerContext, self).__init__(*args, **kwargs) - def reset(self, address: str = None): + def reset(self, address: str = "", **kwargs): self.address = resolve_localhost_to_hostname(address) super(ProcessManagerContext, self)._reset( name="process_manager_context", @@ -26,7 +23,7 @@ def reset(self, address: str = None): driver_args={}, ) - def create_drivers(self, **kwargs) -> Mapping[str, object]: + def create_drivers(self, **kwargs) -> MutableMapping[str, object]: if not self.address: return {} return { @@ -39,16 +36,6 @@ def create_drivers(self, **kwargs) -> Mapping[str, object]: def create_token(self, **kwargs) -> Token: return create_dummy_token_from_uname() - def start_listening(self, broadcaster_conf): - bcch = BroadcastClientConfHandler( - data=broadcaster_conf, - type=ConfTypes.ProtobufAny, - ) - self.status_receiver = BroadcastHandler(bcch) - get_logger("process_manager.shell").info( - f":ear: Listening to the Process Manager at {self.address}" - ) - def terminate(self): if self.status_receiver: self.status_receiver.stop() diff --git a/src/drunc/process_manager/interface/shell.py b/src/drunc/process_manager/interface/shell.py index 5da7434c5..18f7962e2 100644 --- a/src/drunc/process_manager/interface/shell.py +++ b/src/drunc/process_manager/interface/shell.py @@ -71,8 +71,6 @@ def process_manager_shell(ctx, process_manager_address: str, log_level: str) -> process_manager_shell_log.info( f"Connected to {process_manager_address}, running '{desc.name}.{desc.session}' (name.session), starting listening..." ) - if desc.HasField("broadcast"): - ctx.obj.start_listening(desc.broadcast) def cleanup(): ctx.obj.terminate() From 2bd6a5b30eefcab6f8667d17486295b3c21d5bd8 Mon Sep 17 00:00:00 2001 From: James Paul Turner Date: Thu, 11 Jun 2026 20:06:10 +0100 Subject: [PATCH 18/73] Remove broadcast from unified shell. --- src/drunc/unified_shell/context.py | 25 ------------------------- src/drunc/unified_shell/shell.py | 17 ++++------------- src/drunc/utils/configuration.py | 9 --------- 3 files changed, 4 insertions(+), 47 deletions(-) diff --git a/src/drunc/unified_shell/context.py b/src/drunc/unified_shell/context.py index f5c9c759e..73ff03fde 100644 --- a/src/drunc/unified_shell/context.py +++ b/src/drunc/unified_shell/context.py @@ -66,37 +66,12 @@ def set_controller_driver(self, address_controller, **kwargs) -> None: self._token, ) - # This will raise an exception if the driver already exists - # self.set_driver("controller", driver) - def create_token(self, **kwargs) -> Token: from drunc.utils.shell_utils import create_dummy_token_from_uname token = create_dummy_token_from_uname() return token - def start_listening_pm(self, broadcaster_conf) -> None: - from drunc.broadcast.client.broadcast_handler import BroadcastHandler - from drunc.broadcast.client.configuration import BroadcastClientConfHandler - from drunc.utils.configuration import ConfTypes - - bcch = BroadcastClientConfHandler( - type=ConfTypes.ProtobufAny, - data=broadcaster_conf, - ) - self.status_receiver_pm = BroadcastHandler(broadcast_configuration=bcch) - - def start_listening_controller(self, broadcaster_conf) -> None: - from drunc.broadcast.client.broadcast_handler import BroadcastHandler - from drunc.broadcast.client.configuration import BroadcastClientConfHandler - from drunc.utils.configuration import ConfTypes - - bcch = BroadcastClientConfHandler( - type=ConfTypes.ProtobufAny, - data=broadcaster_conf, - ) - self.status_receiver_controller = BroadcastHandler(broadcast_configuration=bcch) - def terminate(self) -> None: if self.status_receiver_pm: self.status_receiver_pm.stop() diff --git a/src/drunc/unified_shell/shell.py b/src/drunc/unified_shell/shell.py index af8bedbec..576201d3c 100644 --- a/src/drunc/unified_shell/shell.py +++ b/src/drunc/unified_shell/shell.py @@ -12,7 +12,6 @@ import click_shell import conffwk from daqpytools.logging import logging_log_levels -from druncschema.description_pb2 import Description from druncschema.process_manager_pb2 import ProcessQuery from drunc.connectivity_service.client import ConnectivityServiceClient @@ -277,9 +276,8 @@ def unified_shell( ctx.obj.reset(address_pm=process_manager_address) # Run a simple command (describe) to check the connection with the process manager - desc: Description | None = None try: - desc = ctx.obj.get_driver().describe() + ctx.obj.get_driver().describe() except Exception as e: ctx.obj.log.error( f"[red]Could not connect to the process manager at the address: [/red]" @@ -305,13 +303,6 @@ def unified_shell( sys.exit(1) - # Broadcasting configuration if requested - if desc.HasField("broadcast"): - ctx.obj.log.debug("Broadcasting") - ctx.obj.start_listening_pm( - broadcaster_conf=desc.broadcast, - ) - # Add the unified shell Click commands to the CLI ctx.obj.log.debug("Adding [green]unified_shell[/green] commands") ctx.command.add_command(boot, "boot") @@ -521,9 +512,9 @@ def cleanup(): ctx.obj.log.debug("Process manager terminated") ctx.obj.log.info("[green]unified_shell exited successfully[/green]") - logging.shutdown() # Shutdown logging - ctx.obj.terminate() # Terminate the broadcasters in the context - ctx.exit() # Close the click context + logging.shutdown() + ctx.obj.terminate() + ctx.exit() ctx.call_on_close(cleanup) diff --git a/src/drunc/utils/configuration.py b/src/drunc/utils/configuration.py index d5747a7e6..2883d8ed5 100644 --- a/src/drunc/utils/configuration.py +++ b/src/drunc/utils/configuration.py @@ -116,7 +116,6 @@ class _DataTypeName(Protocol): class _ConfigurationData(Protocol): type: _DataTypeName - broadcaster: object authoriser: object @@ -179,14 +178,6 @@ def get_data_type_name(self) -> str: """ return str(cast(_ConfigurationData, self.get_data()).type._name_) - def get_data_broadcaster(self) -> object: - """Get the broadcaster from the configuration data. - - Returns: - Any: The broadcaster object. - """ - return cast(_ConfigurationData, self.get_data()).broadcaster - def get_data_authoriser(self) -> object: """Get the authoriser from the configuration data. From 4dca1875e7285e8e64974487eb22addc38edea74 Mon Sep 17 00:00:00 2001 From: James Paul Turner Date: Thu, 11 Jun 2026 20:21:38 +0100 Subject: [PATCH 19/73] Remove broadcast from tests. --- tests/broadcast/__init__.py | 0 tests/broadcast/client/__init__.py | 0 .../client/test_broadcast_handler.py | 0 .../test_broadcast_handler_implementation.py | 0 tests/broadcast/client/test_configuration.py | 0 .../test_grpc_stdout_broadcast_handler.py | 0 .../test_kafka_stdout_broadcast_handler.py | 0 tests/broadcast/server/__init__.py | 0 .../broadcast/server/test_broadcast_sender.py | 0 .../test_broadcast_sender_implementation.py | 0 tests/broadcast/server/test_configuration.py | 0 tests/broadcast/server/test_decorators.py | 72 -------------- tests/broadcast/server/test_grpc_servicer.py | 0 tests/broadcast/server/test_kafka_sender.py | 93 ------------------- tests/broadcast/test_types.py | 0 tests/broadcast/test_utils.py | 0 .../process_manager_mock_impls.py | 3 - 17 files changed, 168 deletions(-) delete mode 100644 tests/broadcast/__init__.py delete mode 100644 tests/broadcast/client/__init__.py delete mode 100644 tests/broadcast/client/test_broadcast_handler.py delete mode 100644 tests/broadcast/client/test_broadcast_handler_implementation.py delete mode 100644 tests/broadcast/client/test_configuration.py delete mode 100644 tests/broadcast/client/test_grpc_stdout_broadcast_handler.py delete mode 100644 tests/broadcast/client/test_kafka_stdout_broadcast_handler.py delete mode 100644 tests/broadcast/server/__init__.py delete mode 100644 tests/broadcast/server/test_broadcast_sender.py delete mode 100644 tests/broadcast/server/test_broadcast_sender_implementation.py delete mode 100644 tests/broadcast/server/test_configuration.py delete mode 100644 tests/broadcast/server/test_decorators.py delete mode 100644 tests/broadcast/server/test_grpc_servicer.py delete mode 100644 tests/broadcast/server/test_kafka_sender.py delete mode 100644 tests/broadcast/test_types.py delete mode 100644 tests/broadcast/test_utils.py diff --git a/tests/broadcast/__init__.py b/tests/broadcast/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/broadcast/client/__init__.py b/tests/broadcast/client/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/broadcast/client/test_broadcast_handler.py b/tests/broadcast/client/test_broadcast_handler.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/broadcast/client/test_broadcast_handler_implementation.py b/tests/broadcast/client/test_broadcast_handler_implementation.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/broadcast/client/test_configuration.py b/tests/broadcast/client/test_configuration.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/broadcast/client/test_grpc_stdout_broadcast_handler.py b/tests/broadcast/client/test_grpc_stdout_broadcast_handler.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/broadcast/client/test_kafka_stdout_broadcast_handler.py b/tests/broadcast/client/test_kafka_stdout_broadcast_handler.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/broadcast/server/__init__.py b/tests/broadcast/server/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/broadcast/server/test_broadcast_sender.py b/tests/broadcast/server/test_broadcast_sender.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/broadcast/server/test_broadcast_sender_implementation.py b/tests/broadcast/server/test_broadcast_sender_implementation.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/broadcast/server/test_configuration.py b/tests/broadcast/server/test_configuration.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/broadcast/server/test_decorators.py b/tests/broadcast/server/test_decorators.py deleted file mode 100644 index 6dfbde783..000000000 --- a/tests/broadcast/server/test_decorators.py +++ /dev/null @@ -1,72 +0,0 @@ -from unittest.mock import MagicMock - -import pytest -from druncschema.broadcast_pb2 import BroadcastType -from druncschema.request_response_pb2 import Request -from druncschema.token_pb2 import Token - -from drunc.broadcast.server.decorators import broadcasted - - -class MockException(Exception): - pass - -@pytest.fixture -def mock_obj(): - """Mock the object that has the .broadcast() method.""" - obj = MagicMock() - obj.name = "test-node" - return obj - - -@pytest.fixture(scope="function") -def mock_request(): - return Request(token=Token(user_name="test", token="tets-token")) - - -@pytest.fixture -def mock_context(): - return MagicMock() - - -def test_broadcasted_success(mock_obj, mock_request, mock_context): - - @broadcasted - def dummy_command(obj, request, context): - return "Success" - - result = dummy_command(mock_obj, mock_request, mock_context) - - assert result == "Success" - - assert mock_obj.broadcast.call_count == 2 # ACK and COMMAND_EXECUTION_SUCCESS - - # first call - ACK - args, kwargs = mock_obj.broadcast.call_args_list[0] - assert kwargs['message'] == "User 'test' executing 'dummy_command'" - assert kwargs['btype'] == BroadcastType.ACK - - # second call - COMMAND_EXECUTION_SUCCESS - # check no missing or additional arguments - mock_obj.broadcast.assert_called_with( - message="User 'test' successfully executed 'dummy_command'", - btype=BroadcastType.COMMAND_EXECUTION_SUCCESS) - - -def test_broadcasted_failure(mock_obj, mock_request, mock_context): - - # command that raises an error - @broadcasted - def dummy_command(obj, request, context): - raise MockException("Test exception") - - with pytest.raises(MockException): - dummy_command(mock_obj, mock_request, mock_context) - - assert mock_obj.broadcast.call_count == 2 # ACK and Exception - - # check no missing or additional arguments passed to broadcast - mock_obj.broadcast.assert_called_with( - message="Command 'dummy_command' failed", - btype=BroadcastType.UNHANDLED_EXCEPTION_RAISED) - \ No newline at end of file diff --git a/tests/broadcast/server/test_grpc_servicer.py b/tests/broadcast/server/test_grpc_servicer.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/broadcast/server/test_kafka_sender.py b/tests/broadcast/server/test_kafka_sender.py deleted file mode 100644 index f2e4df400..000000000 --- a/tests/broadcast/server/test_kafka_sender.py +++ /dev/null @@ -1,93 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest -from kafka.errors import KafkaError, NoBrokersAvailable - -from drunc.broadcast.server.kafka_sender import KafkaSender -from drunc.exceptions import DruncSetupException - - -@pytest.fixture -def mock_kafka_producer(): - """Fixture to mock a KafkaProducer class and its instance.""" - with patch("kafka.KafkaProducer") as mock_class: - mock_instance = mock_class.return_value - yield mock_class, mock_instance - - -@pytest.fixture -def sender_with_mocked_producer(mock_kafka_producer): - """Fixture to create a KafkaSender instance with a mocked KafkaProducer.""" - kafka_sender = KafkaSender( - kafka_address="test-kafka-address", publish_timeout=5, topic="test-topic" - ) - kafka_sender._log = MagicMock() - return kafka_sender - - -def test_init_raises_drunc_setup_exception(): - """Test that KafkaSender raises DruncSetupException when KafkaProducer cannot connect to a broker.""" - mock_logger = MagicMock() - kafka_address = "test-address" - expected_exc_msg = f"{kafka_address} does not seem to point to a kafka broker." - - with ( - patch("logging.getLogger", return_value=mock_logger), - patch("kafka.KafkaProducer", side_effect=NoBrokersAvailable), - ): - with pytest.raises(DruncSetupException) as exc_info: - KafkaSender( - kafka_address=kafka_address, publish_timeout=5, topic="test-topic" - ) - - mock_logger.critical.assert_called_once() - log_call_args = mock_logger.critical.call_args[0][0] - assert expected_exc_msg in log_call_args - assert expected_exc_msg in str(exc_info.value) - - -def test_send_success(sender_with_mocked_producer, mock_kafka_producer): - """Test that KafkaSender._send successfully sends a message and logs the metadata.""" - _, mock_instance = mock_kafka_producer - sender = sender_with_mocked_producer - - mock_future = MagicMock() - mock_future.get.return_value = "test-metadata" - mock_instance.send.return_value = mock_future - - mock_broadcast_msg = MagicMock() - mock_broadcast_msg.SerializeToString.return_value = b"test-msg" - sender._send(mock_broadcast_msg) - - mock_instance.send.assert_called_with("test-topic", b"test-msg") - sender._log.debug.assert_called_with("test-metadata published") - - -def test_send_handle_exception(sender_with_mocked_producer, mock_kafka_producer): - """Test that KafkaSender._send handles KafkaError exceptions and logs the error.""" - _, mock_instance = mock_kafka_producer - sender = sender_with_mocked_producer - - mock_future = MagicMock() - mock_future.get.side_effect = KafkaError("Connection lost") - mock_instance.send.return_value = mock_future - - mock_broadcast_msg = MagicMock() - mock_broadcast_msg.SerializeToString.return_value = b"test-broadcast-msg" - - sender._log = MagicMock() - - sender._send(mock_broadcast_msg) - - sender._log.error.assert_called() - log_message = sender._log.error.call_args[0][0] - assert "Connection lost" in log_message - - -def test_describe_broadcast(sender_with_mocked_producer): - """Test that KafkaSender.describe_broadcast returns the correct information.""" - - result = sender_with_mocked_producer.describe_broadcast() - - assert result.topic == "test-topic" - assert result.kafka_address == "test-kafka-address" diff --git a/tests/broadcast/test_types.py b/tests/broadcast/test_types.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/broadcast/test_utils.py b/tests/broadcast/test_utils.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/process_manager/process_manager_mock_impls.py b/tests/process_manager/process_manager_mock_impls.py index b9b84cf23..d7f83c9c8 100644 --- a/tests/process_manager/process_manager_mock_impls.py +++ b/tests/process_manager/process_manager_mock_impls.py @@ -55,9 +55,6 @@ def _not_implemented_response(self): flag=ResponseFlag.NOT_EXECUTED_NOT_IMPLEMENTED, ) - def _create_broadcast_service(self, name, session): - self.broadcast_service = None - def _boot_impl(self, boot_request: BootRequest) -> ProcessInstanceList: """ Returns default not implemented response to indicate communication is working From 3b60793dbd9a0d6c1006c393f2e131cd5b568626 Mon Sep 17 00:00:00 2001 From: wanyunSu Date: Tue, 16 Jun 2026 13:08:34 +0200 Subject: [PATCH 20/73] safer terminate lcs, so no stale state when lcs pod dies --- src/drunc/process_manager/k8s_process_manager.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/drunc/process_manager/k8s_process_manager.py b/src/drunc/process_manager/k8s_process_manager.py index 60eee88b3..00dc9d2c4 100644 --- a/src/drunc/process_manager/k8s_process_manager.py +++ b/src/drunc/process_manager/k8s_process_manager.py @@ -376,16 +376,18 @@ def notify_termination( self.log.info(end_str) self.broadcast(end_str, BroadcastType.SUBPROCESS_STATUS_UPDATE) - # If the terminated pod was the LCS for its session, mark it as down. - # This prevents other pods booting later from trying to resolve a - # localhost alias that no longer exists. + # If the terminated pod was the LCS for its session, remove all LCS + # state for this session. A partial reset (e.g. only is_booted=False) + # would leave stale podname/port/node_port fields that could mislead + # a future LCS boot — including one that lands on a different node. + # Popping the entry is consistent with the invariant: "no LCS = no entry". lcs = self._lcs_state.get(session) if lcs is not None and lcs.podname == meta.name: self.log.info( f"LCS pod '{meta.name}' for session '{session}' has terminated; " - "clearing LCS booted state." + "removing LCS state so any future boot starts from a clean slate." ) - lcs.is_booted = False + self._lcs_state.pop(session, None) # Clear the list of processes being removed if proc_uuid in self.uuids_pending_deletion: From 9ed334ae4a7109a7f2e73fe4b09aee4d98376bea Mon Sep 17 00:00:00 2001 From: wanyunSu Date: Tue, 16 Jun 2026 14:37:10 +0200 Subject: [PATCH 21/73] add lock for LCS --- .../process_manager/k8s_process_manager.py | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/src/drunc/process_manager/k8s_process_manager.py b/src/drunc/process_manager/k8s_process_manager.py index 00dc9d2c4..d1c94087d 100644 --- a/src/drunc/process_manager/k8s_process_manager.py +++ b/src/drunc/process_manager/k8s_process_manager.py @@ -229,6 +229,7 @@ def __init__(self, configuration: ProcessManagerConfHandler, **kwargs) -> None: # Sessions that have no LCS simply have no entry here. # Use _lcs_state_for(session) to read and _lcs_state.pop(session) to clean up. self._lcs_state: dict[str, _LcsSessionState] = {} + self._lcs_state_lock = threading.Lock() # Host verification cache: {hostname: (is_valid, timestamp)} self._host_cache = {} @@ -381,13 +382,14 @@ def notify_termination( # would leave stale podname/port/node_port fields that could mislead # a future LCS boot — including one that lands on a different node. # Popping the entry is consistent with the invariant: "no LCS = no entry". - lcs = self._lcs_state.get(session) - if lcs is not None and lcs.podname == meta.name: - self.log.info( - f"LCS pod '{meta.name}' for session '{session}' has terminated; " - "removing LCS state so any future boot starts from a clean slate." - ) - self._lcs_state.pop(session, None) + with self._lcs_state_lock: + lcs = self._lcs_state.get(session) + if lcs is not None and lcs.podname == meta.name: + self.log.info( + f"LCS pod '{meta.name}' for session '{session}' has terminated; " + "removing LCS state so any future boot starts from a clean slate." + ) + self._lcs_state.pop(session, None) # Clear the list of processes being removed if proc_uuid in self.uuids_pending_deletion: @@ -539,10 +541,12 @@ def _lcs_state_for(self, session: str) -> _LcsSessionState: Return the LCS state for *session*, creating a fresh entry if absent. Centralises dict access so callers never deal with missing-key logic. + Thread-safe: the check-then-create is performed under _lcs_state_lock. """ - if session not in self._lcs_state: - self._lcs_state[session] = _LcsSessionState() - return self._lcs_state[session] + with self._lcs_state_lock: + if session not in self._lcs_state: + self._lcs_state[session] = _LcsSessionState() + return self._lcs_state[session] def _is_host_cached(self, host: str) -> None | bool: """ @@ -1437,7 +1441,8 @@ def _get_pod_host_aliases( host_aliases - a list containing a single V1HostAlias mapping localhost to the connection server IP, or None if not applicable """ - lcs = self._lcs_state.get(session) + with self._lcs_state_lock: + lcs = self._lcs_state.get(session) if ( self._is_local_connection_server(tree_labels, podname) or lcs is None @@ -1814,7 +1819,8 @@ def _get_connection_server_cluster_ip(self, session: str) -> str: Returns: cluster_ip - the ClusterIP string, or None on failure """ - lcs = self._lcs_state.get(session) + with self._lcs_state_lock: + lcs = self._lcs_state.get(session) if lcs is None or lcs.podname is None: self.log.warning( f"No local connection server registered for session '{session}'" @@ -2212,7 +2218,12 @@ def _wait_for_lcs_readiness(self, podname: str, session: str) -> None: self._wait_for_nodeport_http_ready(url, remaining_time) - lcs.is_booted = True + with self._lcs_state_lock: + # Re-check the entry still exists: the watcher may have popped it + # if the pod died in the narrow window between stage 2 succeeding + # and this assignment. + if self._lcs_state.get(session) is lcs: + lcs.is_booted = True self.log.info(f"Connection server '{podname}' is fully ready.") def _wait_for_controller_readiness( @@ -2668,7 +2679,8 @@ def kill_and_wait(uuids, grace_period=None) -> None: self.log.info(f'Session "{session}" is empty, deleting namespace.') self._core_v1_api.delete_namespace(session) self.managed_sessions.remove(session) - self._lcs_state.pop(session, None) + with self._lcs_state_lock: + self._lcs_state.pop(session, None) except self._api_error_v1_api as e: self.log.warning(f"Failed during namespace cleanup: {e}") From 2aee332d3220c02ac98fa16702455e0f66a52b3c Mon Sep 17 00:00:00 2001 From: wanyunSu Date: Tue, 16 Jun 2026 15:46:45 +0200 Subject: [PATCH 22/73] remove redundent port for lcs --- src/drunc/process_manager/k8s_process_manager.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/drunc/process_manager/k8s_process_manager.py b/src/drunc/process_manager/k8s_process_manager.py index d1c94087d..f0aa23209 100644 --- a/src/drunc/process_manager/k8s_process_manager.py +++ b/src/drunc/process_manager/k8s_process_manager.py @@ -61,7 +61,6 @@ class _LcsSessionState: """ podname: str | None = None - port: int | None = None node_port: int | None = None is_booted: bool = False @@ -1726,7 +1725,6 @@ def _create_pod( if lcs_port: lcs = self._lcs_state_for(session) lcs.podname = podname - lcs.port = lcs_port lcs.node_port = lcs_port else: raise DruncK8sException( From 5b573d1bba15b2a194669c8b10f406057bd0479c Mon Sep 17 00:00:00 2001 From: wanyunSu Date: Wed, 17 Jun 2026 14:44:44 +0200 Subject: [PATCH 23/73] address comments above --- src/drunc/controller/interface/commands.py | 8 ++++---- src/drunc/controller/interface/context.py | 12 +++++++---- src/drunc/controller/interface/shell_utils.py | 20 ++++++++++--------- src/drunc/unified_shell/context.py | 12 ++++------- 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/drunc/controller/interface/commands.py b/src/drunc/controller/interface/commands.py index ac3a88f3f..26f60972b 100644 --- a/src/drunc/controller/interface/commands.py +++ b/src/drunc/controller/interface/commands.py @@ -71,10 +71,10 @@ def wait(obj: ControllerContext, sleep_time: int) -> None: default=True, ) @click.option( - "--full", + "--extended", is_flag=True, default=False, - help="Show additional columns, including the actual endpoint IP address.", + help="Show additional columns, including the IP address of each endpoint.", ) @click.pass_obj def status( @@ -82,7 +82,7 @@ def status( target: str, execute_along_path: bool, execute_on_all_subsequent_children_in_path: bool, - full: bool, + extended: bool, ) -> None: obj.print( render_status_table( @@ -90,7 +90,7 @@ def status( target=target, execute_along_path=execute_along_path, execute_on_all_subsequent_children_in_path=execute_on_all_subsequent_children_in_path, - show_actual_endpoint=full, + show_ip_address=extended, ) ) obj.print_status_summary() diff --git a/src/drunc/controller/interface/context.py b/src/drunc/controller/interface/context.py index 351db8624..27fb31b9f 100644 --- a/src/drunc/controller/interface/context.py +++ b/src/drunc/controller/interface/context.py @@ -41,11 +41,15 @@ def start_listening_controller(self, broadcaster_conf): def get_endpoint_display_host_overrides(self) -> dict[str, str]: """ - Return UI-only endpoint host overrides. + Return display hostname overrides for status-table rendering. - The controller's advertised endpoint remains the authoritative connect - address. This method only provides cosmetic host substitutions for - status-table rendering. + Returns an empty dict because this context connects directly to a + controller without a process manager, so no per-process hostname + metadata is available. + + Returns: + dict[str, str]: Mapping of {process_name: hostname}. + Always empty for this context. """ return {} diff --git a/src/drunc/controller/interface/shell_utils.py b/src/drunc/controller/interface/shell_utils.py index 3c7810d13..f100baa95 100644 --- a/src/drunc/controller/interface/shell_utils.py +++ b/src/drunc/controller/interface/shell_utils.py @@ -38,6 +38,7 @@ ) from rich.table import Table +from drunc.controller.interface.context import ControllerContext from drunc.exceptions import DruncSetupException, DruncShellException from drunc.unified_shell.context import UnifiedShellContext, UnifiedShellMode from drunc.utils.grpc_utils import ( @@ -74,7 +75,7 @@ def get_status_table( status_response: StatusResponse, describe_response: DescribeResponse, display_host_overrides: dict[str, str] | None = None, - show_actual_endpoint: bool = False, + show_ip_address: bool = False, ): status = status_response.status description = describe_response.description @@ -93,8 +94,8 @@ def get_status_table( t.add_column("In error") t.add_column("Included") t.add_column("Endpoint") - if show_actual_endpoint: - t.add_column("Actual Endpoint") + if show_ip_address: + t.add_column("IP Address") def add_status_to_table( table: Table, @@ -150,7 +151,9 @@ def make_uri(host: str) -> str: return endpoint, "" - display_ep, actual_ep = update_endpoint(description.endpoint, status_response.name) + display_ep, actual_ep = update_endpoint( + description.endpoint, status_response.name + ) row = [ prefix + status_response.name, description.info, @@ -160,7 +163,7 @@ def make_uri(host: str) -> str: format_bool(status.included), display_ep, ] - if show_actual_endpoint: + if show_ip_address: row.append(actual_ep) table.add_row(*row) @@ -212,15 +215,13 @@ def add_runinfo_to_table(table: Table, status: Status): return t -from drunc.controller.interface.context import ControllerContext - def render_status_table( ctx: ControllerContext, target: str = "", execute_along_path: bool = True, execute_on_all_subsequent_children_in_path: bool = True, - show_actual_endpoint: bool = False, + show_ip_address: bool = False, ): statuses = ctx.get_driver("controller").status( target=target, @@ -237,9 +238,10 @@ def render_status_table( statuses, descriptions, display_host_overrides=display_host_overrides, - show_actual_endpoint=show_actual_endpoint, + show_ip_address=show_ip_address, ) + class StatusTableUpdater(Progress): def __init__(self, ctx, refresh_per_second=2, *args, **kwargs) -> None: self.ctx = ctx diff --git a/src/drunc/unified_shell/context.py b/src/drunc/unified_shell/context.py index 264d21374..d48ea3df8 100644 --- a/src/drunc/unified_shell/context.py +++ b/src/drunc/unified_shell/context.py @@ -2,6 +2,7 @@ from enum import Enum import grpc +from druncschema.process_manager_pb2 import ProcessQuery from druncschema.token_pb2 import Token from drunc.utils.grpc_utils import ServerTimeout, ServerUnreachable @@ -99,7 +100,6 @@ def start_listening_controller(self, broadcaster_conf) -> None: ) self.status_receiver_controller = BroadcastHandler(broadcast_configuration=bcch) - def get_endpoint_display_host_overrides(self) -> dict[str, str]: """ Return a mapping of process name -> preferred display hostname for endpoint @@ -119,13 +119,9 @@ def get_endpoint_display_host_overrides(self) -> dict[str, str]: if not pm_driver: return {} - from druncschema.process_manager_pb2 import ProcessQuery - - query = ( - ProcessQuery(names=[".*"], session=self.session_name) - if self.session_name - else ProcessQuery(names=[".*"]) - ) + if not self.session_name: + raise RuntimeError("session name must be set before querying process list") + query = ProcessQuery(names=[".*"], session=self.session_name) try: proc_list = pm_driver.ps(query) except (ServerUnreachable, ServerTimeout, grpc.RpcError): From 298a48cf42f9c05a4047e564f34bf7c51a2e3be5 Mon Sep 17 00:00:00 2001 From: Aurash Karimi Date: Wed, 17 Jun 2026 17:21:26 +0100 Subject: [PATCH 24/73] initial working refactor --- src/drunc/authoriser/configuration.py | 29 +- src/drunc/broadcast/client/configuration.py | 66 +++-- src/drunc/broadcast/server/configuration.py | 41 ++- .../children_interface/grpc_child.py | 47 +++- .../children_interface/rest_api_child.py | 37 ++- src/drunc/controller/configuration.py | 38 ++- src/drunc/controller/controller.py | 6 +- src/drunc/controller/interface/context.py | 5 +- src/drunc/controller/interface/controller.py | 7 +- src/drunc/fsm/configuration.py | 73 +++-- src/drunc/process_manager/configuration.py | 111 ++++---- .../process_manager/interface/context.py | 6 +- .../interface/process_manager.py | 10 +- src/drunc/process_manager/process_manager.py | 9 +- src/drunc/process_manager/utils.py | 12 +- src/drunc/session_manager/configuration.py | 29 +- .../interface/session_manager.py | 9 +- src/drunc/unified_shell/context.py | 12 +- src/drunc/unified_shell/shell.py | 11 +- src/drunc/utils/configuration.py | 257 ++++++++++++------ tests/issues/test_issue309.py | 9 +- tests/issues/test_issue363.py | 9 +- 22 files changed, 571 insertions(+), 262 deletions(-) diff --git a/src/drunc/authoriser/configuration.py b/src/drunc/authoriser/configuration.py index 2820db245..25356ca1e 100644 --- a/src/drunc/authoriser/configuration.py +++ b/src/drunc/authoriser/configuration.py @@ -1,5 +1,28 @@ -from drunc.utils.configuration import ConfHandler +from drunc.utils.configuration import ( + ConfData, + ConfHandler, + ConfTypeNotSupported, + ConfTypes, +) -class DummyAuthoriserConfHandler(ConfHandler): - pass +class DummyAuthoriserConfData(ConfData): + """Wrapper for dummy authoriser configuration data.""" + + def __init__(self) -> None: + pass + + def populate_from_dict(self, data: dict[str, object]) -> None: + """Populate from dictionary data.""" + # Dummy authoriser has no configuration requirements + pass + + def populate_from_pbany(self, pbany_data: object) -> None: + """Populate from Protobuf Any message.""" + raise ConfTypeNotSupported(ConfTypes.ProtobufAny, self.__class__.__name__) + + +class DummyAuthoriserConfHandler(ConfHandler[DummyAuthoriserConfData]): + """Handler for dummy authoriser configuration.""" + + confdata_cls = DummyAuthoriserConfData diff --git a/src/drunc/broadcast/client/configuration.py b/src/drunc/broadcast/client/configuration.py index 4b5cdd7f1..9f8d53ca9 100644 --- a/src/drunc/broadcast/client/configuration.py +++ b/src/drunc/broadcast/client/configuration.py @@ -1,43 +1,55 @@ from drunc.broadcast.types import BroadcastTypes -from drunc.utils.configuration import ConfHandler +from drunc.exceptions import DruncSetupException +from drunc.utils.configuration import ConfData, ConfHandler +from drunc.utils.grpc_utils import UnpackingError, unpack_any -class BroadcastClientConfData: # OKSeroo - def __init__(self, type: BroadcastTypes, address: str, topic: str): +class BroadcastClientConfData(ConfData): + """Wrapper for broadcast client configuration data.""" + + def __init__( + self, + type: BroadcastTypes | None = None, + address: str | None = None, + topic: str | None = None, + ) -> None: self.type = type self.address = address self.topic = topic + def populate_from_dict(self, data: dict[str, object]) -> None: + """Populate from dictionary data.""" + if not data: + return + self.type = BroadcastTypes.Kafka + self.address = data.get("kafka_address", data.get("address")) + self.topic = data.get("topic") -class BroadcastClientConfHandler(ConfHandler): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def get_impl_technology(self): - return self.impl_technology - - def _parse_pbany(self, data): - # potentially do something more complicated with different implementation technology here - # match data.format(): - # case KafkaBroadcastHandlerConfiguration - # ... - + def populate_from_pbany(self, pbany_data: object) -> None: + """Populate from Protobuf Any message.""" from druncschema.broadcast_pb2 import KafkaBroadcastHandlerConfiguration - from drunc.utils.grpc_utils import UnpackingError, unpack_any - - if not data.ByteSize(): - return BroadcastClientConfData(type=None, address=None, topic=None) + if not pbany_data.ByteSize(): + self.type = None + self.address = None + self.topic = None + return try: - data = unpack_any(data, KafkaBroadcastHandlerConfiguration) - return BroadcastClientConfData( - type=BroadcastTypes.Kafka, address=data.kafka_address, topic=data.topic - ) - + data = unpack_any(pbany_data, KafkaBroadcastHandlerConfiguration) + self.type = BroadcastTypes.Kafka + self.address = data.kafka_address + self.topic = data.topic except UnpackingError as e: - from drunc.exceptions import DruncSetupException - raise DruncSetupException( f"Input configuration to configure the broadcast was not understood, could not setup the broadcast handler: {e}", e, ) + + +class BroadcastClientConfHandler(ConfHandler[BroadcastClientConfData]): + """Handler for broadcast client configuration.""" + + confdata_cls = BroadcastClientConfData + + def get_impl_technology(self): + return self.data.type diff --git a/src/drunc/broadcast/server/configuration.py b/src/drunc/broadcast/server/configuration.py index fe485e4fa..378b47df9 100644 --- a/src/drunc/broadcast/server/configuration.py +++ b/src/drunc/broadcast/server/configuration.py @@ -1,4 +1,9 @@ -from drunc.utils.configuration import ConfHandler +from drunc.utils.configuration import ( + ConfData, + ConfHandler, + ConfTypeNotSupported, + ConfTypes, +) class KafkaBroadcastSenderConfData: @@ -17,18 +22,38 @@ def from_dict(data: dict): ) -class BroadcastSenderConfHandler(ConfHandler): +class BroadcastSenderConfData(ConfData): + """Wrapper for broadcast sender configuration data.""" + + def __init__(self): + self.kafka_data = None + + def populate_from_dict(self, data: dict[str, object]) -> None: + """Populate from dictionary data.""" + if data == {}: + self.kafka_data = None + else: + self.kafka_data = KafkaBroadcastSenderConfData.from_dict(data) + + def populate_from_pbany(self, pbany_data: object) -> None: + """Populate from Protobuf Any message.""" + raise ConfTypeNotSupported(ConfTypes.ProtobufAny, self.__class__.__name__) + + +class BroadcastSenderConfHandler(ConfHandler[BroadcastSenderConfData]): + """Handler for broadcast sender configuration.""" + + confdata_cls = BroadcastSenderConfData + def _post_process_oks(self): from drunc.broadcast.types import BroadcastTypes + # Normalize wrapped JSON-loaded data to the runtime shape used by sender code. + if hasattr(self.data, "kafka_data"): + self.data = self.data.kafka_data + self.impl_technology = BroadcastTypes.Kafka if self.data else None self.log.debug(self.data) def get_impl_technology(self): return self.impl_technology - - def _parse_dict(self, data): - if data == {}: - self.impl_technology = None - return None - return KafkaBroadcastSenderConfData.from_dict(data) diff --git a/src/drunc/controller/children_interface/grpc_child.py b/src/drunc/controller/children_interface/grpc_child.py index 54c7f6f0d..518ae4229 100644 --- a/src/drunc/controller/children_interface/grpc_child.py +++ b/src/drunc/controller/children_interface/grpc_child.py @@ -43,7 +43,12 @@ from drunc.controller.children_interface.child_node import ChildNode from drunc.exceptions import DruncSetupException from drunc.grpc_settings import CONTROLLER_CLIENT_GRPC_CONFIG -from drunc.utils.configuration import ConfHandler, ConfTypes +from drunc.utils.configuration import ( + ConfData, + ConfHandler, + ConfTypeNotSupported, + ConfTypes, +) from drunc.utils.grpc_utils import ( ServerUnreachable, rethrow_if_unreachable_server, @@ -55,7 +60,40 @@ ) -class gRCPChildConfHandler(ConfHandler): +class gRCPChildConfData(ConfData): + """Wrapper for gRPC child node configuration.""" + + def __init__(self) -> None: + class id_able: + id = None + + class cler: + pass + + self.controller = cler() + self.controller.id = None + self.controller.exposes_service = [] + + class runs_on_obj: + id = None + + self.controller.runs_on = runs_on_obj() + self.controller.runs_on.runs_on = runs_on_obj() + + def populate_from_dict(self, data: dict[str, object]) -> None: + """Populate from dictionary data.""" + raise ConfTypeNotSupported(ConfTypes.JsonFileName, self.__class__.__name__) + + def populate_from_pbany(self, pbany_data: object) -> None: + """Populate from Protobuf Any message.""" + raise ConfTypeNotSupported(ConfTypes.ProtobufAny, self.__class__.__name__) + + +class gRCPChildConfHandler(ConfHandler[gRCPChildConfData]): + """Handler for gRPC child node configuration.""" + + confdata_cls = gRCPChildConfData + def get_uri(self): for service in self.data.controller.exposes_service: if self.data.controller.id + "_control" in service.id: @@ -235,10 +273,7 @@ def check_connection(self) -> bool: def start_listening(self, bdesc): self.broadcast = BroadcastHandler( - BroadcastClientConfHandler( - data=bdesc, - type=ConfTypes.ProtobufAny, - ) + BroadcastClientConfHandler.from_pbany(data=bdesc) ) def status( diff --git a/src/drunc/controller/children_interface/rest_api_child.py b/src/drunc/controller/children_interface/rest_api_child.py index 9eab6130d..b0f42cc71 100644 --- a/src/drunc/controller/children_interface/rest_api_child.py +++ b/src/drunc/controller/children_interface/rest_api_child.py @@ -39,7 +39,12 @@ from drunc.exceptions import DruncException, DruncSetupException from drunc.fsm.configuration import FSMConfHandler from drunc.fsm.core import FSM, FSMDestinationResult, FSMDestinationType -from drunc.utils.configuration import ConfHandler +from drunc.utils.configuration import ( + ConfData, + ConfHandler, + ConfTypeNotSupported, + ConfTypes, +) from drunc.utils.flask_manager import FlaskManager from drunc.utils.utils import ( ControlType, @@ -354,7 +359,33 @@ def check_response(self, timeout: int = 0) -> dict: """ -class RESTAPIChildNodeConfHandler(ConfHandler): +class RESTAPIChildNodeConfData(ConfData): + """Wrapper for REST API child node configuration.""" + + def __init__(self) -> None: + self.id = None + self.exposes_service = [] + + class runs_on_obj: + id = None + + self.runs_on = runs_on_obj() + self.runs_on.runs_on = runs_on_obj() + + def populate_from_dict(self, data: dict[str, object]) -> None: + """Populate from dictionary data.""" + raise ConfTypeNotSupported(ConfTypes.JsonFileName, self.__class__.__name__) + + def populate_from_pbany(self, pbany_data: object) -> None: + """Populate from Protobuf Any message.""" + raise ConfTypeNotSupported(ConfTypes.ProtobufAny, self.__class__.__name__) + + +class RESTAPIChildNodeConfHandler(ConfHandler[RESTAPIChildNodeConfData]): + """Handler for REST API child node configuration.""" + + confdata_cls = RESTAPIChildNodeConfData + def get_host_port(self): for service in self.data.exposes_service: if self.data.id + "_control" in service.id: @@ -379,7 +410,7 @@ def __init__( self.fsm_configuration = fsm_configuration self.connectivity_service = connectivity_service if fsm_configuration: - fsmch = FSMConfHandler(fsm_configuration) + fsmch = FSMConfHandler.from_pyobject(data=fsm_configuration) self.fsm = FSM(conf=fsmch) response_listener_host = socket.gethostname() diff --git a/src/drunc/controller/configuration.py b/src/drunc/controller/configuration.py index 64944fa8c..ed0d2e5f6 100644 --- a/src/drunc/controller/configuration.py +++ b/src/drunc/controller/configuration.py @@ -21,7 +21,12 @@ ) from drunc.exceptions import DruncCommandException, DruncSetupException from drunc.process_manager.configuration import get_commandline_parameters -from drunc.utils.configuration import ConfHandler, ConfTypes +from drunc.utils.configuration import ( + ConfData, + ConfHandler, + ConfTypeNotSupported, + ConfTypes, +) from drunc.utils.utils import ( ControlType, get_control_type_and_uri_from_cli, @@ -30,8 +35,10 @@ ) -class ControllerConfData: # the bastardised OKS - def __init__(self): +class ControllerConfData(ConfData): + """Wrapper for controller configuration data from OKS segment.""" + + def __init__(self) -> None: class id_able: id = None @@ -41,9 +48,22 @@ class cler: self.controller = cler() self.controller.broadcaster = id_able() self.controller.fsm = id_able() + self.segments = [] + + def populate_from_dict(self, data: dict[str, object]) -> None: + """Populate from dictionary data.""" + raise ConfTypeNotSupported(ConfTypes.JsonFileName, self.__class__.__name__) + + def populate_from_pbany(self, pbany_data: object) -> None: + """Populate from Protobuf Any message.""" + raise ConfTypeNotSupported(ConfTypes.ProtobufAny, self.__class__.__name__) + + +class ControllerConfHandler(ConfHandler[ControllerConfData]): + """Handler for controller configuration.""" + confdata_cls = ControllerConfData -class ControllerConfHandler(ConfHandler): @staticmethod def find_segment(segment, id_): if segment.controller.id == id_: @@ -67,7 +87,7 @@ def _grab_segment_conf_from_controller(self, configuration): ) return this_segment - def _post_process_oks(self, *args, **kwargs): + def _post_process_oks(self) -> None: self.authoriser = None self.data = self._grab_segment_conf_from_controller(self.data) @@ -252,16 +272,16 @@ def child_node_factory( match ctype: case ControlType.gRPC: - grpc_conf_handler = gRCPChildConfHandler( - configuration, ConfTypes.PyObject + grpc_conf_handler = gRCPChildConfHandler.from_pyobject( + data=configuration ) return gRPCChildNode( name, grpc_conf_handler, uri, connectivity_service, init_token ) case ControlType.REST_API: - restapi_conf_handler = RESTAPIChildNodeConfHandler( - configuration, ConfTypes.PyObject + restapi_conf_handler = RESTAPIChildNodeConfHandler.from_pyobject( + data=configuration ) return RESTAPIChildNode( name, diff --git a/src/drunc/controller/controller.py b/src/drunc/controller/controller.py index c73f8174b..9bd2d31ff 100644 --- a/src/drunc/controller/controller.py +++ b/src/drunc/controller/controller.py @@ -109,7 +109,7 @@ def __init__(self, configuration, name: str, session: str, token: Token): self.opmon_publisher = getattr(self.configuration, "opmon_publisher", None) self.stop_event: threading.Event | None = None self.thread: threading.Thread | None = None - bsch = BroadcastSenderConfHandler( + bsch = BroadcastSenderConfHandler.from_pyobject( data=self.configuration.data.controller.broadcaster, ) @@ -119,7 +119,7 @@ def __init__(self, configuration, name: str, session: str, token: Token): configuration=bsch, ) - self.fsm_config = FSMConfHandler( + self.fsm_config = FSMConfHandler.from_pyobject( data=self.configuration.data.controller.fsm, ) @@ -132,7 +132,7 @@ def __init__(self, configuration, name: str, session: str, token: Token): top_segment_controller=self.top_segment_controller, ) - dach = DummyAuthoriserConfHandler( + dach = DummyAuthoriserConfHandler.from_pyobject( data=self.configuration.authoriser, ) diff --git a/src/drunc/controller/interface/context.py b/src/drunc/controller/interface/context.py index 082c42a00..e01f1e56f 100644 --- a/src/drunc/controller/interface/context.py +++ b/src/drunc/controller/interface/context.py @@ -5,7 +5,6 @@ from drunc.broadcast.client.broadcast_handler import BroadcastHandler from drunc.broadcast.client.configuration import BroadcastClientConfHandler from drunc.controller.controller_driver import ControllerDriver -from drunc.utils.configuration import ConfTypes from drunc.utils.shell_utils import ( ShellContext, create_dummy_token_from_uname, @@ -34,9 +33,7 @@ def create_token(self, **kwargs) -> Token: return create_dummy_token_from_uname() def start_listening_controller(self, broadcaster_conf): - bcch = BroadcastClientConfHandler( - data=broadcaster_conf, type=ConfTypes.ProtobufAny - ) + bcch = BroadcastClientConfHandler.from_pbany(data=broadcaster_conf) self.status_receiver = BroadcastHandler(broadcast_configuration=bcch) def terminate(self): diff --git a/src/drunc/controller/interface/controller.py b/src/drunc/controller/interface/controller.py index 4eadef8a2..a4b3ff059 100644 --- a/src/drunc/controller/interface/controller.py +++ b/src/drunc/controller/interface/controller.py @@ -14,7 +14,7 @@ CONTROLLER_SERVER_GRPC_CONFIG, CONTROLLER_SERVER_GRPC_MAX_WORKERS, ) -from drunc.utils.configuration import ConfTypes, OKSKey +from drunc.utils.configuration import OKSKey from drunc.utils.utils import ( get_logger, get_root_logger, @@ -86,9 +86,8 @@ def controller_cli( token="", ) - controller_configuration = ControllerConfHandler( - type=ConfTypes.OKSFileName, - data=configurationservice, + controller_configuration = ControllerConfHandler.from_oks( + url=configurationservice, oks_key=OKSKey( schema_file="schema/confmodel/dunedaq.schema.xml", class_name="RCApplication", diff --git a/src/drunc/fsm/configuration.py b/src/drunc/fsm/configuration.py index 50760ae55..0477c1ae0 100644 --- a/src/drunc/fsm/configuration.py +++ b/src/drunc/fsm/configuration.py @@ -4,16 +4,46 @@ from drunc.fsm.action_factory import FSMActionFactory from drunc.fsm.core import PreOrPostTransitionSequence from drunc.fsm.transition import Transition -from drunc.utils.configuration import ConfHandler +from drunc.utils.configuration import ( + ConfData, + ConfHandler, + ConfTypeNotSupported, + ConfTypes, +) from drunc.utils.utils import get_logger -class FSMConfHandler(ConfHandler): +class FSMConfData(ConfData): + """Wrapper for FSM configuration data.""" + + def __init__(self) -> None: + self.states = [] + self.initial_state = None + self.actions = [] + self.transitions = [] + self.pre_transitions = [] + self.post_transitions = [] + self.command_sequences = [] + + def populate_from_dict(self, data: dict[str, object]) -> None: + """Populate from dictionary data.""" + raise ConfTypeNotSupported(ConfTypes.JsonFileName, self.__class__.__name__) + + def populate_from_pbany(self, pbany_data: object) -> None: + """Populate from Protobuf Any message.""" + raise ConfTypeNotSupported(ConfTypes.ProtobufAny, self.__class__.__name__) + + +class FSMConfHandler(ConfHandler[FSMConfData]): + """Handler for FSM configuration.""" + + confdata_cls = FSMConfData + def _fill_pre_post_transition_sequence_oks( self, prefix: str, transition: Transition, - data: list("conffwk.dal.FSMxTransition"), + data: list["conffwk.dal.FSMxTransition"], ) -> PreOrPostTransitionSequence: """ Fill the pre or post transition sequence for a given transition. @@ -51,7 +81,7 @@ def _fill_pre_post_transition_sequence_oks( break return seq - def _post_process_oks(self): + def _post_process_oks(self) -> None: """ Post-process the configuration data after it has been loaded and validated. @@ -72,14 +102,14 @@ def _post_process_oks(self): """ # Define the data structures to store the FSM configuration self.log.debug("_post_process_oks configuration") - self.pre_transitions: dict(Transition, PreOrPostTransitionSequence) = {} - self.post_transitions: dict(Transition, PreOrPostTransitionSequence) = {} - self.actions: dict( + self.pre_transitions: dict[Transition, PreOrPostTransitionSequence] = {} + self.post_transitions: dict[Transition, PreOrPostTransitionSequence] = {} + self.actions: dict[ str, "conffwk.dal.FSMAction" - ) = {} # (e.g. "thread_pinning": thread_pinning FSM action object) - self.transitions: list(Transition) = [] - self.sequences: list["conffwk.dal.FSMSequence"] = [] - self.states: list(str) = self.data.states + ] = {} # (e.g. "thread_pinning": thread_pinning FSM action object) + self.transitions: list[Transition] = [] + self.sequences: list[FSMSequence] = [] + self.states: list[str] = self.data.states self.initial_state: str = self.data.initial_state # Fill the actions dictionary with the FSMAction objects corresponding to the @@ -126,26 +156,27 @@ def _post_process_oks(self): cmd_ids = [cmd.id for cmd in sequence.sequence] self.sequences.append(FSMSequence(id=seq_id, command_ids=cmd_ids)) - # def _parse_dict(self, data): - # pass - - def get_actions(self): + def get_actions(self) -> dict[str, "conffwk.dal.FSMAction"]: return self.actions - def get_initial_state(self): + def get_initial_state(self) -> str | None: return self.data.initial_state - def get_states(self): + def get_states(self) -> list[str]: return self.data.states - def get_transitions(self): + def get_transitions(self) -> list[Transition]: return self.transitions - def get_pre_transitions_sequences(self): + def get_pre_transitions_sequences( + self, + ) -> dict[Transition, PreOrPostTransitionSequence]: return self.pre_transitions - def get_post_transitions_sequences(self): + def get_post_transitions_sequences( + self, + ) -> dict[Transition, PreOrPostTransitionSequence]: return self.post_transitions - def get_sequences(self): + def get_sequences(self) -> list[FSMSequence]: return self.sequences diff --git a/src/drunc/process_manager/configuration.py b/src/drunc/process_manager/configuration.py index da9198722..2e0bda8d3 100644 --- a/src/drunc/process_manager/configuration.py +++ b/src/drunc/process_manager/configuration.py @@ -3,7 +3,7 @@ import sys from enum import Enum from importlib import resources -from typing import TYPE_CHECKING, Any, Dict, Union +from typing import TYPE_CHECKING, Any, Dict, Self, Union from urllib.parse import unquote, urlparse from jsonschema import ValidationError @@ -15,7 +15,12 @@ from drunc.broadcast.server.configuration import KafkaBroadcastSenderConfData from drunc.exceptions import DruncCommandException from drunc.process_manager.exceptions import UnknownProcessManagerType -from drunc.utils.configuration import ConfHandler +from drunc.utils.configuration import ( + ConfData, + ConfHandler, + ConfTypeNotSupported, + ConfTypes, +) from drunc.utils.utils import get_logger if TYPE_CHECKING: @@ -38,7 +43,9 @@ class ProcessManagerTypes(Enum): SSH_PARAMIKO = 3 -class ProcessManagerConfData: +class ProcessManagerConfData(ConfData): + """Wrapper for process manager configuration data.""" + def __init__(self): self.broadcaster = None self.authoriser = None @@ -46,85 +53,97 @@ def __init__(self): self.command_address = "" self.environment = {} self.settings = {} + self.opmon_conf = None self.opmon_uri = None self.opmon_publisher = None - -class ProcessManagerConfHandler(ConfHandler): - def __init__(self, log_path: str, *args, **kwargs): - super().__init__(*args, **kwargs) - self.log_path = log_path - self.log = get_logger("process_manager.conf_handler") - - def get_log_path(self): - return self.log_path - - def _parse_dict(self, data): - new_data = ProcessManagerConfData() + def populate_from_dict(self, data: dict[str, object]) -> None: + """Populate from dictionary data.""" if data.get("broadcaster"): - new_data.broadcaster = KafkaBroadcastSenderConfData.from_dict( + self.broadcaster = KafkaBroadcastSenderConfData.from_dict( data.get("broadcaster") ) - new_data.environment = data.get("environment", {}) - new_data.settings = data.get("settings", {}) + self.environment = data.get("environment", {}) + self.settings = data.get("settings", {}) + self.opmon_conf = data.get("opmon_conf") + self.opmon_uri = data.get("opmon_uri") match data["type"].lower(): case "ssh": - new_data.type = ProcessManagerTypes.SSH_SHELL - new_data.kill_timeout = data.get("kill_timeout", 0.5) + self.type = ProcessManagerTypes.SSH_SHELL + self.kill_timeout = data.get("kill_timeout", 0.5) case "ssh-paramiko": - new_data.type = ProcessManagerTypes.SSH_PARAMIKO - new_data.kill_timeout = data.get("kill_timeout", 0.5) + self.type = ProcessManagerTypes.SSH_PARAMIKO + self.kill_timeout = data.get("kill_timeout", 0.5) case "k8s": - new_data.type = ProcessManagerTypes.K8s - new_data.image = data.get("image", "ghcr.io/dune-daq/alma9:latest") + self.type = ProcessManagerTypes.K8s + self.image = data.get("image", "ghcr.io/dune-daq/alma9:latest") case _: raise UnknownProcessManagerType(data["type"]) - # opmon_publisher left as default None - self.opmon_conf = parse_opmon_conf( + def populate_from_pbany(self, pbany_data: object) -> None: + """Populate from Protobuf Any message.""" + raise ConfTypeNotSupported(ConfTypes.ProtobufAny, self.__class__.__name__) + + +class ProcessManagerConfHandler(ConfHandler[ProcessManagerConfData]): + """Handler for process manager configuration.""" + + confdata_cls = ProcessManagerConfData + log_path: str = "./" + + @classmethod + def from_json( + cls, path: str, session_name: str | None = None, log_path: str = "./" + ) -> Self: + """Create handler from JSON file with optional log path.""" + instance = super().from_json(path, session_name) + instance.log_path = log_path + instance.log = get_logger("process_manager.conf_handler") + return instance + + def get_log_path(self): + return self.log_path + + def _post_process_oks(self) -> None: + """Post-process to handle OpMon configuration.""" + opmon_conf = parse_opmon_conf( log=self.log, - conf=data.get("opmon_conf", None), - uri=data.get("opmon_uri", None), - session=new_data.type.name, + conf=getattr(self.data, "opmon_conf", None), + uri=getattr(self.data, "opmon_uri", None), + session=self.data.type.name, application="process_manager", ) - if self.opmon_conf.path == "./info.json": - self.opmon_conf.path = ( - "./info." - + self.opmon_conf.session - + "." - + self.opmon_conf.application - + ".json" + if opmon_conf.path == "./info.json": + opmon_conf.path = ( + "./info." + opmon_conf.session + "." + opmon_conf.application + ".json" ) self.log.debug( - "Initializing process manager OpMon with configuration %s", self.opmon_conf + "Initializing process manager OpMon with configuration %s", opmon_conf ) try: - if self.opmon_conf.opmon_type == "stream": - new_data.opmon_publisher = KafkaOpMonPublisher(self.opmon_conf) + if opmon_conf.opmon_type == "stream": + self.data.opmon_publisher = KafkaOpMonPublisher(opmon_conf) self.log.debug( "KafkaOpMonPublisher initialized with configuration %s", - self.opmon_conf, + opmon_conf, ) else: - new_data.opmon_publisher = OpMonPublisher( - conf=self.opmon_conf, rich_handler=True + self.data.opmon_publisher = OpMonPublisher( + conf=opmon_conf, rich_handler=True ) self.log.debug( "%s OpMonPublisher initialized with configuration %s", - self.opmon_conf.opmon_type, - self.opmon_conf, + opmon_conf.opmon_type, + opmon_conf, ) except Exception as e: self.log.error("Failed to initialize OpMonPublisher: %s", e) raise DruncCommandException("Failed to initialize OpMonPublisher.") - return new_data - def get_commandline_parameters( config_filename: str, diff --git a/src/drunc/process_manager/interface/context.py b/src/drunc/process_manager/interface/context.py index a81396130..224c9a5fd 100644 --- a/src/drunc/process_manager/interface/context.py +++ b/src/drunc/process_manager/interface/context.py @@ -5,7 +5,6 @@ from drunc.broadcast.client.broadcast_handler import BroadcastHandler from drunc.broadcast.client.configuration import BroadcastClientConfHandler from drunc.process_manager.process_manager_driver import ProcessManagerDriver -from drunc.utils.configuration import ConfTypes from drunc.utils.shell_utils import ( ShellContext, create_dummy_token_from_uname, @@ -40,10 +39,7 @@ def create_token(self, **kwargs) -> Token: return create_dummy_token_from_uname() def start_listening(self, broadcaster_conf): - bcch = BroadcastClientConfHandler( - data=broadcaster_conf, - type=ConfTypes.ProtobufAny, - ) + bcch = BroadcastClientConfHandler.from_pbany(data=broadcaster_conf) self.status_receiver = BroadcastHandler(bcch) get_logger("process_manager.shell").info( f":ear: Listening to the Process Manager at {self.address}" diff --git a/src/drunc/process_manager/interface/process_manager.py b/src/drunc/process_manager/interface/process_manager.py index 6e3d1b294..ae85bfe94 100644 --- a/src/drunc/process_manager/interface/process_manager.py +++ b/src/drunc/process_manager/interface/process_manager.py @@ -22,7 +22,7 @@ ) from drunc.process_manager.process_manager import ProcessManager from drunc.process_manager.utils import get_log_path -from drunc.utils.configuration import parse_conf_url +from drunc.utils.configuration import ConfTypes, parse_conf_url from drunc.utils.grpc_utils import RichErrorServerInterceptor from drunc.utils.utils import ( get_logger, @@ -60,10 +60,12 @@ def run_pm( log.debug("Process manager configuration is valid.") conf_path, conf_type = parse_conf_url(pm_conf) + path_or_url = conf_path.split(":")[1] - pmch = ProcessManagerConfHandler( - log_path=log_path, type=conf_type, data=conf_path.split(":")[1] - ) + if conf_type == ConfTypes.JsonFileName: + pmch = ProcessManagerConfHandler.from_json(path=path_or_url, log_path=log_path) + else: + pmch = ProcessManagerConfHandler.from_pyobject(data=path_or_url) log_path = get_log_path( user=getpass.getuser(), diff --git a/src/drunc/process_manager/process_manager.py b/src/drunc/process_manager/process_manager.py index 07afa8b86..fe4e2de1b 100644 --- a/src/drunc/process_manager/process_manager.py +++ b/src/drunc/process_manager/process_manager.py @@ -39,7 +39,6 @@ ProcessManagerConfHandler, ProcessManagerTypes, ) -from drunc.utils.configuration import ConfTypes from drunc.utils.utils import get_logger, pid_info_str @@ -83,8 +82,8 @@ def __init__( self._create_broadcast_service(self.name, self.session) - dach = DummyAuthoriserConfHandler( - data=self.configuration.get_data_authoriser(), type=ConfTypes.PyObject + dach = DummyAuthoriserConfHandler.from_pyobject( + data=self.configuration.get_data_authoriser() ) self.opmon_publisher = getattr( @@ -169,8 +168,8 @@ def get_log_path(self): return self.configuration.get_log_path() def _create_broadcast_service(self, name, session): - bsch = BroadcastSenderConfHandler( - data=self.configuration.get_data_broadcaster(), type=ConfTypes.PyObject + bsch = BroadcastSenderConfHandler.from_pyobject( + data=self.configuration.get_data_broadcaster() ) self.broadcast_service = ( diff --git a/src/drunc/process_manager/utils.py b/src/drunc/process_manager/utils.py index b7d881d7e..a96ea9607 100644 --- a/src/drunc/process_manager/utils.py +++ b/src/drunc/process_manager/utils.py @@ -20,7 +20,7 @@ get_process_manager_configuration, ) from drunc.processes.process_metadata import ProcessMetadata -from drunc.utils.configuration import parse_conf_url +from drunc.utils.configuration import ConfTypes, parse_conf_url from drunc.utils.utils import now_str @@ -347,9 +347,13 @@ def get_pm_type_from_name(pm_name: str) -> ProcessManagerTypes: pm_conf_file = get_process_manager_configuration(pm_name) conf_path, conf_type = parse_conf_url(pm_conf_file) - pmch = ProcessManagerConfHandler( - log_path="./", type=conf_type, data=conf_path.split(":")[1] - ) + path_or_url = conf_path.split(":")[1] + + if conf_type == ConfTypes.JsonFileName: + pmch = ProcessManagerConfHandler.from_json(path=path_or_url) + else: + # OKS or other types - fallback to from_pyobject + pmch = ProcessManagerConfHandler.from_pyobject(data=path_or_url) return pmch.data.type diff --git a/src/drunc/session_manager/configuration.py b/src/drunc/session_manager/configuration.py index 2211eb6ac..b52d35cf0 100644 --- a/src/drunc/session_manager/configuration.py +++ b/src/drunc/session_manager/configuration.py @@ -1,9 +1,30 @@ """Configuration for the session manager service.""" -from drunc.utils.configuration import ConfHandler +from drunc.utils.configuration import ( + ConfData, + ConfHandler, + ConfTypeNotSupported, + ConfTypes, +) -class SessionManagerConfHandler(ConfHandler): - """TODO: Change this exception to something more useful.""" +class SessionManagerConfData(ConfData): + """Wrapper for session manager configuration data.""" - pass + def __init__(self) -> None: + pass + + def populate_from_dict(self, data: dict[str, object]) -> None: + """Populate from dictionary data.""" + # Session manager has no configuration requirements + pass + + def populate_from_pbany(self, pbany_data: object) -> None: + """Populate from Protobuf Any message.""" + raise ConfTypeNotSupported(ConfTypes.ProtobufAny, self.__class__.__name__) + + +class SessionManagerConfHandler(ConfHandler[SessionManagerConfData]): + """Handler for session manager configuration.""" + + confdata_cls = SessionManagerConfData diff --git a/src/drunc/session_manager/interface/session_manager.py b/src/drunc/session_manager/interface/session_manager.py index afb6e68b4..af96351bd 100644 --- a/src/drunc/session_manager/interface/session_manager.py +++ b/src/drunc/session_manager/interface/session_manager.py @@ -11,7 +11,10 @@ MANAGER_SERVER_GRPC_CONFIG, MANAGER_SERVER_GRPC_MAX_WORKERS, ) -from drunc.session_manager.configuration import SessionManagerConfHandler +from drunc.session_manager.configuration import ( + SessionManagerConfData, + SessionManagerConfHandler, +) from drunc.session_manager.session_manager import SessionManager from drunc.utils.grpc_utils import RichErrorServerInterceptor from drunc.utils.utils import get_logger, get_root_logger @@ -38,7 +41,7 @@ def serve(session_manager: SessionManager, address: str) -> None: @click.command() -def session_manager_cli()-> None: +def session_manager_cli() -> None: """CLI interface for the Drunc session manager. This command starts the session manager service, which allows clients to manage @@ -51,7 +54,7 @@ def session_manager_cli()-> None: logger = get_logger(app_name, rich_handler=True) # Load the configuration for the session manager. - config = SessionManagerConfHandler() + config = SessionManagerConfHandler.from_pyobject(data=SessionManagerConfData()) logger.info(f"Using '{config}' as the SessionManager configuration.") # Load the session manager. diff --git a/src/drunc/unified_shell/context.py b/src/drunc/unified_shell/context.py index f5c9c759e..50a9d9c17 100644 --- a/src/drunc/unified_shell/context.py +++ b/src/drunc/unified_shell/context.py @@ -78,23 +78,15 @@ def create_token(self, **kwargs) -> Token: def start_listening_pm(self, broadcaster_conf) -> None: from drunc.broadcast.client.broadcast_handler import BroadcastHandler from drunc.broadcast.client.configuration import BroadcastClientConfHandler - from drunc.utils.configuration import ConfTypes - bcch = BroadcastClientConfHandler( - type=ConfTypes.ProtobufAny, - data=broadcaster_conf, - ) + bcch = BroadcastClientConfHandler.from_pbany(data=broadcaster_conf) self.status_receiver_pm = BroadcastHandler(broadcast_configuration=bcch) def start_listening_controller(self, broadcaster_conf) -> None: from drunc.broadcast.client.broadcast_handler import BroadcastHandler from drunc.broadcast.client.configuration import BroadcastClientConfHandler - from drunc.utils.configuration import ConfTypes - bcch = BroadcastClientConfHandler( - type=ConfTypes.ProtobufAny, - data=broadcaster_conf, - ) + bcch = BroadcastClientConfHandler.from_pbany(data=broadcaster_conf) self.status_receiver_controller = BroadcastHandler(broadcast_configuration=bcch) def terminate(self) -> None: diff --git a/src/drunc/unified_shell/shell.py b/src/drunc/unified_shell/shell.py index af8bedbec..8a3f38a85 100644 --- a/src/drunc/unified_shell/shell.py +++ b/src/drunc/unified_shell/shell.py @@ -62,7 +62,7 @@ from drunc.unified_shell.commands import boot, start_shell from drunc.unified_shell.context import UnifiedShellMode from drunc.unified_shell.shell_utils import generate_fsm_sequence_command -from drunc.utils.configuration import ConfTypes, OKSKey +from drunc.utils.configuration import OKSKey from drunc.utils.grpc_utils import ServerUnreachable from drunc.utils.utils import ( format_name_for_cli, @@ -335,9 +335,8 @@ def unified_shell( # configuration and getting the FSM transitions from it. ctx.obj.log.debug("Defining the pseudo controller to get its FSM commands") controller_name = session_dal.segment.controller.id - controller_configuration = ControllerConfHandler( - type=ConfTypes.OKSFileName, - data=ctx.obj.configuration_file, + controller_configuration = ControllerConfHandler.from_oks( + url=ctx.obj.configuration_file, oks_key=OKSKey( schema_file="schema/confmodel/dunedaq.schema.xml", class_name="RCApplication", @@ -356,7 +355,9 @@ def unified_shell( # live with it. At least until controller.core uses file handler instead of stream get_logger("controller.core.FSM", log_level="CRITICAL") - fsmch = FSMConfHandler(data=controller_configuration.data.controller.fsm) + fsmch = FSMConfHandler.from_pyobject( + data=controller_configuration.data.controller.fsm + ) ctx.obj.log.debug("Initializing the [green]StatefulNode[/green]") stateful_node = StatefulNode(fsm_configuration=fsmch, top_segment_controller=False) diff --git a/src/drunc/utils/configuration.py b/src/drunc/utils/configuration.py index d5747a7e6..d3ce35918 100644 --- a/src/drunc/utils/configuration.py +++ b/src/drunc/utils/configuration.py @@ -1,9 +1,11 @@ """Configuration utilities for DRUNC.""" import json +import logging import os +from abc import ABC, abstractmethod from enum import Enum -from typing import Protocol, cast +from typing import Generic, Protocol, Self, TypeVar, cast import conffwk @@ -120,54 +122,177 @@ class _ConfigurationData(Protocol): authoriser: object -class ConfHandler: +class ConfData(ABC): + """Base class for configuration wrapper objects that populate from raw sources.""" + + @abstractmethod + def populate_from_dict(self, data: dict[str, object]) -> None: + """Populate from a dictionary (JSON source). + + Args: + data: Dictionary data from JSON configuration file. + + Raises: + ConfTypeNotSupported: If this handler doesn't support JSON sources. + """ + raise ConfTypeNotSupported(ConfTypes.JsonFileName, self.__class__.__name__) + + @abstractmethod + def populate_from_pbany(self, pbany_data: object) -> None: + """Populate from a Protobuf Any message. + + Args: + pbany_data: Protobuf Any message. + + Raises: + ConfTypeNotSupported: If this handler doesn't support Protobuf sources. + """ + raise ConfTypeNotSupported(ConfTypes.ProtobufAny, self.__class__.__name__) + + +ConfDataType = TypeVar("ConfDataType", bound=ConfData) + + +class ConfHandler(Generic[ConfDataType]): """Handler for loading and parsing DRUNC configurations. - Supports multiple configuration types including JSON files, Protobuf messages, - and OKS. + Generic over a ConfDataType that wraps the parsed configuration. + Supports multiple configuration sources via from_* classmethods. """ - def __init__( - self, - data: object = None, - type: ConfTypes = ConfTypes.PyObject, - oks_key: OKSKey | None = None, - *args: object, - **kwargs: object, - ) -> None: - """Initialize a ConfHandler. + confdata_cls: type[ConfDataType] + data: ConfDataType + type: ConfTypes + oks_key: OKSKey | None + class_name: str + log: logging.Logger + root_id: int + controller_id: int + process_id: int + process_id_infra: int + session_name: str | None + initial_data: object + oks_path: str + db: object + + @classmethod + def from_pyobject(cls, data: object, session_name: str | None = None) -> Self: + """Create handler from a Python object. + + Args: + data: The configuration object (typically OKS DAL). + session_name: Optional session name. + + Returns: + Self: Initialized handler instance. + """ + instance = cls.__new__(cls) + instance._init_common(session_name) + instance.initial_data = data + instance.data = cast(ConfDataType, data) + instance.type = ConfTypes.PyObject + instance._post_process_oks() + return instance + + @classmethod + def from_pbany(cls, data: object, session_name: str | None = None) -> Self: + """Create handler from a Protobuf Any message. + + Args: + data: The Protobuf Any message. + session_name: Optional session name. + + Returns: + Self: Initialized handler instance. + + Raises: + ConfTypeNotSupported: If subclass doesn't override parsing. + """ + instance = cls.__new__(cls) + instance._init_common(session_name) + instance.initial_data = data + instance.data = instance._new_conf_data() + instance.data.populate_from_pbany(data) + instance.type = ConfTypes.PyObject + instance._post_process_oks() + return instance + + @classmethod + def from_json(cls, path: str, session_name: str | None = None) -> Self: + """Create handler from a JSON file. Args: - data: The configuration data. Defaults to None. - type: The configuration type. Defaults to PyObject. - oks_key: OKS key if using OKS configuration. Defaults to None. - *args: Additional positional arguments. - **kwargs: Additional keyword arguments. + path: Path to JSON configuration file. + session_name: Optional session name. + + Returns: + Self: Initialized handler instance. Raises: - DruncSetupException: If OKS type is used without an OKS key. + DruncSetupException: If file not found. + ConfTypeNotSupported: If subclass doesn't override parsing. + """ + instance = cls.__new__(cls) + instance._init_common(session_name) + instance.initial_data = path + resolved = expand_path(path, True) + if not os.path.exists(expand_path(path)): + raise DruncSetupException(f"Location {resolved} ({path}) is empty!") + with open(resolved) as f: + json_data = json.loads(f.read()) + instance.data = instance._new_conf_data() + instance.data.populate_from_dict(cast(dict[str, object], json_data)) + instance.type = ConfTypes.PyObject + instance._post_process_oks() + return instance + + @classmethod + def from_oks( + cls, + url: str, + oks_key: OKSKey, + session_name: str | None = None, + ) -> Self: + """Create handler from OKS configuration. + + Args: + url: OKS database path. + oks_key: Key to identify the object in OKS. + session_name: Optional session name. + + Returns: + Self: Initialized handler instance. + """ + instance = cls.__new__(cls) + instance._init_common(session_name) + instance.initial_data = url + instance.oks_key = oks_key + instance.data = cast(ConfDataType, instance._parse_oks_file(url)) + instance.type = ConfTypes.PyObject + instance._post_process_oks() + return instance + + def _init_common(self, session_name: str | None = None) -> None: + """Initialize common attributes. + + Args: + session_name: Optional session name. """ self.class_name = self.__class__.__name__ self.log = get_logger("utils." + self.class_name) - self.initial_type = type - self.initial_data = data self.root_id = 0 self.controller_id = 0 self.process_id = 0 self.process_id_infra = 0 - self.session_name = kwargs.get("session_name") - - if type == ConfTypes.OKSFileName and oks_key is None: - raise DruncSetupException("Need to provide a key for the OKS file") - - self.oks_key = oks_key - self.validate_and_parse_configuration_location(*args, **kwargs) + self.session_name = session_name + self.oks_key = None + self.type = ConfTypes.Unknown - def get_data(self) -> object: + def get_data(self) -> ConfDataType: """Get the configuration data. Returns: - Any: The stored configuration data. + ConfDataType: The stored configuration data. """ return self.data @@ -183,7 +308,7 @@ def get_data_broadcaster(self) -> object: """Get the broadcaster from the configuration data. Returns: - Any: The broadcaster object. + object: The broadcaster object. """ return cast(_ConfigurationData, self.get_data()).broadcaster @@ -191,7 +316,7 @@ def get_data_authoriser(self) -> object: """Get the authoriser from the configuration data. Returns: - Any: The authoriser object. + object: The authoriser object. """ return cast(_ConfigurationData, self.get_data()).authoriser @@ -204,6 +329,17 @@ def copy_oks_key(self) -> OKSKey | None: return self.oks_key def _parse_oks_file(self, oks_path: str) -> object: + """Parse OKS configuration file. + + Args: + oks_path: Path to OKS database. + + Returns: + object: The parsed DAL object. + + Raises: + DruncSetupException: If OKS setup or parameters are missing. + """ try: self.oks_path = oks_path self.log.debug(f"Using {self.oks_path} to configure") @@ -223,54 +359,19 @@ def _parse_oks_file(self, oks_path: str) -> object: "OKS params where not passed to this ConfigurationHandler, cannot parse OKS configurations" ) from e - def _post_process_oks(self, *args: object, **kwargs: object) -> None: - pass + def _post_process_oks(self) -> None: + """Post-process configuration after loading. - def _parse_pbany(self, pbany_data: object) -> object: - raise ConfTypeNotSupported(ConfTypes.ProtobufAny, self.class_name) + Override in subclasses to perform custom initialization. + """ + pass - def _parse_dict(self, data: dict[str, object]) -> object: - raise ConfTypeNotSupported(ConfTypes.JsonFileName, self.class_name) + def _new_conf_data(self) -> ConfDataType: + """Create a new instance of the configuration data wrapper. - def validate_and_parse_configuration_location( - self, *args: object, **kwargs: object - ) -> None: - """Validate and parse the configuration from the provided location. + Must be overridden by subclasses that use from_json or from_pbany. - Supports JsonFileName, OKSFileName, and PyObject types. - - Args: - *args: Additional positional arguments. - **kwargs: Additional keyword arguments. + Returns: + ConfDataType: A new empty instance ready for population. """ - match self.initial_type: - case ConfTypes.PyObject: - self.data = self.initial_data - self.type = self.initial_type - self._post_process_oks(*args, **kwargs) - - case ConfTypes.JsonFileName: - resolved = expand_path(cast(str, self.initial_data), True) - if not os.path.exists(expand_path(cast(str, self.initial_data))): - raise DruncSetupException( - f"Location {resolved} ({self.initial_data}) is empty!" - ) - - with open(resolved) as f: - data = json.loads(f.read()) - self.data = self._parse_dict(data) - self.type = ConfTypes.PyObject - self._post_process_oks(*args, **kwargs) - - case ConfTypes.OKSFileName: - self.data = self._parse_oks_file(cast(str, self.initial_data)) - self.type = ConfTypes.PyObject - self._post_process_oks(*args, **kwargs) - - case ConfTypes.ProtobufAny: - self.data = self._parse_pbany(self.initial_data) - self.type = ConfTypes.PyObject - self._post_process_oks(*args, **kwargs) - - case _: - raise ConfTypeNotSupported(self.initial_type, self.class_name) + return self.confdata_cls() diff --git a/tests/issues/test_issue309.py b/tests/issues/test_issue309.py index dbd027628..1d1b3082c 100644 --- a/tests/issues/test_issue309.py +++ b/tests/issues/test_issue309.py @@ -6,14 +6,13 @@ def test_issue309(load_test_config): get_root_logger("INFO") - from drunc.utils.configuration import OKSKey, parse_conf_url + from drunc.utils.configuration import OKSKey - conf_path, conf_type = parse_conf_url("oksconflibs:deep-segments-config.data.xml") + conf_path = "oksconflibs:deep-segments-config.data.xml" controller_id = "controller-3" - controller_configuration = ControllerConfHandler( - type=conf_type, - data=conf_path, + controller_configuration = ControllerConfHandler.from_oks( + url=conf_path, oks_key=OKSKey( schema_file="schema/confmodel/dunedaq.schema.xml", class_name="RCApplication", diff --git a/tests/issues/test_issue363.py b/tests/issues/test_issue363.py index 02e2ec088..a8fca2317 100644 --- a/tests/issues/test_issue363.py +++ b/tests/issues/test_issue363.py @@ -1,18 +1,17 @@ # https://github.com/DUNE-DAQ/drunc/issues/363 from drunc.controller.configuration import ControllerConfHandler -from drunc.utils.configuration import OKSKey, parse_conf_url +from drunc.utils.configuration import OKSKey from drunc.utils.utils import get_root_logger def test_issue363(load_test_config): get_root_logger("INFO") - conf_path, conf_type = parse_conf_url("oksconflibs:nestedConfig.data.xml") + conf_path = "oksconflibs:nestedConfig.data.xml" controller_id = "nested-segment-controller" - controller_configuration = ControllerConfHandler( - type=conf_type, - data=conf_path, + controller_configuration = ControllerConfHandler.from_oks( + url=conf_path, oks_key=OKSKey( schema_file="schema/confmodel/dunedaq.schema.xml", class_name="RCApplication", From 536af2465a05becd9e51932ff5427a5a02ebf589 Mon Sep 17 00:00:00 2001 From: Aurash Karimi Date: Wed, 17 Jun 2026 17:53:56 +0100 Subject: [PATCH 25/73] clean up confhandler --- src/drunc/process_manager/process_manager.py | 12 +- src/drunc/utils/configuration.py | 142 ++++--------------- 2 files changed, 35 insertions(+), 119 deletions(-) diff --git a/src/drunc/process_manager/process_manager.py b/src/drunc/process_manager/process_manager.py index fe4e2de1b..df54ef872 100644 --- a/src/drunc/process_manager/process_manager.py +++ b/src/drunc/process_manager/process_manager.py @@ -60,7 +60,7 @@ def __init__( super().__init__() self.log = get_logger( - f"process_manager.{configuration.get_data_type_name()}_process_manager", + f"process_manager.{configuration.data.type.name}_process_manager", ) self.log.debug(pid_info_str()) self.log.debug("Initialized ProcessManager") @@ -83,13 +83,11 @@ def __init__( self._create_broadcast_service(self.name, self.session) dach = DummyAuthoriserConfHandler.from_pyobject( - data=self.configuration.get_data_authoriser() + data=self.configuration.data.authoriser ) - self.opmon_publisher = getattr( - self.configuration.get_data(), "opmon_publisher", None - ) - interval_s = getattr(self.configuration.get_data(), "interval_s", 10.0) + self.opmon_publisher = getattr(self.configuration.data, "opmon_publisher", None) + interval_s = getattr(self.configuration.data, "interval_s", 10.0) self.authoriser = DummyAuthoriser(dach, SystemType.PROCESS_MANAGER) self.process_store = {} # dict[str, sh.RunningCommand] # str = uuid @@ -169,7 +167,7 @@ def get_log_path(self): def _create_broadcast_service(self, name, session): bsch = BroadcastSenderConfHandler.from_pyobject( - data=self.configuration.get_data_broadcaster() + data=self.configuration.data.broadcaster ) self.broadcast_service = ( diff --git a/src/drunc/utils/configuration.py b/src/drunc/utils/configuration.py index d3ce35918..97fff40c0 100644 --- a/src/drunc/utils/configuration.py +++ b/src/drunc/utils/configuration.py @@ -5,7 +5,7 @@ import os from abc import ABC, abstractmethod from enum import Enum -from typing import Generic, Protocol, Self, TypeVar, cast +from typing import Generic, Type, TypeVar, cast import conffwk @@ -112,16 +112,6 @@ def __init__( self.session = session -class _DataTypeName(Protocol): - _name_: str - - -class _ConfigurationData(Protocol): - type: _DataTypeName - broadcaster: object - authoriser: object - - class ConfData(ABC): """Base class for configuration wrapper objects that populate from raw sources.""" @@ -160,7 +150,7 @@ class ConfHandler(Generic[ConfDataType]): Supports multiple configuration sources via from_* classmethods. """ - confdata_cls: type[ConfDataType] + confdata_cls: Type[ConfDataType] data: ConfDataType type: ConfTypes oks_key: OKSKey | None @@ -176,17 +166,12 @@ class ConfHandler(Generic[ConfDataType]): db: object @classmethod - def from_pyobject(cls, data: object, session_name: str | None = None) -> Self: - """Create handler from a Python object. - - Args: - data: The configuration object (typically OKS DAL). - session_name: Optional session name. - - Returns: - Self: Initialized handler instance. - """ - instance = cls.__new__(cls) + def from_pyobject( + cls, data: object, session_name: str | None = None + ) -> "ConfHandler[ConfDataType]": + instance: ConfHandler[ConfDataType] = cast( + ConfHandler[ConfDataType], cls.__new__(cls) + ) instance._init_common(session_name) instance.initial_data = data instance.data = cast(ConfDataType, data) @@ -195,55 +180,38 @@ def from_pyobject(cls, data: object, session_name: str | None = None) -> Self: return instance @classmethod - def from_pbany(cls, data: object, session_name: str | None = None) -> Self: - """Create handler from a Protobuf Any message. - - Args: - data: The Protobuf Any message. - session_name: Optional session name. - - Returns: - Self: Initialized handler instance. - - Raises: - ConfTypeNotSupported: If subclass doesn't override parsing. - """ - instance = cls.__new__(cls) + def from_pbany( + cls, data: object, session_name: str | None = None + ) -> "ConfHandler[ConfDataType]": + instance: ConfHandler[ConfDataType] = cast( + ConfHandler[ConfDataType], cls.__new__(cls) + ) instance._init_common(session_name) instance.initial_data = data - instance.data = instance._new_conf_data() + instance.data = cls.confdata_cls() instance.data.populate_from_pbany(data) instance.type = ConfTypes.PyObject instance._post_process_oks() return instance @classmethod - def from_json(cls, path: str, session_name: str | None = None) -> Self: - """Create handler from a JSON file. - - Args: - path: Path to JSON configuration file. - session_name: Optional session name. - - Returns: - Self: Initialized handler instance. - - Raises: - DruncSetupException: If file not found. - ConfTypeNotSupported: If subclass doesn't override parsing. - """ - instance = cls.__new__(cls) + def from_json( + cls, path: str, session_name: str | None = None + ) -> "ConfHandler[ConfDataType]": + instance: ConfHandler[ConfDataType] = cast( + ConfHandler[ConfDataType], cls.__new__(cls) + ) instance._init_common(session_name) instance.initial_data = path resolved = expand_path(path, True) if not os.path.exists(expand_path(path)): raise DruncSetupException(f"Location {resolved} ({path}) is empty!") with open(resolved) as f: - json_data = json.loads(f.read()) - instance.data = instance._new_conf_data() - instance.data.populate_from_dict(cast(dict[str, object], json_data)) - instance.type = ConfTypes.PyObject - instance._post_process_oks() + json_data = json.load(f) + instance.data = cls.confdata_cls() + instance.data.populate_from_dict(cast(dict[str, object], json_data)) + instance.type = ConfTypes.PyObject + instance._post_process_oks() return instance @classmethod @@ -252,18 +220,10 @@ def from_oks( url: str, oks_key: OKSKey, session_name: str | None = None, - ) -> Self: - """Create handler from OKS configuration. - - Args: - url: OKS database path. - oks_key: Key to identify the object in OKS. - session_name: Optional session name. - - Returns: - Self: Initialized handler instance. - """ - instance = cls.__new__(cls) + ) -> "ConfHandler[ConfDataType]": + instance: ConfHandler[ConfDataType] = cast( + ConfHandler[ConfDataType], cls.__new__(cls) + ) instance._init_common(session_name) instance.initial_data = url instance.oks_key = oks_key @@ -288,38 +248,6 @@ def _init_common(self, session_name: str | None = None) -> None: self.oks_key = None self.type = ConfTypes.Unknown - def get_data(self) -> ConfDataType: - """Get the configuration data. - - Returns: - ConfDataType: The stored configuration data. - """ - return self.data - - def get_data_type_name(self) -> str: - """Get the type name of the configuration data. - - Returns: - str: The name of the data type. - """ - return str(cast(_ConfigurationData, self.get_data()).type._name_) - - def get_data_broadcaster(self) -> object: - """Get the broadcaster from the configuration data. - - Returns: - object: The broadcaster object. - """ - return cast(_ConfigurationData, self.get_data()).broadcaster - - def get_data_authoriser(self) -> object: - """Get the authoriser from the configuration data. - - Returns: - object: The authoriser object. - """ - return cast(_ConfigurationData, self.get_data()).authoriser - def copy_oks_key(self) -> OKSKey | None: """Get a copy of the OKS key if one exists. @@ -365,13 +293,3 @@ def _post_process_oks(self) -> None: Override in subclasses to perform custom initialization. """ pass - - def _new_conf_data(self) -> ConfDataType: - """Create a new instance of the configuration data wrapper. - - Must be overridden by subclasses that use from_json or from_pbany. - - Returns: - ConfDataType: A new empty instance ready for population. - """ - return self.confdata_cls() From befdc86c0bc9accdbd06509d7ae29545673db304 Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Thu, 18 Jun 2026 13:34:09 +0200 Subject: [PATCH 26/73] Addressing Ruff issues --- src/drunc/controller/interface/context.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/drunc/controller/interface/context.py b/src/drunc/controller/interface/context.py index e1cd6c727..713bcc7e6 100644 --- a/src/drunc/controller/interface/context.py +++ b/src/drunc/controller/interface/context.py @@ -30,12 +30,6 @@ def create_drivers(self, **kwargs) -> Mapping[str, object]: def create_token(self, **kwargs) -> Token: return create_dummy_token_from_uname() - def start_listening_controller(self, broadcaster_conf): - bcch = BroadcastClientConfHandler( - data=broadcaster_conf, type=ConfTypes.ProtobufAny - ) - self.status_receiver = BroadcastHandler(broadcast_configuration=bcch) - def get_endpoint_display_host_overrides(self) -> dict[str, str]: """ Return display hostname overrides for status-table rendering. From d84a015538e94a139bc8355ccf41ee5175016928 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 18 Jun 2026 15:18:27 -0500 Subject: [PATCH 27/73] JCF: move the integration tests into the installed codebase --- {integtest => src/drunc/integtest}/integ_test_utils.py | 0 {integtest => src/drunc/integtest}/process_manager_test.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename {integtest => src/drunc/integtest}/integ_test_utils.py (100%) rename {integtest => src/drunc/integtest}/process_manager_test.py (100%) diff --git a/integtest/integ_test_utils.py b/src/drunc/integtest/integ_test_utils.py similarity index 100% rename from integtest/integ_test_utils.py rename to src/drunc/integtest/integ_test_utils.py diff --git a/integtest/process_manager_test.py b/src/drunc/integtest/process_manager_test.py similarity index 100% rename from integtest/process_manager_test.py rename to src/drunc/integtest/process_manager_test.py From bee92065aa48afeb6e3f07993e7eae16ccc6c753 Mon Sep 17 00:00:00 2001 From: Aurash Karimi Date: Fri, 19 Jun 2026 16:30:24 +0100 Subject: [PATCH 28/73] remove confdata entirely --- src/drunc/authoriser/configuration.py | 26 +---- .../broadcast/client/broadcast_handler.py | 2 +- src/drunc/broadcast/client/configuration.py | 46 ++++----- .../client/kafka_stdout_broadcast_handler.py | 4 +- .../broadcast/server/broadcast_sender.py | 4 +- src/drunc/broadcast/server/configuration.py | 52 ++++------ .../children_interface/grpc_child.py | 49 ++-------- .../children_interface/rest_api_child.py | 68 +++++-------- src/drunc/controller/configuration.py | 58 +++-------- src/drunc/controller/controller.py | 6 +- src/drunc/controller/utils.py | 13 ++- src/drunc/fsm/configuration.py | 64 ++++-------- src/drunc/process_manager/configuration.py | 46 +++------ .../interface/process_manager.py | 4 +- .../process_manager/k8s_process_manager.py | 6 +- src/drunc/process_manager/process_manager.py | 24 +++-- .../process_manager/ssh_process_manager.py | 16 ++- src/drunc/process_manager/utils.py | 2 +- src/drunc/session_manager/configuration.py | 26 +---- .../interface/session_manager.py | 7 +- src/drunc/unified_shell/shell.py | 4 +- src/drunc/utils/configuration.py | 98 +++++++------------ tests/issues/test_issue309.py | 2 +- tests/issues/test_issue363.py | 4 +- 24 files changed, 206 insertions(+), 425 deletions(-) diff --git a/src/drunc/authoriser/configuration.py b/src/drunc/authoriser/configuration.py index 25356ca1e..49f20ee2f 100644 --- a/src/drunc/authoriser/configuration.py +++ b/src/drunc/authoriser/configuration.py @@ -1,28 +1,8 @@ -from drunc.utils.configuration import ( - ConfData, - ConfHandler, - ConfTypeNotSupported, - ConfTypes, -) +from drunc.utils.configuration import ConfHandler -class DummyAuthoriserConfData(ConfData): - """Wrapper for dummy authoriser configuration data.""" - - def __init__(self) -> None: - pass +class DummyAuthoriserConfHandler(ConfHandler): + """Handler for dummy authoriser configuration.""" def populate_from_dict(self, data: dict[str, object]) -> None: - """Populate from dictionary data.""" - # Dummy authoriser has no configuration requirements pass - - def populate_from_pbany(self, pbany_data: object) -> None: - """Populate from Protobuf Any message.""" - raise ConfTypeNotSupported(ConfTypes.ProtobufAny, self.__class__.__name__) - - -class DummyAuthoriserConfHandler(ConfHandler[DummyAuthoriserConfData]): - """Handler for dummy authoriser configuration.""" - - confdata_cls = DummyAuthoriserConfData diff --git a/src/drunc/broadcast/client/broadcast_handler.py b/src/drunc/broadcast/client/broadcast_handler.py index 1f144f582..32b267bbe 100644 --- a/src/drunc/broadcast/client/broadcast_handler.py +++ b/src/drunc/broadcast/client/broadcast_handler.py @@ -13,7 +13,7 @@ def __init__(self, broadcast_configuration: BroadcastClientConfHandler): self.configuration = broadcast_configuration self.implementation = None - match self.configuration.data.type: + match self.configuration.type: # Being a bit sloppy here, having a Kafka sender doesn't mean we want to dump everything to stdout # There could be cases where we want to do other things. # For now, 1 server type <-> 1 client type... diff --git a/src/drunc/broadcast/client/configuration.py b/src/drunc/broadcast/client/configuration.py index 9f8d53ca9..c86c9377c 100644 --- a/src/drunc/broadcast/client/configuration.py +++ b/src/drunc/broadcast/client/configuration.py @@ -1,24 +1,16 @@ from drunc.broadcast.types import BroadcastTypes from drunc.exceptions import DruncSetupException -from drunc.utils.configuration import ConfData, ConfHandler +from drunc.utils.configuration import ConfHandler from drunc.utils.grpc_utils import UnpackingError, unpack_any -class BroadcastClientConfData(ConfData): - """Wrapper for broadcast client configuration data.""" - - def __init__( - self, - type: BroadcastTypes | None = None, - address: str | None = None, - topic: str | None = None, - ) -> None: - self.type = type - self.address = address - self.topic = topic +class BroadcastClientConfHandler(ConfHandler): + """Handler for broadcast client configuration.""" def populate_from_dict(self, data: dict[str, object]) -> None: - """Populate from dictionary data.""" + self.type: BroadcastTypes | None = None + self.address: str | None = None + self.topic: str | None = None if not data: return self.type = BroadcastTypes.Kafka @@ -26,13 +18,12 @@ def populate_from_dict(self, data: dict[str, object]) -> None: self.topic = data.get("topic") def populate_from_pbany(self, pbany_data: object) -> None: - """Populate from Protobuf Any message.""" from druncschema.broadcast_pb2 import KafkaBroadcastHandlerConfiguration + self.type = None + self.address = None + self.topic = None if not pbany_data.ByteSize(): - self.type = None - self.address = None - self.topic = None return try: data = unpack_any(pbany_data, KafkaBroadcastHandlerConfiguration) @@ -45,11 +36,16 @@ def populate_from_pbany(self, pbany_data: object) -> None: e, ) + def _post_process_oks(self) -> None: + raw = self._raw_data + if raw is not None: + self.type = getattr(raw, "type", None) + self.address = getattr(raw, "address", None) + self.topic = getattr(raw, "topic", None) + elif not hasattr(self, "type"): + self.type = None + self.address = None + self.topic = None -class BroadcastClientConfHandler(ConfHandler[BroadcastClientConfData]): - """Handler for broadcast client configuration.""" - - confdata_cls = BroadcastClientConfData - - def get_impl_technology(self): - return self.data.type + def get_impl_technology(self) -> BroadcastTypes | None: + return self.type diff --git a/src/drunc/broadcast/client/kafka_stdout_broadcast_handler.py b/src/drunc/broadcast/client/kafka_stdout_broadcast_handler.py index 36022ee96..1b26e8c02 100644 --- a/src/drunc/broadcast/client/kafka_stdout_broadcast_handler.py +++ b/src/drunc/broadcast/client/kafka_stdout_broadcast_handler.py @@ -21,8 +21,8 @@ def __init__(self, message_format, conf): # if 'broadcast_types_loglevels' in self.global_kafka_stdout_conf: # self.broadcast_types_loglevels.update(self.global_kafka_stdout_conf['broadcast_types_loglevels']) - self.kafka_address = self.conf.data.address - self.topic = self.conf.data.topic + self.kafka_address = self.conf.address + self.topic = self.conf.topic # self.broadcast_types_loglevels.update(conf.data.get('broadcast_types_loglevels', {})) diff --git a/src/drunc/broadcast/server/broadcast_sender.py b/src/drunc/broadcast/server/broadcast_sender.py index f138a3f5a..d934e64bf 100644 --- a/src/drunc/broadcast/server/broadcast_sender.py +++ b/src/drunc/broadcast/server/broadcast_sender.py @@ -46,8 +46,8 @@ def __init__( from drunc.broadcast.server.kafka_sender import KafkaSender self.implementation = KafkaSender( - self.configuration.data.address, - self.configuration.data.publish_timeout, + self.configuration.address, + self.configuration.publish_timeout, topic=f"control.{self.identifier}", ) case _: diff --git a/src/drunc/broadcast/server/configuration.py b/src/drunc/broadcast/server/configuration.py index 378b47df9..3a0ec3a3f 100644 --- a/src/drunc/broadcast/server/configuration.py +++ b/src/drunc/broadcast/server/configuration.py @@ -1,9 +1,4 @@ -from drunc.utils.configuration import ( - ConfData, - ConfHandler, - ConfTypeNotSupported, - ConfTypes, -) +from drunc.utils.configuration import ConfHandler class KafkaBroadcastSenderConfData: @@ -22,38 +17,33 @@ def from_dict(data: dict): ) -class BroadcastSenderConfData(ConfData): - """Wrapper for broadcast sender configuration data.""" - - def __init__(self): - self.kafka_data = None +class BroadcastSenderConfHandler(ConfHandler): + """Handler for broadcast sender configuration.""" def populate_from_dict(self, data: dict[str, object]) -> None: - """Populate from dictionary data.""" if data == {}: - self.kafka_data = None + self.address = None + self.publish_timeout = None else: - self.kafka_data = KafkaBroadcastSenderConfData.from_dict(data) - - def populate_from_pbany(self, pbany_data: object) -> None: - """Populate from Protobuf Any message.""" - raise ConfTypeNotSupported(ConfTypes.ProtobufAny, self.__class__.__name__) - + kafka_data = KafkaBroadcastSenderConfData.from_dict(data) + self.address = kafka_data.address + self.publish_timeout = kafka_data.publish_timeout -class BroadcastSenderConfHandler(ConfHandler[BroadcastSenderConfData]): - """Handler for broadcast sender configuration.""" - - confdata_cls = BroadcastSenderConfData - - def _post_process_oks(self): + def _post_process_oks(self) -> None: from drunc.broadcast.types import BroadcastTypes - # Normalize wrapped JSON-loaded data to the runtime shape used by sender code. - if hasattr(self.data, "kafka_data"): - self.data = self.data.kafka_data - - self.impl_technology = BroadcastTypes.Kafka if self.data else None - self.log.debug(self.data) + raw = self._raw_data + if raw is not None: + # OKS/pyobject path: raw is the broadcaster OKS object (or None if no broadcaster) + self.address = getattr(raw, "address", None) + self.publish_timeout = getattr(raw, "publish_timeout", None) + elif not hasattr(self, "address"): + # Neither OKS nor JSON populated — should not happen in practice + self.address = None + self.publish_timeout = None + + self.impl_technology = BroadcastTypes.Kafka if self.address else None + self.log.debug(self.address) def get_impl_technology(self): return self.impl_technology diff --git a/src/drunc/controller/children_interface/grpc_child.py b/src/drunc/controller/children_interface/grpc_child.py index 518ae4229..c38621867 100644 --- a/src/drunc/controller/children_interface/grpc_child.py +++ b/src/drunc/controller/children_interface/grpc_child.py @@ -43,12 +43,7 @@ from drunc.controller.children_interface.child_node import ChildNode from drunc.exceptions import DruncSetupException from drunc.grpc_settings import CONTROLLER_CLIENT_GRPC_CONFIG -from drunc.utils.configuration import ( - ConfData, - ConfHandler, - ConfTypeNotSupported, - ConfTypes, -) +from drunc.utils.configuration import ConfHandler from drunc.utils.grpc_utils import ( ServerUnreachable, rethrow_if_unreachable_server, @@ -60,46 +55,18 @@ ) -class gRCPChildConfData(ConfData): - """Wrapper for gRPC child node configuration.""" - - def __init__(self) -> None: - class id_able: - id = None - - class cler: - pass - - self.controller = cler() - self.controller.id = None - self.controller.exposes_service = [] - - class runs_on_obj: - id = None - - self.controller.runs_on = runs_on_obj() - self.controller.runs_on.runs_on = runs_on_obj() - - def populate_from_dict(self, data: dict[str, object]) -> None: - """Populate from dictionary data.""" - raise ConfTypeNotSupported(ConfTypes.JsonFileName, self.__class__.__name__) - - def populate_from_pbany(self, pbany_data: object) -> None: - """Populate from Protobuf Any message.""" - raise ConfTypeNotSupported(ConfTypes.ProtobufAny, self.__class__.__name__) - - -class gRCPChildConfHandler(ConfHandler[gRCPChildConfData]): +class gRCPChildConfHandler(ConfHandler): """Handler for gRPC child node configuration.""" - confdata_cls = gRCPChildConfData + def _post_process_oks(self) -> None: + self.controller = self._raw_data.controller def get_uri(self): - for service in self.data.controller.exposes_service: - if self.data.controller.id + "_control" in service.id: - return f"{service.protocol}://{self.data.controller.runs_on.runs_on.id}:{service.port}" + for service in self.controller.exposes_service: + if self.controller.id + "_control" in service.id: + return f"{service.protocol}://{self.controller.runs_on.runs_on.id}:{service.port}" raise DruncSetupException( - f"gRPC API child node {self.data.controller.id} does not expose a control service" + f"gRPC API child node {self.controller.id} does not expose a control service" ) diff --git a/src/drunc/controller/children_interface/rest_api_child.py b/src/drunc/controller/children_interface/rest_api_child.py index b0f42cc71..aa28a5951 100644 --- a/src/drunc/controller/children_interface/rest_api_child.py +++ b/src/drunc/controller/children_interface/rest_api_child.py @@ -39,12 +39,7 @@ from drunc.exceptions import DruncException, DruncSetupException from drunc.fsm.configuration import FSMConfHandler from drunc.fsm.core import FSM, FSMDestinationResult, FSMDestinationType -from drunc.utils.configuration import ( - ConfData, - ConfHandler, - ConfTypeNotSupported, - ConfTypes, -) +from drunc.utils.configuration import ConfHandler from drunc.utils.flask_manager import FlaskManager from drunc.utils.utils import ( ControlType, @@ -359,39 +354,25 @@ def check_response(self, timeout: int = 0) -> dict: """ -class RESTAPIChildNodeConfData(ConfData): - """Wrapper for REST API child node configuration.""" - - def __init__(self) -> None: - self.id = None - self.exposes_service = [] - - class runs_on_obj: - id = None - - self.runs_on = runs_on_obj() - self.runs_on.runs_on = runs_on_obj() - - def populate_from_dict(self, data: dict[str, object]) -> None: - """Populate from dictionary data.""" - raise ConfTypeNotSupported(ConfTypes.JsonFileName, self.__class__.__name__) - - def populate_from_pbany(self, pbany_data: object) -> None: - """Populate from Protobuf Any message.""" - raise ConfTypeNotSupported(ConfTypes.ProtobufAny, self.__class__.__name__) - - -class RESTAPIChildNodeConfHandler(ConfHandler[RESTAPIChildNodeConfData]): +class RESTAPIChildNodeConfHandler(ConfHandler): """Handler for REST API child node configuration.""" - confdata_cls = RESTAPIChildNodeConfData + def _post_process_oks(self) -> None: + raw = self._raw_data + # Flatten attributes that are always accessed from outside + self.id = getattr(raw, "id", None) + self.exposes_service = getattr(raw, "exposes_service", []) + self.runs_on = getattr(raw, "runs_on", None) + self.proxy = getattr(raw, "proxy", [None, None]) + # Pass through any other attributes callers may inspect dynamically + self._raw = raw def get_host_port(self): - for service in self.data.exposes_service: - if self.data.id + "_control" in service.id: - return self.data.runs_on.runs_on.id, service.port + for service in self.exposes_service: + if self.id + "_control" in service.id: + return self.runs_on.runs_on.id, service.port raise DruncSetupException( - f"REST API child node {self.data.id} does not expose a control service" + f"REST API child node {self.id} does not expose a control service" ) @@ -423,7 +404,7 @@ def __init__( f"Application {name} does not expose a control service in the configuration, or has not advertised itself to the application registry service, or the application registry service is not reachable." ) - proxy_host, proxy_port = getattr(self.configuration.data, "proxy", [None, None]) + proxy_host, proxy_port = self.configuration.proxy proxy_port = int(proxy_port) if proxy_port is not None else None self.commander = AppCommander( @@ -573,16 +554,15 @@ def describe( if self.configuration is not None: if detector_name := get_detector_name(self.configuration): description.info = detector_name - if hasattr( - self.configuration.data, "application_name" - ): # Application nodes. - description.type = self.configuration.data.application_name - description.name = self.configuration.data.id - elif hasattr(self.configuration.data, "controller") and hasattr( - self.configuration.data.controller, "application_name" + raw = self.configuration._raw + if hasattr(raw, "application_name"): # Application nodes. + description.type = raw.application_name + description.name = raw.id + elif hasattr(raw, "controller") and hasattr( + raw.controller, "application_name" ): # Controller nodes. - description.type = self.configuration.data.controller.application_name - description.name = self.configuration.data.controller.id + description.type = raw.controller.application_name + description.name = raw.controller.id response.description.CopyFrom(description) diff --git a/src/drunc/controller/configuration.py b/src/drunc/controller/configuration.py index ed0d2e5f6..6d3c93513 100644 --- a/src/drunc/controller/configuration.py +++ b/src/drunc/controller/configuration.py @@ -21,12 +21,7 @@ ) from drunc.exceptions import DruncCommandException, DruncSetupException from drunc.process_manager.configuration import get_commandline_parameters -from drunc.utils.configuration import ( - ConfData, - ConfHandler, - ConfTypeNotSupported, - ConfTypes, -) +from drunc.utils.configuration import ConfHandler from drunc.utils.utils import ( ControlType, get_control_type_and_uri_from_cli, @@ -35,35 +30,9 @@ ) -class ControllerConfData(ConfData): - """Wrapper for controller configuration data from OKS segment.""" - - def __init__(self) -> None: - class id_able: - id = None - - class cler: - pass - - self.controller = cler() - self.controller.broadcaster = id_able() - self.controller.fsm = id_able() - self.segments = [] - - def populate_from_dict(self, data: dict[str, object]) -> None: - """Populate from dictionary data.""" - raise ConfTypeNotSupported(ConfTypes.JsonFileName, self.__class__.__name__) - - def populate_from_pbany(self, pbany_data: object) -> None: - """Populate from Protobuf Any message.""" - raise ConfTypeNotSupported(ConfTypes.ProtobufAny, self.__class__.__name__) - - -class ControllerConfHandler(ConfHandler[ControllerConfData]): +class ControllerConfHandler(ConfHandler): """Handler for controller configuration.""" - confdata_cls = ControllerConfData - @staticmethod def find_segment(segment, id_): if segment.controller.id == id_: @@ -89,28 +58,27 @@ def _grab_segment_conf_from_controller(self, configuration): def _post_process_oks(self) -> None: self.authoriser = None - self.data = self._grab_segment_conf_from_controller(self.data) + segment = self._grab_segment_conf_from_controller(self._raw_data) + self.controller = segment.controller + self.segments = segment.segments + self.applications = segment.applications - self.this_host = self.data.controller.runs_on.runs_on.id + self.this_host = self.controller.runs_on.runs_on.id if self.this_host in ["localhost"] or self.this_host.startswith("127."): self.this_host = socket.gethostname() self.opmon_publisher = None self.opmon_conf = parse_opmon_conf( log=self.log, - conf=self.data.controller.opmon_conf, + conf=self.controller.opmon_conf, uri=self.session.opmon_uri, session=self.session_name, - application=self.data.controller.id, + application=self.controller.id, ) if self.opmon_conf.path == "./info.json": self.opmon_conf.path = ( - "./info." - + self.opmon_conf.session - + "." - + self.data.controller.id - + ".json" + "./info." + self.opmon_conf.session + "." + self.controller.id + ".json" ) self.log.debug("Initializing OpMon with configuration %s", self.opmon_conf) @@ -214,13 +182,13 @@ def process_application(app): # threading the children look up threads = [] - for segment in self.data.segments: + for segment in self.segments: self.log.debug(segment) t = threading.Thread(target=process_segment, args=(segment,)) threads.append(t) t.start() - for app in self.data.applications: + for app in self.applications: self.log.debug(app) t = threading.Thread(target=process_application, args=(app,)) threads.append(t) @@ -287,7 +255,7 @@ def child_node_factory( name, restapi_conf_handler, uri, - self.data.controller.fsm, + self.controller.fsm, connectivity_service=connectivity_service, ) diff --git a/src/drunc/controller/controller.py b/src/drunc/controller/controller.py index 9bd2d31ff..d15e17103 100644 --- a/src/drunc/controller/controller.py +++ b/src/drunc/controller/controller.py @@ -110,7 +110,7 @@ def __init__(self, configuration, name: str, session: str, token: Token): self.stop_event: threading.Event | None = None self.thread: threading.Thread | None = None bsch = BroadcastSenderConfHandler.from_pyobject( - data=self.configuration.data.controller.broadcaster, + data=self.configuration.controller.broadcaster, ) self.broadcast_service = BroadcastSender( @@ -120,7 +120,7 @@ def __init__(self, configuration, name: str, session: str, token: Token): ) self.fsm_config = FSMConfHandler.from_pyobject( - data=self.configuration.data.controller.fsm, + data=self.configuration.controller.fsm, ) self.stateful_node = StatefulNode( @@ -245,7 +245,7 @@ def init_controller(self) -> None: log_init_controller.info(f"Taking control of {child.name}") child.take_control(execute_on_all_subsequent_children_in_path=True) - interval_s = getattr(self.configuration.data, "interval_s", 10.0) + interval_s = getattr(self.configuration, "interval_s", 10.0) if self.opmon_publisher is not None: self.stop_event = threading.Event() diff --git a/src/drunc/controller/utils.py b/src/drunc/controller/utils.py index d9c0b9994..82970e6ea 100644 --- a/src/drunc/controller/utils.py +++ b/src/drunc/controller/utils.py @@ -49,17 +49,16 @@ def get_status_message(controller): def get_detector_name(configuration) -> str: detector_name = None log = get_logger("controller.core.get_detector_name") - if hasattr(configuration.data, "contains") and len(configuration.data.contains) > 0: - if len(configuration.data.contains) > 0: + raw = getattr(configuration, "_raw", None) + if raw is not None and hasattr(raw, "contains") and len(raw.contains) > 0: + if len(raw.contains) > 0: log.debug( - f"Application {configuration.data.id} has multiple contains, using the first one" + f"Application {raw.id} has multiple contains, using the first one" ) - detector_name = ( - configuration.data.contains[0].id.replace("-", "_").replace("_", " ") - ) + detector_name = raw.contains[0].id.replace("-", "_").replace("_", " ") else: log.debug( - f'Application {configuration.data.id} has no "contains" relation, hence no detector' + f'Application {getattr(raw, "id", "?")} has no "contains" relation, hence no detector' ) return detector_name diff --git a/src/drunc/fsm/configuration.py b/src/drunc/fsm/configuration.py index 0477c1ae0..16b3890bf 100644 --- a/src/drunc/fsm/configuration.py +++ b/src/drunc/fsm/configuration.py @@ -4,41 +4,13 @@ from drunc.fsm.action_factory import FSMActionFactory from drunc.fsm.core import PreOrPostTransitionSequence from drunc.fsm.transition import Transition -from drunc.utils.configuration import ( - ConfData, - ConfHandler, - ConfTypeNotSupported, - ConfTypes, -) +from drunc.utils.configuration import ConfHandler from drunc.utils.utils import get_logger -class FSMConfData(ConfData): - """Wrapper for FSM configuration data.""" - - def __init__(self) -> None: - self.states = [] - self.initial_state = None - self.actions = [] - self.transitions = [] - self.pre_transitions = [] - self.post_transitions = [] - self.command_sequences = [] - - def populate_from_dict(self, data: dict[str, object]) -> None: - """Populate from dictionary data.""" - raise ConfTypeNotSupported(ConfTypes.JsonFileName, self.__class__.__name__) - - def populate_from_pbany(self, pbany_data: object) -> None: - """Populate from Protobuf Any message.""" - raise ConfTypeNotSupported(ConfTypes.ProtobufAny, self.__class__.__name__) - - -class FSMConfHandler(ConfHandler[FSMConfData]): +class FSMConfHandler(ConfHandler): """Handler for FSM configuration.""" - confdata_cls = FSMConfData - def _fill_pre_post_transition_sequence_oks( self, prefix: str, @@ -100,26 +72,28 @@ def _post_process_oks(self) -> None: Raises: None """ + raw = self._raw_data + # Define the data structures to store the FSM configuration self.log.debug("_post_process_oks configuration") - self.pre_transitions: dict[Transition, PreOrPostTransitionSequence] = {} - self.post_transitions: dict[Transition, PreOrPostTransitionSequence] = {} + self._pre_transitions: dict[Transition, PreOrPostTransitionSequence] = {} + self._post_transitions: dict[Transition, PreOrPostTransitionSequence] = {} self.actions: dict[ str, "conffwk.dal.FSMAction" ] = {} # (e.g. "thread_pinning": thread_pinning FSM action object) self.transitions: list[Transition] = [] self.sequences: list[FSMSequence] = [] - self.states: list[str] = self.data.states - self.initial_state: str = self.data.initial_state + self.states: list[str] = raw.states + self.initial_state: str = raw.initial_state # Fill the actions dictionary with the FSMAction objects corresponding to the # action names defined in the configuration - for action in self.data.actions: # type: 'conffwk.dal.FSMAction' + for action in raw.actions: # type: 'conffwk.dal.FSMAction' self.actions[action.id] = FSMActionFactory.get().get_action( action.id, action ) - for transition in self.data.transitions: # type: 'conffwk.dal.FSMTransition' + for transition in raw.transitions: # type: 'conffwk.dal.FSMTransition' tr = Transition( name=transition.id, source=transition.source, @@ -130,12 +104,12 @@ def _post_process_oks(self) -> None: # Get the pre and post transition sequences for the transition pre_transitions: PreOrPostTransitionSequence = ( self._fill_pre_post_transition_sequence_oks( - "pre", tr, self.data.pre_transitions + "pre", tr, raw.pre_transitions ) ) post_transitions: PreOrPostTransitionSequence = ( self._fill_pre_post_transition_sequence_oks( - "post", tr, self.data.post_transitions + "post", tr, raw.post_transitions ) ) @@ -144,14 +118,14 @@ def _post_process_oks(self) -> None: tr.arguments += post_transitions.get_arguments() # Store the pre and post transition sequences for the transition - self.pre_transitions[tr] = pre_transitions - self.post_transitions[tr] = post_transitions + self._pre_transitions[tr] = pre_transitions + self._post_transitions[tr] = post_transitions # Add the transition to the list of transitions self.transitions += [tr] # Fill the sequences of commands to execute for each transition - for sequence in self.data.command_sequences: + for sequence in raw.command_sequences: seq_id = sequence.id cmd_ids = [cmd.id for cmd in sequence.sequence] self.sequences.append(FSMSequence(id=seq_id, command_ids=cmd_ids)) @@ -160,10 +134,10 @@ def get_actions(self) -> dict[str, "conffwk.dal.FSMAction"]: return self.actions def get_initial_state(self) -> str | None: - return self.data.initial_state + return self.initial_state def get_states(self) -> list[str]: - return self.data.states + return self.states def get_transitions(self) -> list[Transition]: return self.transitions @@ -171,12 +145,12 @@ def get_transitions(self) -> list[Transition]: def get_pre_transitions_sequences( self, ) -> dict[Transition, PreOrPostTransitionSequence]: - return self.pre_transitions + return self._pre_transitions def get_post_transitions_sequences( self, ) -> dict[Transition, PreOrPostTransitionSequence]: - return self.post_transitions + return self._post_transitions def get_sequences(self) -> list[FSMSequence]: return self.sequences diff --git a/src/drunc/process_manager/configuration.py b/src/drunc/process_manager/configuration.py index 2e0bda8d3..994465ed5 100644 --- a/src/drunc/process_manager/configuration.py +++ b/src/drunc/process_manager/configuration.py @@ -15,12 +15,7 @@ from drunc.broadcast.server.configuration import KafkaBroadcastSenderConfData from drunc.exceptions import DruncCommandException from drunc.process_manager.exceptions import UnknownProcessManagerType -from drunc.utils.configuration import ( - ConfData, - ConfHandler, - ConfTypeNotSupported, - ConfTypes, -) +from drunc.utils.configuration import ConfHandler from drunc.utils.utils import get_logger if TYPE_CHECKING: @@ -43,13 +38,15 @@ class ProcessManagerTypes(Enum): SSH_PARAMIKO = 3 -class ProcessManagerConfData(ConfData): - """Wrapper for process manager configuration data.""" +class ProcessManagerConfHandler(ConfHandler): + """Handler for process manager configuration.""" + + log_path: str = "./" - def __init__(self): + def populate_from_dict(self, data: dict[str, object]) -> None: self.broadcaster = None self.authoriser = None - self.type = ProcessManagerTypes.Unknown + self.pm_type = ProcessManagerTypes.Unknown self.command_address = "" self.environment = {} self.settings = {} @@ -57,8 +54,6 @@ def __init__(self): self.opmon_uri = None self.opmon_publisher = None - def populate_from_dict(self, data: dict[str, object]) -> None: - """Populate from dictionary data.""" if data.get("broadcaster"): self.broadcaster = KafkaBroadcastSenderConfData.from_dict( data.get("broadcaster") @@ -70,28 +65,17 @@ def populate_from_dict(self, data: dict[str, object]) -> None: match data["type"].lower(): case "ssh": - self.type = ProcessManagerTypes.SSH_SHELL + self.pm_type = ProcessManagerTypes.SSH_SHELL self.kill_timeout = data.get("kill_timeout", 0.5) case "ssh-paramiko": - self.type = ProcessManagerTypes.SSH_PARAMIKO + self.pm_type = ProcessManagerTypes.SSH_PARAMIKO self.kill_timeout = data.get("kill_timeout", 0.5) case "k8s": - self.type = ProcessManagerTypes.K8s + self.pm_type = ProcessManagerTypes.K8s self.image = data.get("image", "ghcr.io/dune-daq/alma9:latest") case _: raise UnknownProcessManagerType(data["type"]) - def populate_from_pbany(self, pbany_data: object) -> None: - """Populate from Protobuf Any message.""" - raise ConfTypeNotSupported(ConfTypes.ProtobufAny, self.__class__.__name__) - - -class ProcessManagerConfHandler(ConfHandler[ProcessManagerConfData]): - """Handler for process manager configuration.""" - - confdata_cls = ProcessManagerConfData - log_path: str = "./" - @classmethod def from_json( cls, path: str, session_name: str | None = None, log_path: str = "./" @@ -109,9 +93,9 @@ def _post_process_oks(self) -> None: """Post-process to handle OpMon configuration.""" opmon_conf = parse_opmon_conf( log=self.log, - conf=getattr(self.data, "opmon_conf", None), - uri=getattr(self.data, "opmon_uri", None), - session=self.data.type.name, + conf=getattr(self, "opmon_conf", None), + uri=getattr(self, "opmon_uri", None), + session=getattr(self, "pm_type", ProcessManagerTypes.Unknown).name, application="process_manager", ) @@ -125,13 +109,13 @@ def _post_process_oks(self) -> None: ) try: if opmon_conf.opmon_type == "stream": - self.data.opmon_publisher = KafkaOpMonPublisher(opmon_conf) + self.opmon_publisher = KafkaOpMonPublisher(opmon_conf) self.log.debug( "KafkaOpMonPublisher initialized with configuration %s", opmon_conf, ) else: - self.data.opmon_publisher = OpMonPublisher( + self.opmon_publisher = OpMonPublisher( conf=opmon_conf, rich_handler=True ) self.log.debug( diff --git a/src/drunc/process_manager/interface/process_manager.py b/src/drunc/process_manager/interface/process_manager.py index ae85bfe94..eb2fa4e13 100644 --- a/src/drunc/process_manager/interface/process_manager.py +++ b/src/drunc/process_manager/interface/process_manager.py @@ -69,7 +69,7 @@ def run_pm( log_path = get_log_path( user=getpass.getuser(), - session_name=pmch.data.type.name, + session_name=getattr(pmch, "pm_type", pmch.type).name, application_name=appName, override_logs=override_logs, app_log_path=log_path, @@ -78,7 +78,7 @@ def run_pm( # Logger has been added to process_manager, so everything will be logged add_handler(log, HandlerType.File, True, path=log_path) - for key, value in pmch.data.environment.items(): + for key, value in pmch.environment.items(): os.environ[key] = value pm = ProcessManager.get(pmch, name="process_manager") diff --git a/src/drunc/process_manager/k8s_process_manager.py b/src/drunc/process_manager/k8s_process_manager.py index 04116cd61..dce63a454 100644 --- a/src/drunc/process_manager/k8s_process_manager.py +++ b/src/drunc/process_manager/k8s_process_manager.py @@ -218,7 +218,7 @@ def __init__(self, configuration: ProcessManagerConfHandler, **kwargs) -> None: # Get settings from configuration JSON file # Any comments following this one will relate to the parameters retrieved from # the configuration file if the comment starts as "CONFIGURATION -" - settings = getattr(self.configuration.data, "settings", {}) + settings = getattr(self.configuration, "settings", {}) # CONFIGURATION - label defaults labels = settings.get("labels", {}) @@ -1176,7 +1176,7 @@ def _build_pod_main_container( main_container - the fully configured V1Container object """ - pod_image = self.configuration.data.image + pod_image = self.configuration.image exec_and_args_list = boot_request.process_description.executable_and_arguments # Build command to exec @@ -1221,7 +1221,7 @@ def _build_pod_main_container( resource_reqs = None is_perf_app = self.perf_selector in podname.lower() if is_perf_app: - settings = getattr(self.configuration.data, "settings", {}) + settings = getattr(self.configuration, "settings", {}) host_configs = settings.get("host_configs", {}) if not target_host or target_host not in host_configs: diff --git a/src/drunc/process_manager/process_manager.py b/src/drunc/process_manager/process_manager.py index df54ef872..d8c4b41ad 100644 --- a/src/drunc/process_manager/process_manager.py +++ b/src/drunc/process_manager/process_manager.py @@ -60,7 +60,7 @@ def __init__( super().__init__() self.log = get_logger( - f"process_manager.{configuration.data.type.name}_process_manager", + f"process_manager.{configuration.pm_type.name}_process_manager", ) self.log.debug(pid_info_str()) self.log.debug("Initialized ProcessManager") @@ -83,11 +83,11 @@ def __init__( self._create_broadcast_service(self.name, self.session) dach = DummyAuthoriserConfHandler.from_pyobject( - data=self.configuration.data.authoriser + data=self.configuration.authoriser ) - self.opmon_publisher = getattr(self.configuration.data, "opmon_publisher", None) - interval_s = getattr(self.configuration.data, "interval_s", 10.0) + self.opmon_publisher = getattr(self.configuration, "opmon_publisher", None) + interval_s = getattr(self.configuration, "interval_s", 10.0) self.authoriser = DummyAuthoriser(dach, SystemType.PROCESS_MANAGER) self.process_store = {} # dict[str, sh.RunningCommand] # str = uuid @@ -167,7 +167,7 @@ def get_log_path(self): def _create_broadcast_service(self, name, session): bsch = BroadcastSenderConfHandler.from_pyobject( - data=self.configuration.data.broadcaster + data=self.configuration.broadcaster ) self.broadcast_service = ( @@ -176,7 +176,7 @@ def _create_broadcast_service(self, name, session): session=session, configuration=bsch, ) - if bsch.data + if bsch.impl_technology is not None else None ) @@ -781,19 +781,19 @@ def _get_process_uid( def get(conf, **kwargs): log = get_logger("process_manager.get") - if conf.data.type == ProcessManagerTypes.SSH_SHELL: + if conf.pm_type == ProcessManagerTypes.SSH_SHELL: from drunc.process_manager.ssh_process_manager_shell import ( SSHProcessManagerShell, ) log.debug("Starting [green]SSH Shell process_manager[/green]") return SSHProcessManagerShell(conf, **kwargs) - elif conf.data.type == ProcessManagerTypes.K8s: + elif conf.pm_type == ProcessManagerTypes.K8s: from drunc.process_manager.k8s_process_manager import K8sProcessManager log.debug("Starting [green]K8s process_manager[/green]") return K8sProcessManager(conf, **kwargs) - elif conf.data.type == ProcessManagerTypes.SSH_PARAMIKO: + elif conf.pm_type == ProcessManagerTypes.SSH_PARAMIKO: from drunc.process_manager.ssh_process_manager_paramiko_client import ( SSHProcessManagerParamikoClient, ) @@ -801,7 +801,5 @@ def get(conf, **kwargs): log.debug("Starting [green]SSH Paramiko process_manager[/green]") return SSHProcessManagerParamikoClient(conf, **kwargs) else: - log.error(f"ProcessManager type {conf.get('type')} is unsupported!") - raise RuntimeError( - f"ProcessManager type {conf.get('type')} is unsupported!" - ) + log.error(f"ProcessManager type {conf.pm_type} is unsupported!") + raise RuntimeError(f"ProcessManager type {conf.pm_type} is unsupported!") diff --git a/src/drunc/process_manager/ssh_process_manager.py b/src/drunc/process_manager/ssh_process_manager.py index e915ecd73..1d11f2daa 100644 --- a/src/drunc/process_manager/ssh_process_manager.py +++ b/src/drunc/process_manager/ssh_process_manager.py @@ -37,13 +37,11 @@ def __init__( self.disable_localhost_host_key_check = False self.disable_host_key_check = False - if self.configuration.data.settings: - self.disable_localhost_host_key_check = ( - self.configuration.data.settings.get( - "disable_localhost_host_key_check", False - ) + if self.configuration.settings: + self.disable_localhost_host_key_check = self.configuration.settings.get( + "disable_localhost_host_key_check", False ) - self.disable_host_key_check = self.configuration.data.settings.get( + self.disable_host_key_check = self.configuration.settings.get( "disable_host_key_check", False ) @@ -102,7 +100,7 @@ def _build_process_instance( def _get_process_timeouts(self, uuids: List[str]) -> dict[str, float]: process_timeouts = {} for process_uuid in uuids: - process_timeouts[process_uuid] = self.configuration.data.kill_timeout + process_timeouts[process_uuid] = self.configuration.kill_timeout return process_timeouts def _on_ssh_process_exit( @@ -528,7 +526,7 @@ def _restart_impl(self, query: ProcessQuery) -> ProcessInstanceList: self.add_process_to_expected_dead_processes(uuid) exit_status = self.ssh_lifetime_manager.kill_process( - uuid, self.configuration.data.kill_timeout + uuid, self.configuration.kill_timeout ) if exit_status is not None: self.archived_exit_statuses[uuid] = exit_status @@ -692,7 +690,7 @@ def _flush_impl(self, query: ProcessQuery) -> ProcessInstanceList: del self.boot_request[proc_uuid] # Clean data associated with the process from the lifetime manager self.ssh_lifetime_manager.kill_process( - proc_uuid, self.configuration.data.kill_timeout + proc_uuid, self.configuration.kill_timeout ) pi_return_code = ( diff --git a/src/drunc/process_manager/utils.py b/src/drunc/process_manager/utils.py index a96ea9607..8793cc8d4 100644 --- a/src/drunc/process_manager/utils.py +++ b/src/drunc/process_manager/utils.py @@ -355,7 +355,7 @@ def get_pm_type_from_name(pm_name: str) -> ProcessManagerTypes: # OKS or other types - fallback to from_pyobject pmch = ProcessManagerConfHandler.from_pyobject(data=path_or_url) - return pmch.data.type + return getattr(pmch, "pm_type", pmch.type) def format_hostname(hostname: str) -> str: diff --git a/src/drunc/session_manager/configuration.py b/src/drunc/session_manager/configuration.py index b52d35cf0..096ea553f 100644 --- a/src/drunc/session_manager/configuration.py +++ b/src/drunc/session_manager/configuration.py @@ -1,30 +1,10 @@ """Configuration for the session manager service.""" -from drunc.utils.configuration import ( - ConfData, - ConfHandler, - ConfTypeNotSupported, - ConfTypes, -) +from drunc.utils.configuration import ConfHandler -class SessionManagerConfData(ConfData): - """Wrapper for session manager configuration data.""" - - def __init__(self) -> None: - pass +class SessionManagerConfHandler(ConfHandler): + """Handler for session manager configuration.""" def populate_from_dict(self, data: dict[str, object]) -> None: - """Populate from dictionary data.""" - # Session manager has no configuration requirements pass - - def populate_from_pbany(self, pbany_data: object) -> None: - """Populate from Protobuf Any message.""" - raise ConfTypeNotSupported(ConfTypes.ProtobufAny, self.__class__.__name__) - - -class SessionManagerConfHandler(ConfHandler[SessionManagerConfData]): - """Handler for session manager configuration.""" - - confdata_cls = SessionManagerConfData diff --git a/src/drunc/session_manager/interface/session_manager.py b/src/drunc/session_manager/interface/session_manager.py index af96351bd..0e7a79849 100644 --- a/src/drunc/session_manager/interface/session_manager.py +++ b/src/drunc/session_manager/interface/session_manager.py @@ -11,10 +11,7 @@ MANAGER_SERVER_GRPC_CONFIG, MANAGER_SERVER_GRPC_MAX_WORKERS, ) -from drunc.session_manager.configuration import ( - SessionManagerConfData, - SessionManagerConfHandler, -) +from drunc.session_manager.configuration import SessionManagerConfHandler from drunc.session_manager.session_manager import SessionManager from drunc.utils.grpc_utils import RichErrorServerInterceptor from drunc.utils.utils import get_logger, get_root_logger @@ -54,7 +51,7 @@ def session_manager_cli() -> None: logger = get_logger(app_name, rich_handler=True) # Load the configuration for the session manager. - config = SessionManagerConfHandler.from_pyobject(data=SessionManagerConfData()) + config = SessionManagerConfHandler.from_pyobject(data=None) logger.info(f"Using '{config}' as the SessionManager configuration.") # Load the session manager. diff --git a/src/drunc/unified_shell/shell.py b/src/drunc/unified_shell/shell.py index 8a3f38a85..004b3e4af 100644 --- a/src/drunc/unified_shell/shell.py +++ b/src/drunc/unified_shell/shell.py @@ -355,9 +355,7 @@ def unified_shell( # live with it. At least until controller.core uses file handler instead of stream get_logger("controller.core.FSM", log_level="CRITICAL") - fsmch = FSMConfHandler.from_pyobject( - data=controller_configuration.data.controller.fsm - ) + fsmch = FSMConfHandler.from_pyobject(data=controller_configuration.controller.fsm) ctx.obj.log.debug("Initializing the [green]StatefulNode[/green]") stateful_node = StatefulNode(fsm_configuration=fsmch, top_segment_controller=False) diff --git a/src/drunc/utils/configuration.py b/src/drunc/utils/configuration.py index 97fff40c0..9fb5b2199 100644 --- a/src/drunc/utils/configuration.py +++ b/src/drunc/utils/configuration.py @@ -3,9 +3,8 @@ import json import logging import os -from abc import ABC, abstractmethod from enum import Enum -from typing import Generic, Type, TypeVar, cast +from typing import cast import conffwk @@ -112,46 +111,15 @@ def __init__( self.session = session -class ConfData(ABC): - """Base class for configuration wrapper objects that populate from raw sources.""" - - @abstractmethod - def populate_from_dict(self, data: dict[str, object]) -> None: - """Populate from a dictionary (JSON source). - - Args: - data: Dictionary data from JSON configuration file. - - Raises: - ConfTypeNotSupported: If this handler doesn't support JSON sources. - """ - raise ConfTypeNotSupported(ConfTypes.JsonFileName, self.__class__.__name__) - - @abstractmethod - def populate_from_pbany(self, pbany_data: object) -> None: - """Populate from a Protobuf Any message. - - Args: - pbany_data: Protobuf Any message. - - Raises: - ConfTypeNotSupported: If this handler doesn't support Protobuf sources. - """ - raise ConfTypeNotSupported(ConfTypes.ProtobufAny, self.__class__.__name__) - - -ConfDataType = TypeVar("ConfDataType", bound=ConfData) - - -class ConfHandler(Generic[ConfDataType]): +class ConfHandler: """Handler for loading and parsing DRUNC configurations. - Generic over a ConfDataType that wraps the parsed configuration. Supports multiple configuration sources via from_* classmethods. + Subclasses override populate_from_dict / populate_from_pbany to handle + JSON and protobuf sources, and _post_process_oks to handle OKS/pyobject + sources (via self._raw_data). """ - confdata_cls: Type[ConfDataType] - data: ConfDataType type: ConfTypes oks_key: OKSKey | None class_name: str @@ -164,43 +132,34 @@ class ConfHandler(Generic[ConfDataType]): initial_data: object oks_path: str db: object + _raw_data: object # raw OKS/pyobject data, available during _post_process_oks @classmethod def from_pyobject( cls, data: object, session_name: str | None = None - ) -> "ConfHandler[ConfDataType]": - instance: ConfHandler[ConfDataType] = cast( - ConfHandler[ConfDataType], cls.__new__(cls) - ) + ) -> "ConfHandler": + instance: ConfHandler = cls.__new__(cls) instance._init_common(session_name) instance.initial_data = data - instance.data = cast(ConfDataType, data) + instance._raw_data = data instance.type = ConfTypes.PyObject instance._post_process_oks() return instance @classmethod - def from_pbany( - cls, data: object, session_name: str | None = None - ) -> "ConfHandler[ConfDataType]": - instance: ConfHandler[ConfDataType] = cast( - ConfHandler[ConfDataType], cls.__new__(cls) - ) + def from_pbany(cls, data: object, session_name: str | None = None) -> "ConfHandler": + instance: ConfHandler = cls.__new__(cls) instance._init_common(session_name) instance.initial_data = data - instance.data = cls.confdata_cls() - instance.data.populate_from_pbany(data) + instance._raw_data = None + instance.populate_from_pbany(data) instance.type = ConfTypes.PyObject instance._post_process_oks() return instance @classmethod - def from_json( - cls, path: str, session_name: str | None = None - ) -> "ConfHandler[ConfDataType]": - instance: ConfHandler[ConfDataType] = cast( - ConfHandler[ConfDataType], cls.__new__(cls) - ) + def from_json(cls, path: str, session_name: str | None = None) -> "ConfHandler": + instance: ConfHandler = cls.__new__(cls) instance._init_common(session_name) instance.initial_data = path resolved = expand_path(path, True) @@ -208,8 +167,8 @@ def from_json( raise DruncSetupException(f"Location {resolved} ({path}) is empty!") with open(resolved) as f: json_data = json.load(f) - instance.data = cls.confdata_cls() - instance.data.populate_from_dict(cast(dict[str, object], json_data)) + instance._raw_data = None + instance.populate_from_dict(cast(dict[str, object], json_data)) instance.type = ConfTypes.PyObject instance._post_process_oks() return instance @@ -220,18 +179,30 @@ def from_oks( url: str, oks_key: OKSKey, session_name: str | None = None, - ) -> "ConfHandler[ConfDataType]": - instance: ConfHandler[ConfDataType] = cast( - ConfHandler[ConfDataType], cls.__new__(cls) - ) + ) -> "ConfHandler": + instance: ConfHandler = cls.__new__(cls) instance._init_common(session_name) instance.initial_data = url instance.oks_key = oks_key - instance.data = cast(ConfDataType, instance._parse_oks_file(url)) + instance._raw_data = instance._parse_oks_file(url) instance.type = ConfTypes.PyObject instance._post_process_oks() return instance + def populate_from_dict(self, data: dict[str, object]) -> None: + """Populate from a dictionary (JSON source). + + Override in subclasses that support JSON configuration. + """ + raise ConfTypeNotSupported(ConfTypes.JsonFileName, self.__class__.__name__) + + def populate_from_pbany(self, pbany_data: object) -> None: + """Populate from a Protobuf Any message. + + Override in subclasses that support protobuf configuration. + """ + raise ConfTypeNotSupported(ConfTypes.ProtobufAny, self.__class__.__name__) + def _init_common(self, session_name: str | None = None) -> None: """Initialize common attributes. @@ -291,5 +262,6 @@ def _post_process_oks(self) -> None: """Post-process configuration after loading. Override in subclasses to perform custom initialization. + For OKS/pyobject sources, self._raw_data holds the raw object. """ pass diff --git a/tests/issues/test_issue309.py b/tests/issues/test_issue309.py index 1d1b3082c..d12070ad8 100644 --- a/tests/issues/test_issue309.py +++ b/tests/issues/test_issue309.py @@ -22,4 +22,4 @@ def test_issue309(load_test_config): session_name="test", ) - assert controller_configuration.data.controller.id == controller_id + assert controller_configuration.controller.id == controller_id diff --git a/tests/issues/test_issue363.py b/tests/issues/test_issue363.py index a8fca2317..ff477a5e7 100644 --- a/tests/issues/test_issue363.py +++ b/tests/issues/test_issue363.py @@ -20,6 +20,6 @@ def test_issue363(load_test_config): ), session_name="test", ) - ids = [segment.id for segment in controller_configuration.data.segments] + ids = [segment.id for segment in controller_configuration.segments] assert ids == ["bottom-segment-1", "bottom-segment-2"] - assert controller_configuration.data.controller.id == controller_id + assert controller_configuration.controller.id == controller_id From d817a94ecb47d46285f4788844a50baef95e90f1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 22 Jun 2026 10:24:21 -0500 Subject: [PATCH 29/73] JCF: Issue #346: add __init__.py to bring this directory in line with other subdirectories and prevent automated check failure --- src/drunc/integtest/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/drunc/integtest/__init__.py diff --git a/src/drunc/integtest/__init__.py b/src/drunc/integtest/__init__.py new file mode 100644 index 000000000..e69de29bb From 4cbb2783146ecadc3f771a2978ab0bb566716b9a Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 22 Jun 2026 10:27:57 -0500 Subject: [PATCH 30/73] JCF: Issue #346: ...except integtest isn't meant to be an importable package Revert "JCF: Issue #346: add __init__.py to bring this directory in line with other subdirectories and prevent automated check failure" This reverts commit d817a94ecb47d46285f4788844a50baef95e90f1. --- src/drunc/integtest/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/drunc/integtest/__init__.py diff --git a/src/drunc/integtest/__init__.py b/src/drunc/integtest/__init__.py deleted file mode 100644 index e69de29bb..000000000 From ffab17e23f5a6b1bd07a116eaeba18bb07d645ff Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 22 Jun 2026 11:22:10 -0500 Subject: [PATCH 31/73] JCF: Issue #346: exclude integtest directory from requirement that it contain an __init__.py file in the check_dirs_init_file.yml workflow --- .github/workflows/check_dirs_init_file.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/check_dirs_init_file.yml b/.github/workflows/check_dirs_init_file.yml index 4b5eceea2..f34b86529 100644 --- a/.github/workflows/check_dirs_init_file.yml +++ b/.github/workflows/check_dirs_init_file.yml @@ -21,6 +21,7 @@ jobs: -path "src/tests*" -o \ -path "src/docs*" -o \ -path "src/config*" -o \ + -path "src/drunc/integtest*" -o \ -path "src/drunc/data*" \) -prune -o \ -type d -exec sh -c '[ ! -e "$1/__init__.py" ] && echo "$1"' _ {} \;) From 8e27c49e9c23980d3b0ed8b00765a5239c85827b Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Wed, 24 Jun 2026 11:47:55 +0200 Subject: [PATCH 32/73] update integtest + unify lines --- integtest/integ_test_utils.py | 189 +++++++++++++++++++++++++----- integtest/process_manager_test.py | 30 ++--- 2 files changed, 172 insertions(+), 47 deletions(-) diff --git a/integtest/integ_test_utils.py b/integtest/integ_test_utils.py index 2f85ee3a9..855c4fdfa 100644 --- a/integtest/integ_test_utils.py +++ b/integtest/integ_test_utils.py @@ -188,45 +188,77 @@ def require_pattern_match( return match -def _parse_ps_table_from_index( - lines: list[str], start_idx: int +# ── Table parsing ────────────────────────────────────────────────────────────── + + +def _parse_table_from_index( + lines: list[str], start_idx: int, columns: list[str] ) -> list[dict[str, str]]: - """Parse a Unicode table of processes starting after `start_idx`. + """Parse a Unicode box table starting after `start_idx`, mapping cells to `columns`. - The parser expects rows that start with `│` and stops at a line starting - with `└`. It returns dictionaries with normalized column names. + Expects rows that start with `│` and stops at a line starting with `└`. + Rows with fewer cells than `columns` are silently skipped. """ - table_rows: list[dict[str, str]] = [] + rows: list[dict[str, str]] = [] for line in lines[start_idx + 1 :]: stripped = line.strip() - if stripped.startswith("└"): break - if not stripped.startswith("│"): continue - cells = [cell.strip() for cell in stripped.strip("│").split("│")] - if len(cells) < 7: + if len(cells) < len(columns): continue + rows.append(dict(zip(columns, cells))) - table_rows.append( - { - "session": cells[0], - "friendly_name": cells[1], - "user": cells[2], - "host": cells[3], - "uuid": cells[4], - "alive": cells[5], - "exit_code": cells[6], - } - ) + return rows - return table_rows +def _get_table_after_echo( + lines: list[str], + echo_marker: str, + header_keyword: str, + columns: list[str], +) -> list[dict[str, str]]: + """Return parsed table rows found after `echo_marker`, anchored by `header_keyword`. -def get_ps_table_after_echo(stdout: str, echo_marker: str) -> list[dict[str, str]]: + Args: + stdout: Raw stdout string (ANSI stripping is handled internally). + echo_marker: The drunc.echo marker to anchor the search. + header_keyword: Substring identifying the table header line. + columns: Ordered column names to map to each cell. + + Returns: + Parsed rows as a list of dicts. Empty list if no table is found. + """ + echo_idx = require_echo_marker_index(lines, echo_marker) + + table_start_idx = find_line_index( + lines, + lambda line: header_keyword in line, + start_idx=echo_idx + 1, + ) + if table_start_idx is None: + return [] + + return _parse_table_from_index(lines, table_start_idx, columns) + + +_PS_COLUMNS = ["session", "friendly_name", "user", "host", "uuid", "alive", "exit_code"] +_STATUS_COLUMNS = [ + "name", + "info", + "state", + "substate", + "in_error", + "included", + "endpoint", +] +_EXEC_REPORT_COLUMNS = ["name", "command_execution", "fsm_transition"] + + +def get_ps_table_after_echo(lines: list[str], echo_marker: str) -> list[dict[str, str]]: """Return parsed process-table rows found after a specific echo marker. If no process table is found after the marker, returns an empty list. @@ -242,19 +274,38 @@ def get_ps_table_after_echo(stdout: str, echo_marker: str) -> list[dict[str, str >>> table[0]["friendly_name"] 'root-controller' """ - lines = strip_ansi(stdout).splitlines() + return _get_table_after_echo(lines, echo_marker, "Processes running", _PS_COLUMNS) - echo_idx = require_echo_marker_index(lines, echo_marker) - table_start_idx = find_line_index( - lines, - lambda line: "Processes running" in line, - start_idx=echo_idx + 1, +def get_status_table_after_echo( + lines: list[str], echo_marker: str +) -> list[dict[str, str]]: + """Return parsed status-table rows found after a specific echo marker. + + If no status table is found after the marker, returns an empty list. + + Returns: + Parsed rows with keys: name, info, state, substate, in_error, included, endpoint. + """ + return _get_table_after_echo(lines, echo_marker, "status", _STATUS_COLUMNS) + + +def get_execution_report_after_echo( + lines: list[str], echo_marker: str +) -> list[dict[str, str]]: + """Return parsed execution-report rows found after a specific echo marker. + + If no execution report is found after the marker, returns an empty list. + + Returns: + Parsed rows with keys: name, command_execution, fsm_transition. + """ + return _get_table_after_echo( + lines, echo_marker, "execution report", _EXEC_REPORT_COLUMNS ) - if table_start_idx is None: - return [] - return _parse_ps_table_from_index(lines, table_start_idx) + +# ── Process table helpers ────────────────────────────────────────────────────── def get_column_for_friendly_name( @@ -334,3 +385,77 @@ def assert_process_presence( assert not matching_rows, ( f"Expected '{friendly_name}' to be absent from ps table {context}, but it is still present." ) + + +# ── Status table assertion helpers ──────────────────────────────────────────── + + +def check_execution_report_success(report: list[dict[str, str]]) -> None: + """Assert every row in an execution report shows success for both columns. + + Raises: + AssertionError: On the first row that fails either check, + with the process name and actual values reported. + """ + assert report, "Execution report is empty — nothing to check." + + for row in report: + name = row["name"] + assert row["command_execution"] == "Executed Successfully", ( + f"Process '{name}': expected command_execution='Executed Successfully', " + f"got '{row['command_execution']}'." + ) + assert row["fsm_transition"] == "Fsm Executed Successfully", ( + f"Process '{name}': expected fsm_transition='Fsm Executed Successfully', " + f"got '{row['fsm_transition']}'." + ) + + +def check_status_table_states( + status_table: list[dict[str, str]], + expected_state: str, +) -> None: + """Assert that every row in a status table has the expected `state`. + + Raises: + AssertionError: Lists all rows whose state does not match. + """ + assert status_table, "Status table is empty — nothing to check." + + failures = [ + f" '{row['name']}': state='{row['state']}'" + for row in status_table + if row["state"] != expected_state + ] + assert not failures, ( + f"Expected all processes to have state='{expected_state}', " + f"but the following did not:\n" + "\n".join(failures) + ) + + +def check_status_table_substates( + status_table: list[dict[str, str]], + controller_substate: str, + non_controller_substate: str, +) -> None: + """Assert substates based on whether a process name contains 'controller'. + + - Rows whose `name` contains 'controller' must match `controller_substate`. + - All other rows must match `non_controller_substate`. + + Raises: + AssertionError: Lists all rows whose substate does not match the rule. + """ + assert status_table, "Status table is empty — nothing to check." + + failures: list[str] = [] + for row in status_table: + name = row["name"] + is_controller = "controller" in name + expected = controller_substate if is_controller else non_controller_substate + if row["substate"] != expected: + failures.append( + f" '{name}': expected substate='{expected}', got '{row['substate']}'" + ) + + assert not failures, "Substate mismatch(es) found:\n" + "\n".join(failures) diff --git a/integtest/process_manager_test.py b/integtest/process_manager_test.py index 2a3640d23..9b4212a09 100644 --- a/integtest/process_manager_test.py +++ b/integtest/process_manager_test.py @@ -194,10 +194,10 @@ def test_log_files(run_dunerc) -> None: def test_boot(run_dunerc) -> None: """Checks that boot starts the managed processes and exposes UUIDs in ps.""" - stdout = run_dunerc.completed_process.stdout + lines = strip_ansi(run_dunerc.completed_process.stdout).splitlines() - ps_pre_boot = get_ps_table_after_echo(stdout, "pre_boot") - ps_post_boot = get_ps_table_after_echo(stdout, "post_boot") + ps_pre_boot = get_ps_table_after_echo(lines, "pre_boot") + ps_post_boot = get_ps_table_after_echo(lines, "post_boot") assert not ps_pre_boot, ( f"Expected ps table before boot to be empty, but found {len(ps_pre_boot)} row(s): " @@ -325,8 +325,7 @@ def test_wait_command_duration_from_logs(run_dunerc) -> None: def test_restart_mlt_logs(run_dunerc) -> None: """Checks that restarting mlt produces the expected restart, exit, and boot logs.""" - stdout = run_dunerc.completed_process.stdout - lines = strip_ansi(stdout).splitlines() + lines = strip_ansi(run_dunerc.completed_process.stdout).splitlines() echo_idx = require_echo_marker_index(lines, "pre_restart_mlt") @@ -349,7 +348,8 @@ def test_restart_mlt_logs(run_dunerc) -> None: require_pattern_match( restart_text, re.compile( - r"Process 'mlt' \(.*?\) was terminated by the process manager through the remote pid\. Reported exit code: 0\.", re.DOTALL + r"Process 'mlt' \(.*?\) was terminated by the process manager through the remote pid\. Reported exit code: 0\.", + re.DOTALL, ), error_message="Did not find the mlt exit-code log line after graceful termination.", ) @@ -368,10 +368,10 @@ def test_restart_mlt_logs(run_dunerc) -> None: def test_kill_removes_mlt_from_ps_table(run_dunerc) -> None: """Checks that killing mlt removes it from the subsequent ps table.""" - stdout = run_dunerc.completed_process.stdout + lines = strip_ansi(run_dunerc.completed_process.stdout).splitlines() - ps_before_kill = get_ps_table_after_echo(stdout, "test_kill_mlt") - ps_after_kill = get_ps_table_after_echo(stdout, "test_kill_mlt_post") + ps_before_kill = get_ps_table_after_echo(lines, "test_kill_mlt") + ps_after_kill = get_ps_table_after_echo(lines, "test_kill_mlt_post") assert_process_presence(ps_before_kill, "mlt", context="before kill") assert_process_presence( @@ -381,8 +381,8 @@ def test_kill_removes_mlt_from_ps_table(run_dunerc) -> None: def test_mlt_recovers_after_kill(run_dunerc) -> None: """Checks that mlt is present again after the recovery restart sequence.""" - stdout = run_dunerc.completed_process.stdout - ps_after_recovery = get_ps_table_after_echo(stdout, "test_recovery_post") + lines = strip_ansi(run_dunerc.completed_process.stdout).splitlines() + ps_after_recovery = get_ps_table_after_echo(lines, "test_recovery_post") assert_process_presence(ps_after_recovery, "mlt", context="after recovery") @@ -390,15 +390,15 @@ def test_flush(run_dunerc) -> None: """Checks that flush work by crashing mlt, seeing that the process exists, and then flushing to show its gone""" - stdout = run_dunerc.completed_process.stdout - ps_initial = get_ps_table_after_echo(stdout, "test_flush") + lines = strip_ansi(run_dunerc.completed_process.stdout).splitlines() + ps_initial = get_ps_table_after_echo(lines, "test_flush") assert_process_presence(ps_initial, "mlt", context="before crash") - ps_after_crash = get_ps_table_after_echo(stdout, "after_crash") + ps_after_crash = get_ps_table_after_echo(lines, "after_crash") mlt_alive = get_column_for_friendly_name(ps_after_crash, "mlt", "alive") assert mlt_alive == "False", "The mlt should have crashed" - ps_after_flash = get_ps_table_after_echo(stdout, "after_flush") + ps_after_flash = get_ps_table_after_echo(lines, "after_flush") assert_process_presence( ps_after_flash, "mlt", context="after crash", expected_present=False ) From d203207432b3e514fd81f9a45e78b639cddcc7fb Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Wed, 24 Jun 2026 12:33:17 +0200 Subject: [PATCH 33/73] Add controller test --- integtest/controller_test.py | 250 ++++++++++++++++++++++++++++++++++ integtest/integ_test_utils.py | 13 ++ 2 files changed, 263 insertions(+) create mode 100644 integtest/controller_test.py diff --git a/integtest/controller_test.py b/integtest/controller_test.py new file mode 100644 index 000000000..e36dde500 --- /dev/null +++ b/integtest/controller_test.py @@ -0,0 +1,250 @@ +import os +import re +from dataclasses import dataclass, field + +import integrationtest.data_classes as data_classes +import integrationtest.log_file_checks as log_file_checks +import pytest +from integ_test_utils import ( + check_execution_report_success, + check_status_table_states, + check_status_table_substates, + get_execution_report_after_echo, + get_run_info_after_echo, + get_status_table_after_echo, + strip_ansi, +) + +pytest_plugins = "integrationtest.integrationtest_drunc" + +# Values that help determine the running conditions +number_of_data_producers = 2 +data_rate_slowdown_factor = 1 # 10 for ProtoWIB/DuneWIB +run_duration = 10 # seconds +readout_window_time_before = 1000 +readout_window_time_after = 1001 + +check_for_logfile_errors = True + +ignored_logfile_problems = { + "-controller": [ + "Worker with pid \\d+ was terminated due to signal", + "Connection '.*' not found on the application registry", + ], + "SSH_SHELL_process_manager": [ + "was terminated unexpectedly through the remote pid by a SIGKILL", + ], + "connectivity-service": [ + "errorlog: -", + ], +} + +conf_dict = data_classes.integtest_params_for_generated_dunedaq_config() +conf_dict.object_databases = ["config/daqsystemtest/integrationtest-objects.data.xml"] +conf_dict.dro_map_config.n_streams = number_of_data_producers +conf_dict.op_env = "integtest" +conf_dict.session = "minimal" +conf_dict.tpg_enabled = False + +# For testing, allow drunc to manage ConnectivityService (default is False, integrationtest manages Connectivity Service) +conf_dict.drunc_connsvc = True +# For testing, specify connectivity service port (default is 0, a random port is chosen for the Connectivity Service) +# conf_dict.connsvc_port = 12345 + +conf_dict.config_substitutions.append( + data_classes.attribute_substitution( + obj_id="random-tc-generator", + obj_class="RandomTCMakerConf", + updates={"trigger_rate_hz": 1}, + ) +) +conf_dict.config_substitutions.append( + data_classes.attribute_substitution( + obj_class="TCReadoutMap", + obj_id="def-random-readout", + updates={ + "time_before": readout_window_time_before, + "time_after": readout_window_time_after, + }, + ) +) + +confgen_arguments = {"MinimalSystem": conf_dict} + + +# ── FSM command dataclass ────────────────────────────────────────────────────── + + +@dataclass +class FsmCommandParams: + marker: str + command: str + expected_state: str + non_controller_substate: str = "idle" + command_args: list[str] = field(default_factory=list) + run_number: int | None = None + + @property + def done_marker(self) -> str: + return f"{self.marker}_done" + + @property + def full_command(self) -> str: + return " ".join([self.command] + self.command_args) + + def to_command_block(self) -> str: + return f""" +echo {self.marker} +{self.full_command} +echo {self.marker}_done +status +echo {self.marker}_status_done +""" + + +_FSM_COMMANDS = [ + FsmCommandParams("test_conf", "conf", "configured"), + FsmCommandParams( + "test_start", "start", "ready", command_args=["--run-number", "1"], run_number=1 + ), + FsmCommandParams("test_enable_triggers", "enable-triggers", "running"), + FsmCommandParams("test_disable_triggers", "disable-triggers", "ready"), + FsmCommandParams("test_drain_dataflow", "drain-dataflow", "dataflow_drained"), + FsmCommandParams( + "test_stop_trigger_sources", "stop-trigger-sources", "trigger_sources_stopped" + ), + FsmCommandParams("test_stop", "stop", "configured"), + FsmCommandParams("test_scrap", "scrap", "initial"), +] + +# ── Command list ─────────────────────────────────────────────────────────────── + +dunerc_command_list = ( + """ +boot +echo post_boot +status +echo post_boot_done +""" + + "".join(p.to_command_block() for p in _FSM_COMMANDS) + + "\nterminate" +).split() + + +UUID_RE = re.compile( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" +) + + +# ── Fixtures ─────────────────────────────────────────────────────────────────── + + +@pytest.fixture(scope="module") +def boot_status_table(run_dunerc): + """Parse and cache the status table produced immediately after boot. + + Scoped to the module so every test in this file can compare against the + same baseline without re-parsing stdout each time. + """ + lines = strip_ansi(run_dunerc.completed_process.stdout).splitlines() + return get_status_table_after_echo(lines, "post_boot") + + +# ── Helpers ──────────────────────────────────────────────────────────────────── + + +def _check_command( + lines: list[str], + boot_status_table: list[dict[str, str]], + params: FsmCommandParams, +) -> None: + """Shared assertion logic for a drunc FSM command. + + Checks: + - Execution report names match boot table, all rows successful. + - Post-command status table has expected state/substates. + - Run number if specified. + """ + exec_report = get_execution_report_after_echo(lines, params.marker) + assert exec_report, f"No execution report found after '{params.marker}' marker." + + boot_names = {row["name"] for row in boot_status_table} + report_names = {row["name"] for row in exec_report} + assert report_names == boot_names, ( + f"Execution report names do not match boot status table names.\n" + f" Only in report: {report_names - boot_names}\n" + f" Only in boot table: {boot_names - report_names}" + ) + check_execution_report_success(exec_report) + + status_table = get_status_table_after_echo(lines, params.done_marker) + assert status_table, f"No status table found after '{params.done_marker}' marker." + check_status_table_states(status_table, expected_state=params.expected_state) + check_status_table_substates( + status_table, + controller_substate=params.expected_state, + non_controller_substate=params.non_controller_substate, + ) + + if params.run_number is not None: + run_info = get_run_info_after_echo(lines, params.done_marker) + assert run_info, f"No Run Info table found after '{params.done_marker}' marker." + assert run_info["Run number"] == str(params.run_number), ( + f"Expected run number '{params.run_number}', got '{run_info['Run number']}'." + ) + + +# ── Tests ────────────────────────────────────────────────────────────────────── + + +def test_dunerc_success(run_dunerc) -> None: + """Checks that the drunc integration command sequence completes successfully.""" + current_test = os.environ.get("PYTEST_CURRENT_TEST") + match_obj = re.search(r".*\[(.+)-run_.*rc.*\d].*", current_test) + if match_obj: + current_test = match_obj.group(1) + banner_line = re.sub(".", "=", current_test) + print(banner_line) + print(current_test) + print(banner_line) + + assert run_dunerc.completed_process.returncode == 0 + + +def test_log_files(run_dunerc) -> None: + """Checks that expected process-manager log files exist and are free of errors.""" + assert any( + f"{run_dunerc.daq_session_name}_df-01" in str(logname) + for logname in run_dunerc.log_files + ) + assert any( + f"{run_dunerc.daq_session_name}_dfo" in str(logname) + for logname in run_dunerc.log_files + ) + assert any( + f"{run_dunerc.daq_session_name}_mlt" in str(logname) + for logname in run_dunerc.log_files + ) + assert any( + f"{run_dunerc.daq_session_name}_ru" in str(logname) + for logname in run_dunerc.log_files + ) + + if check_for_logfile_errors: + assert log_file_checks.logs_are_error_free( + [ + logname + for logname in run_dunerc.log_files + if "process_manager" in str(logname) + ], + True, + True, + ignored_logfile_problems, + ) + + +@pytest.mark.parametrize("params", _FSM_COMMANDS, ids=lambda p: p.marker) +def test_fsm_command(run_dunerc, boot_status_table, params: FsmCommandParams) -> None: + """Checks that each FSM command executes successfully and transitions all processes to the expected state.""" + lines = strip_ansi(run_dunerc.completed_process.stdout).splitlines() + _check_command(lines, boot_status_table, params) diff --git a/integtest/integ_test_utils.py b/integtest/integ_test_utils.py index 855c4fdfa..570190569 100644 --- a/integtest/integ_test_utils.py +++ b/integtest/integ_test_utils.py @@ -459,3 +459,16 @@ def check_status_table_substates( ) assert not failures, "Substate mismatch(es) found:\n" + "\n".join(failures) + + +_RUN_INFO_COLUMNS = ["key", "value"] + + +def get_run_info_after_echo(lines: list[str], echo_marker: str) -> dict[str, str]: + """Return parsed Run Info key-value pairs found after `echo_marker`. + + The Run Info table is a two-column │key│value│ table anchored by 'Run Info'. + Returns a dict mapping stripped key → stripped value. + """ + rows = _get_table_after_echo(lines, echo_marker, "Run Info", _RUN_INFO_COLUMNS) + return {row["key"]: row["value"] for row in rows} From 35685e547f2a2251183ee6f43fd92b681e057517 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Wed, 24 Jun 2026 14:20:17 +0200 Subject: [PATCH 34/73] Improve fsm sequences bit --- integtest/controller_test.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/integtest/controller_test.py b/integtest/controller_test.py index e36dde500..7f7d38d0f 100644 --- a/integtest/controller_test.py +++ b/integtest/controller_test.py @@ -117,6 +117,18 @@ def to_command_block(self) -> str: FsmCommandParams("test_scrap", "scrap", "initial"), ] +_FSM_SEQUENCES = { + "test_start_run": FsmCommandParams( + "test_start_run", + "start-run", + "running", + command_args=["--run-number", "2"], + run_number=2, + ), + "test_shutdown": FsmCommandParams("test_shutdown", "shutdown", "initial"), + "test_stop_run": FsmCommandParams("test_stop_run", "stop-run", "configured"), +} + # ── Command list ─────────────────────────────────────────────────────────────── dunerc_command_list = ( @@ -127,10 +139,13 @@ def to_command_block(self) -> str: echo post_boot_done """ + "".join(p.to_command_block() for p in _FSM_COMMANDS) + + _FSM_SEQUENCES["test_start_run"].to_command_block() + + _FSM_SEQUENCES["test_stop_run"].to_command_block() + + "start-run --run-number 3" + + _FSM_SEQUENCES["test_stop_run"].to_command_block() + "\nterminate" ).split() - UUID_RE = re.compile( r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" ) From 517363a0db5c523cc403c212ce31d71b485fcafd Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Wed, 24 Jun 2026 14:20:30 +0200 Subject: [PATCH 35/73] add controller test to integtest list --- scripts/drunc_integtest_bundle.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/drunc_integtest_bundle.sh b/scripts/drunc_integtest_bundle.sh index e32a4aeb3..827784703 100755 --- a/scripts/drunc_integtest_bundle.sh +++ b/scripts/drunc_integtest_bundle.sh @@ -5,7 +5,7 @@ # Based entirely of the implementation of daqsystemtest_integtest_bundle.sh # Original author: KAB, 10-Oct-2023 -integtest_list=( "process_manager_test.py" ) +integtest_list=( "process_manager_test.py" "controller_test.py" ) let last_test_index=${#integtest_list[@]}-1 usage() { From afd226170eebbfd5eb9e463efbd0f18c82df83da Mon Sep 17 00:00:00 2001 From: Emir Muhammad <49058518+emmuhamm@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:17:11 +0100 Subject: [PATCH 36/73] Update log names for ssh process manager-like processes (#951) * update log names * Changing log names --------- Co-authored-by: PawelPlesniak --- .../ssh_process_lifetime_manager_from_forked_process.py | 6 +++++- .../processes/ssh_process_lifetime_manager_paramiko.py | 6 +++++- src/drunc/processes/ssh_process_lifetime_manager_shell.py | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/drunc/processes/ssh_process_lifetime_manager_from_forked_process.py b/src/drunc/processes/ssh_process_lifetime_manager_from_forked_process.py index 1a79636ab..6541ec57b 100644 --- a/src/drunc/processes/ssh_process_lifetime_manager_from_forked_process.py +++ b/src/drunc/processes/ssh_process_lifetime_manager_from_forked_process.py @@ -229,7 +229,11 @@ def __init__( The exception is reconstructed as a RuntimeError from the serialised message forwarded by the child process. """ - self.log = logger if logger is not None else get_logger(__name__) + self.log = ( + logger + if logger is not None + else get_logger("ssh_process_lifetime_manager_forked", rich_handler=True) + ) self._on_process_exit = on_process_exit # Queues for IPC between parent and child. diff --git a/src/drunc/processes/ssh_process_lifetime_manager_paramiko.py b/src/drunc/processes/ssh_process_lifetime_manager_paramiko.py index e918ee6e8..eaacbe27b 100644 --- a/src/drunc/processes/ssh_process_lifetime_manager_paramiko.py +++ b/src/drunc/processes/ssh_process_lifetime_manager_paramiko.py @@ -46,7 +46,11 @@ def __init__( """ self.disable_host_key_check = disable_host_key_check self.disable_localhost_host_key_check = disable_localhost_host_key_check - self.log = logger if logger else get_logger(__name__) + self.log = ( + logger + if logger + else get_logger("ssh_process_lifetime_manager_paramiko", rich_handler=True) + ) self.log.warning( "The paramiko-based SSH process manager is NOT actively maintatined. Consider using the shell-based SSH process manager instead." ) diff --git a/src/drunc/processes/ssh_process_lifetime_manager_shell.py b/src/drunc/processes/ssh_process_lifetime_manager_shell.py index 7cda71d08..7d25a3ff1 100644 --- a/src/drunc/processes/ssh_process_lifetime_manager_shell.py +++ b/src/drunc/processes/ssh_process_lifetime_manager_shell.py @@ -318,7 +318,11 @@ def __init__( """ self.disable_host_key_check = disable_host_key_check self.disable_localhost_host_key_check = disable_localhost_host_key_check - self.log = logger if logger else get_logger(__name__) + self.log = ( + logger + if logger + else get_logger("ssh_process_lifetime_manager_shell", rich_handler=True) + ) self._on_process_exit = on_process_exit # Create SSH command wrapper From 3cfa06900029ef7b76aa9eabc6bbeb8673d5ee2b Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 29 Jun 2026 11:57:58 -0500 Subject: [PATCH 37/73] JCF: Issue #346: add verbatim a pytest_configure function Pawel provided which should prevent pytest from erroring in the event that the .venv in use isn't writable --- conftest.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/conftest.py b/conftest.py index f45c6c2fe..f4705064b 100644 --- a/conftest.py +++ b/conftest.py @@ -1,3 +1,5 @@ +import os + """ Pytest configuration for the drunc project. Registers custom command-line options and test markers. @@ -71,3 +73,18 @@ def skip_if_no_option(marker_name, option_name, reason): skip_if_no_option( "paramiko", "--test-paramiko", "Use --test-paramiko to run Paramiko tests" ) + +def pytest_configure(config): + """ + Automatically disable the cache provider if the current + directory is not writable. + """ + + # Check if we have write access to the directory where we are running + if not os.access('.', os.W_OK): + # We explicitly block the 'cacheprovider' plugin + # This prevents the PermissionError in read-only environments + config.pluginmanager.set_blocked("cacheprovider") + + # Print a note so users know why it's disabled + print("\n[Config] Read-only directory detected. Cache provider disabled.") From c168e674a8c0ff161351d33db0cb29668cd29be7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 30 Jun 2026 11:35:01 -0500 Subject: [PATCH 38/73] JCF: Issue #346: After testing the previous commit with the test build DITFD_DEV_260630_A9, I still observed cache warnings, so reverting the change and returning the code to its state in the original PR #954. Revert "JCF: Issue #346: add verbatim a pytest_configure function Pawel provided which should prevent pytest from erroring in the event that the .venv in use isn't writable" This reverts commit 3cfa06900029ef7b76aa9eabc6bbeb8673d5ee2b. --- conftest.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/conftest.py b/conftest.py index f4705064b..f45c6c2fe 100644 --- a/conftest.py +++ b/conftest.py @@ -1,5 +1,3 @@ -import os - """ Pytest configuration for the drunc project. Registers custom command-line options and test markers. @@ -73,18 +71,3 @@ def skip_if_no_option(marker_name, option_name, reason): skip_if_no_option( "paramiko", "--test-paramiko", "Use --test-paramiko to run Paramiko tests" ) - -def pytest_configure(config): - """ - Automatically disable the cache provider if the current - directory is not writable. - """ - - # Check if we have write access to the directory where we are running - if not os.access('.', os.W_OK): - # We explicitly block the 'cacheprovider' plugin - # This prevents the PermissionError in read-only environments - config.pluginmanager.set_blocked("cacheprovider") - - # Print a note so users know why it's disabled - print("\n[Config] Read-only directory detected. Cache provider disabled.") From 461a988ee8b3c2a775f54be147145d8f7bda90e1 Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Tue, 7 Jul 2026 15:32:51 +0200 Subject: [PATCH 39/73] Removing documentation references to broadcaster --- docs/Messaging-format.md | 57 ------------------------------- docs/Process-manager-interface.md | 2 +- docs/Process-manager.md | 4 +-- docs/Unified-shell-reference.md | 1 - 4 files changed, 2 insertions(+), 62 deletions(-) diff --git a/docs/Messaging-format.md b/docs/Messaging-format.md index 9a048c783..3a3a196de 100644 --- a/docs/Messaging-format.md +++ b/docs/Messaging-format.md @@ -79,14 +79,12 @@ message Description { string name = 2; optional string session = 3; repeated CommandDescription commands = 4; - optional google.protobuf.Any broadcast = 5; } ``` * `type` can be `process_manager` or `controller` right now it is a string. * `name` is the name of the server. * `session` is the (optional) session name. * `commands` is a vector of acceptable commands to send to the endpoint. -* `broadcast` is a description of the broadcast service. `CommandDescription` is used to describe all the commands, it has the following format: ``` @@ -102,58 +100,3 @@ message CommandDescription { * `help` is a string providing help * `return_type` is the format of the `data` field that should be expected inside the `Response` if the command execution was successful. -For broadcasting, there is right now only one format that the `describe` command can fill, with a description of the Kafka service that is used to broadcast the log messages: -``` -message KafkaBroadcastHandlerConfiguration{ - string kafka_address = 1; - string topic = 2; -} -``` -* `kafka_address` is a bootstrap server -* `topic` is the Kafka topic on which this service is logging. - -More formats of broadcasting may be added in the future (potentially using [ERS's python binding](https://github.com/DUNE-DAQ/erskafka/tree/develop/python/erskafka)). - -# Broadcasting -As mentioned earlier, the only working solution for broadcasting is Kafka. All messages feed to Kafka by drunc have the [form](https://github.com/DUNE-DAQ/druncschema/blob/develop/schema/druncschema/broadcast.proto#L51): -``` -message BroadcastMessage{ - Emitter emitter = 1; - BroadcastType type = 2; - google.protobuf.Any data = 3; -} -``` -Where: -``` -message Emitter { - string process = 1; - string session = 2; -} -``` -and -``` -enum BroadcastType { - ACK = 0; - RECEIVER_REMOVED = 1; - RECEIVER_ADDED = 2; - SERVER_READY = 3; - SERVER_SHUTDOWN = 4; - TEXT_MESSAGE = 15; - COMMAND_EXECUTION_START = 5; - COMMAND_RECEIVED = 16; - COMMAND_EXECUTION_SUCCESS = 6; - EXCEPTION_RAISED = 7; - UNHANDLED_EXCEPTION_RAISED = 8; - STATUS_UPDATE = 9; - SUBPROCESS_STATUS_UPDATE = 10; - DEBUG = 11; - CHILD_COMMAND_EXECUTION_START = 12; - CHILD_COMMAND_EXECUTION_SUCCESS = 13; - CHILD_COMMAND_EXECUTION_FAILED = 14; - FSM_STATUS_UPDATE = 17; -} -``` - -Note that for now, the `data` field is only filled with [PlainText](https://github.com/DUNE-DAQ/druncschema/blob/develop/schema/druncschema/generic.proto#L7C1-L9C2) messages. - -In the future, this may change. diff --git a/docs/Process-manager-interface.md b/docs/Process-manager-interface.md index 90e03b7c1..bd97b814f 100644 --- a/docs/Process-manager-interface.md +++ b/docs/Process-manager-interface.md @@ -193,7 +193,7 @@ message ExceptionNotification { Each RPC call is described here. ### `describe` -Returns metadata about the process manager, including available commands, session info, and broadcast description. +Returns metadata about the process manager, including available commands, and session info. * input: `Request` * output: [Description](https://dune-daq-sw.readthedocs.io/en/latest/packages/drunc/Messaging-format) diff --git a/docs/Process-manager.md b/docs/Process-manager.md index b661f39d2..eeafdf59f 100644 --- a/docs/Process-manager.md +++ b/docs/Process-manager.md @@ -7,7 +7,7 @@ For a standalone `process_manager` you will need two shells - one shell to run t To boot a `process_manager`, you will need to choose the most appropriate configuration that applies to the use case. The configurations that are packaged with `drunc` are defined in `drunc/src/data/process_manager/`, which are * `ssh-standalone.json`: `ssh` based implementation without a `kafka` feed. Uses remote shell client processes to manage ssh connections. * `ssh-standalone-paramiko-client.json`: `ssh` based implementation without a `kafka` feed. Uses paramiko library to manage ssh connections from the python process. Currently NOT maintained but may be revisited in future - use `ssh-standalone` only for now. -* `ssh-pocket-kafka.json`: `ssh` based implementation with `pocket`'s `kafka` for message broadcasting. +* `ssh-pocket-kafka.json`: `ssh` based implementation with `pocket`'s `kafka`. * `ssh-CERN-kafka.json`: `ssh` based implementation with `kafka` service running at ENH1. * `ssh-CERN-kafka-OpMon.json`: `ssh` based implementation with `kafka` service running at ENH1, and with Opmon. * `k8s.json`: `kubernetes` implementation (not recommended nor working, so don't use this unless you are an working on getting it to work). @@ -26,8 +26,6 @@ To start the ssh version without kafka: drunc-process-manager ssh-standalone Using 'file://src/drunc/data/process_manager/ssh-standalone.json' as the ProcessManager configuration Starting 'SSHProcessManager' -[12:43:26] INFO "BroadcastSenderConfHandler": None configuration.py:25 - INFO "Controller": DummyAuthoriser ready dummy_authoriser.py:13 ProcessManager was started on np04-srv-019:10054 ``` Once this is done, you will not be able to send commands to the process from the current shell with the `process_manager` acting in the foreground. To interact with a standalone instance of `process_manager` you will need to connect to it (see below). diff --git a/docs/Unified-shell-reference.md b/docs/Unified-shell-reference.md index 2cf1c9c6a..bc2085672 100644 --- a/docs/Unified-shell-reference.md +++ b/docs/Unified-shell-reference.md @@ -414,7 +414,6 @@ logs -n root-controller --how-far 5 INFO "Controller": 'df-controller@localhost:5600' (type ChildNodeType.gRPC) controller.py:123 INFO "Controller": 'trg-controller@localhost:5700' (type ChildNodeType.gRPC) controller.py:123 INFO "Controller": 'hsi-controller@localhost:5800' (type ChildNodeType.gRPC) controller.py:123 - INFO "Broadcast": ready broadcast_sender.py:65 root-controller was started on localhost:3333 ───────────────────────────────────────────────────────────────────────────────────────────── End ────────────────────────────────────────────────────────────────────────────────────────────── ``` From f1e272a86ca2893cbfb3fec89ba0b3f7c73ad600 Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Tue, 7 Jul 2026 15:36:42 +0200 Subject: [PATCH 40/73] Making docs check run on every PR --- .github/workflows/check_docs.yml | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/check_docs.yml b/.github/workflows/check_docs.yml index 546c22a84..40ea29528 100644 --- a/.github/workflows/check_docs.yml +++ b/.github/workflows/check_docs.yml @@ -2,16 +2,16 @@ name: Check Links In Docs on: pull_request: - paths: - - 'docs/**' - workflow_dispatch: - workflow_call: - schedule: - - cron: "0 0 * * 6" # midnight every Saturday + workflow_dispatch: + workflow_call: + schedule: + - cron: "0 0 * * 6" # Runs every Saturday at midnight jobs: check-links: runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout code uses: actions/checkout@v4 @@ -19,5 +19,9 @@ jobs: - name: Run Lychee link checker uses: lycheeverse/lychee-action@v2 with: - args: docs/ - \ No newline at end of file + args: > + --exclude 'https://twiki.cern.ch/.*' + --exclude 'https://indico.*' + --exclude 'https://wiki.dunescience.org/wiki/.*' + --exclude 'https://github.com/DUNE-DAQ/drunc/wiki/Archive:.*' + docs/ \ No newline at end of file From e4d7c6e7005467145bfc9a1499a353f9f88ebcda Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Wed, 8 Jul 2026 16:28:08 +0200 Subject: [PATCH 41/73] Removing stale broadcaster code --- src/drunc/process_manager/process_manager.py | 1 + src/drunc/unified_shell/context.py | 14 -------------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/src/drunc/process_manager/process_manager.py b/src/drunc/process_manager/process_manager.py index ba7cec9dc..71eff1306 100644 --- a/src/drunc/process_manager/process_manager.py +++ b/src/drunc/process_manager/process_manager.py @@ -72,6 +72,7 @@ def __init__( self.name = name self.session = session + self.log.debug("Setting up dummy authoriser") dach = DummyAuthoriserConfHandler.from_pyobject( data=self.configuration.authoriser ) diff --git a/src/drunc/unified_shell/context.py b/src/drunc/unified_shell/context.py index 3824584f2..87963e605 100644 --- a/src/drunc/unified_shell/context.py +++ b/src/drunc/unified_shell/context.py @@ -75,20 +75,6 @@ def create_token(self, **kwargs) -> Token: token = create_dummy_token_from_uname() return token - def start_listening_pm(self, broadcaster_conf) -> None: - from drunc.broadcast.client.broadcast_handler import BroadcastHandler - from drunc.broadcast.client.configuration import BroadcastClientConfHandler - - bcch = BroadcastClientConfHandler.from_pbany(data=broadcaster_conf) - self.status_receiver_pm = BroadcastHandler(broadcast_configuration=bcch) - - def start_listening_controller(self, broadcaster_conf) -> None: - from drunc.broadcast.client.broadcast_handler import BroadcastHandler - from drunc.broadcast.client.configuration import BroadcastClientConfHandler - - bcch = BroadcastClientConfHandler.from_pbany(data=broadcaster_conf) - self.status_receiver_controller = BroadcastHandler(broadcast_configuration=bcch) - def get_endpoint_display_host_overrides(self) -> dict[str, str]: """ Return a mapping of process name -> preferred display hostname for endpoint From c44dc01b8d7dead7698e9a1600c45806cc7242b4 Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Wed, 8 Jul 2026 17:16:02 +0200 Subject: [PATCH 42/73] Updates to fix develop --- src/drunc/process_manager/process_manager.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/drunc/process_manager/process_manager.py b/src/drunc/process_manager/process_manager.py index 71eff1306..524f6b800 100644 --- a/src/drunc/process_manager/process_manager.py +++ b/src/drunc/process_manager/process_manager.py @@ -72,15 +72,12 @@ def __init__( self.name = name self.session = session - self.log.debug("Setting up dummy authoriser") dach = DummyAuthoriserConfHandler.from_pyobject( data=self.configuration.authoriser ) - self.opmon_publisher = getattr( - self.configuration.get_data(), "opmon_publisher", None - ) - interval_s = getattr(self.configuration.get_data(), "interval_s", 10.0) + self.opmon_publisher = self.configuration.opmon_publisher + interval_s = self.configuration.opmon_conf["interval_s"] self.authoriser = DummyAuthoriser(dach, SystemType.PROCESS_MANAGER) self.process_store = {} # dict[str, sh.RunningCommand] # str = uuid From 8af507db2ca10efa80938917c1931179941e6206 Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Wed, 8 Jul 2026 17:33:42 +0200 Subject: [PATCH 43/73] Fixed tests --- tests/process_manager/process_manager_mock_impls.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/process_manager/process_manager_mock_impls.py b/tests/process_manager/process_manager_mock_impls.py index d7f83c9c8..831ac2c38 100644 --- a/tests/process_manager/process_manager_mock_impls.py +++ b/tests/process_manager/process_manager_mock_impls.py @@ -38,7 +38,8 @@ def __init__( """ all-default constructor for testing purposes. """ - configuration.get_data().opmon_publisher = None + configuration.opmon_conf = {"level": "info", "interval_s": 10.0} + configuration.opmon_publisher = None super().__init__(configuration, name, session, **kwargs) def _not_implemented_response(self): From 856bd173d27fb6ca165f125d59a7a848f069f8fc Mon Sep 17 00:00:00 2001 From: Pawel Plesniak <56116795+PawelPlesniak@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:46:27 +0100 Subject: [PATCH 44/73] Pawel plesniak emuhammad/split shell fixing (#888) * Add logging calls * Add multi user support * Update terminate and ps commands * Add split shell support * Add multiuser support * remove broadcaster again * Ruff correction * Cleanup * K8s session name check re-introduced, LCS configs working * Pytest working * Reordering the position to parse the address * The k8s-specific naming format check now does not cause the PMaaS operaiton to fail * minor cleanup --------- Co-authored-by: PawelPlesniak Co-authored-by: Emir Muhammad --- src/drunc/controller/controller.py | 39 ++++-- src/drunc/controller/interface/context.py | 2 + src/drunc/process_manager/configuration.py | 3 +- .../process_manager/interface/cli_argument.py | 27 ++-- .../process_manager/interface/commands.py | 132 +++++++++++++----- .../process_manager/interface/context.py | 4 +- src/drunc/process_manager/interface/shell.py | 11 +- .../process_manager/k8s_process_manager.py | 15 ++ src/drunc/process_manager/oks_parser.py | 9 +- src/drunc/process_manager/process_manager.py | 83 +++++++++-- .../process_manager/process_manager_driver.py | 110 ++++++++++++--- .../process_manager/ssh_process_manager.py | 25 +++- src/drunc/process_manager/utils.py | 3 + .../ssh_process_lifetime_manager_shell.py | 88 +++++++++--- src/drunc/unified_shell/commands.py | 111 ++++++++++++++- src/drunc/unified_shell/context.py | 5 +- src/drunc/unified_shell/shell.py | 97 ++++++++----- src/drunc/utils/shell_utils.py | 34 +++++ src/drunc/utils/utils.py | 105 ++++++++++++++ tests/conftest.py | 4 + tests/process_manager/conftest.py | 7 +- .../interface/test_commands.py | 12 +- .../process_manager_mock_impls.py | 10 ++ .../test_process_manager_driver.py | 2 +- ...est_ssh_process_lifetime_manager_common.py | 7 + tests/utils/test_utils.py | 7 + 26 files changed, 789 insertions(+), 163 deletions(-) diff --git a/src/drunc/controller/controller.py b/src/drunc/controller/controller.py index 7e076fbcc..de3ac41c2 100644 --- a/src/drunc/controller/controller.py +++ b/src/drunc/controller/controller.py @@ -1,4 +1,4 @@ -import multiprocessing +import os import threading import time from concurrent.futures import ThreadPoolExecutor @@ -129,17 +129,29 @@ def __init__(self, configuration, name: str, session: str, token: Token): self.connectivity_service_thread = None self.uri = "" if self.configuration.session.connectivity_service: - connection_server = self.configuration.session.connectivity_service.host - connection_port = ( - self.configuration.session.connectivity_service.service.port + # Remaps the localhost into the correct server + # and also grabs the correct port from the right environment from the config + + connection_server_host = ( + self.configuration.session.connectivity_service.host ) + connection_port = os.getenv("CONNECTION_PORT") + if connection_server_host == "localhost": + injected_hostname = os.getenv("DRUNC_HOST_NAME") + if not injected_hostname: + raise ValueError("DRUNC_HOST_NAME environment variable is not set.") + self.log.debug( + f"Remapping connectivity service host from 'localhost' to '{injected_hostname}'" + ) + connection_server_host = injected_hostname + log_init.info( - f"Connectivity server {connection_server}:{connection_port} is enabled" + f"Connectivity server {connection_server_host}:{connection_port} is enabled" ) self.connectivity_service = ConnectivityServiceClient( session=self.session, - address=f"{connection_server}:{connection_port}", + address=f"{connection_server_host}:{connection_port}", ) def init_controller(self) -> None: @@ -326,6 +338,13 @@ def advertise_control_address(self, address): if not self.connectivity_service: return + self.log.debug( + f"Looking for connectivity service at address {self.connectivity_service.address}" + ) + if not self.connectivity_service.is_ready(timeout=20): + raise ValueError( + "Connectivity service unavailable for control address advertising." + ) self.log.info( f"Registering {self.name} ({address}) to the connectivity service at {self.connectivity_service.address}" ) @@ -384,14 +403,6 @@ def terminate(self): except Exception as e: self.log.warning(f"Error stopping opmon publisher: {e}") - self.log.debug("Threading threads") - for t in threading.enumerate(): - self.log.debug(f"{t.name} TID: {t.native_id} is_alive: {t.is_alive}") - - with multiprocessing.Manager() as manager: - self.log.debug("Multiprocess threads") - self.log.debug(manager.list()) - def __del__(self): self.terminate() diff --git a/src/drunc/controller/interface/context.py b/src/drunc/controller/interface/context.py index 8da69f38e..3468edce6 100644 --- a/src/drunc/controller/interface/context.py +++ b/src/drunc/controller/interface/context.py @@ -11,6 +11,8 @@ class ControllerContext(ShellContext): # boilerplatefest + shell_id = "controller_shell" + def __init__(self): self.status_receiver = None self.took_control = False diff --git a/src/drunc/process_manager/configuration.py b/src/drunc/process_manager/configuration.py index 5eedbaa7c..bcbefeb0d 100644 --- a/src/drunc/process_manager/configuration.py +++ b/src/drunc/process_manager/configuration.py @@ -15,7 +15,7 @@ from drunc.exceptions import DruncCommandException from drunc.process_manager.exceptions import UnknownProcessManagerType from drunc.utils.configuration import ConfHandler -from drunc.utils.utils import get_logger +from drunc.utils.utils import get_logger, touch_and_chmod if TYPE_CHECKING: import conffwk @@ -110,6 +110,7 @@ def _post_process_oks(self) -> None: opmon_conf, ) else: + touch_and_chmod(opmon_conf.path) self.opmon_publisher = OpMonPublisher( conf=opmon_conf, rich_handler=True ) diff --git a/src/drunc/process_manager/interface/cli_argument.py b/src/drunc/process_manager/interface/cli_argument.py index 27e84872f..abefd002c 100644 --- a/src/drunc/process_manager/interface/cli_argument.py +++ b/src/drunc/process_manager/interface/cli_argument.py @@ -7,15 +7,10 @@ def validate_conf_string(ctx, param, boot_configuration): return boot_configuration -def add_query_options(at_least_one: bool, all_processes_by_default: bool = False): - def wrapper(f0): - f1 = click.option( - "-s", - "--session", - type=str, - default=None, - help="Select the processes on a particular session", - )(f0) +def add_query_options_no_session( + at_least_one: bool, all_processes_by_default: bool = False +): + def wrapper(f1): f2 = click.option( "-n", "--name", @@ -41,3 +36,17 @@ def wrapper(f0): return generate_process_query(f4, at_least_one, all_processes_by_default) return wrapper + + +def add_query_options(at_least_one: bool, all_processes_by_default: bool = False): + def wrapper(f0): + f1 = click.option( + "-s", + "--session", + type=str, + default=None, + help="Select the processes on a particular session", + )(f0) + return add_query_options_no_session(at_least_one, all_processes_by_default)(f1) + + return wrapper diff --git a/src/drunc/process_manager/interface/commands.py b/src/drunc/process_manager/interface/commands.py index 129f3ea78..0d2cd3c43 100644 --- a/src/drunc/process_manager/interface/commands.py +++ b/src/drunc/process_manager/interface/commands.py @@ -1,4 +1,5 @@ import getpass +from time import sleep import click from druncschema.process_manager_pb2 import LogRequest, ProcessQuery @@ -11,8 +12,8 @@ ) from drunc.process_manager.interface.context import ProcessManagerContext from drunc.process_manager.utils import tabulate_process_instance_list -from drunc.utils.shell_utils import InterruptedCommand -from drunc.utils.utils import get_logger +from drunc.utils.shell_utils import InterruptedCommand, log_pm_cmd +from drunc.utils.utils import get_logger, resolve_context_peer @click.command("boot") @@ -43,11 +44,15 @@ def boot( override_logs: bool, ) -> None: log = get_logger("process_manager.shell") - processes = obj.get_driver("process_manager").ps(ProcessQuery(user=user)) + log_pm_cmd(obj) + processes = obj.get_driver("process_manager").ps( + ProcessQuery(user=user, session=session_name) + ) + # The run control will validate this in the session manager in the future if len(processes.values) > 0: click.confirm( - f"You already have {len(processes.values)} processes running, are you sure you want to boot a session?", + f"You already have {len(processes.values)} processes running for {session_name}, are you sure you want to boot a session?", abort=True, ) @@ -76,6 +81,7 @@ def boot( raise e controller_address = obj.get_driver("process_manager").controller_address + controller_address = resolve_context_peer(controller_address) if controller_address: obj.print( Panel( @@ -129,6 +135,7 @@ def dummy_boot( session_name: str, ) -> None: log = get_logger("process_manager.shell") + log_pm_cmd(obj) log.debug( f"Running dummy_boot with {n_processes} processes for {sleep} seconds {n_sleeps} times, requested by user {user}" ) @@ -150,6 +157,16 @@ def dummy_boot( return +@click.command("wait") +@click.argument("sleep_time", type=int, default=1) +@click.pass_obj +def wait(obj: ProcessManagerContext, sleep_time: int) -> None: + log = get_logger("process_manager.wait") + log.info(f"Command [green]wait[/green] running for {sleep_time} seconds.") + sleep(sleep_time) # seconds + log.info(f"Command [green]wait[/green] ran for {sleep_time} seconds.") + + @click.command("terminate") @click.option( "-w", @@ -161,6 +178,7 @@ def dummy_boot( @click.pass_obj def terminate(obj: ProcessManagerContext, width: int | None) -> None: log = get_logger("process_manager.shell") + log_pm_cmd(obj) log.debug("Terminating") result = obj.get_driver("process_manager").terminate() if not result: @@ -171,23 +189,35 @@ def terminate(obj: ProcessManagerContext, width: int | None) -> None: obj.delete_driver("controller") +def kill_decorators(f): + f = click.pass_obj(f) + f = click.option( + "-w", + "--width", + type=int, + default=None, + help="Table width. Default is automatically calculated", + )(f) + f = click.option( + "--crash", + is_flag=True, + default=False, + help="Simulate a crash: send SIGKILL without any cleanup, leaving the process manager in an unexpected-death state.", + )(f) + return f + + @click.command("kill") -@click.option( - "-w", - "--width", - type=int, - default=None, - help="Table width. Default is automatically calculated", -) @add_query_options(at_least_one=True) -@click.option( - "--crash", - is_flag=True, - default=False, - help="Simulate a crash: send SIGKILL without any cleanup, leaving the process manager in an unexpected-death state.", -) -@click.pass_obj -def kill(obj: ProcessManagerContext, query: ProcessQuery, width: int | None) -> None: +@kill_decorators +def kill(obj, query, width): + log_pm_cmd(obj) + return kill_impl(obj, query, width) + + +def kill_impl( + obj: ProcessManagerContext, query: ProcessQuery, width: int | None +) -> None: log = get_logger("process_manager.shell") log.debug(f"Killing with query {query}") result = obj.get_driver("process_manager").kill(query) @@ -198,17 +228,27 @@ def kill(obj: ProcessManagerContext, query: ProcessQuery, width: int | None) -> ) # rich tables require console printing +def flush_decorators(f): + f = click.pass_obj(f) + f = click.option( + "-w", + "--width", + type=int, + default=None, + help="Table width. Default is automatically calculated", + )(f) + return f + + @click.command("flush") -@click.option( - "-w", - "--width", - type=int, - default=None, - help="Table width. Default is automatically calculated", -) @add_query_options(at_least_one=False, all_processes_by_default=True) -@click.pass_obj -def flush( +@flush_decorators +def flush(obj, query, width): + log_pm_cmd(obj) + return flush_impl(obj, query, width) + + +def flush_impl( obj: ProcessManagerContext, query: ProcessQuery, width: int | None, @@ -223,18 +263,28 @@ def flush( ) # rich tables require console printing +def logs_decorators(f): + f = click.pass_obj(f) + f = click.option("--grep", type=str, default=None)(f) + f = click.option( + "--how-far", + type=int, + show_default=True, + default=100, + help="How many lines one wants", + )(f) + return f + + @click.command("logs") @add_query_options(at_least_one=True) -@click.option( - "--how-far", - type=int, - show_default=True, - default=100, - help="How many lines one wants", -) -@click.option("--grep", type=str, default=None) -@click.pass_obj -def logs( +@logs_decorators +def logs(obj, how_far, grep, query): + log_pm_cmd(obj) + return logs_impl(obj, how_far, grep, query) + + +def logs_impl( obj: ProcessManagerContext, how_far: int, grep: str, query: ProcessQuery ) -> None: log = get_logger("process_manager.shell") @@ -276,6 +326,11 @@ def logs( @add_query_options(at_least_one=True) @click.pass_obj def restart(obj: ProcessManagerContext, query: ProcessQuery) -> None: + log_pm_cmd(obj) + return restart_impl(obj, query) + + +def restart_impl(obj: ProcessManagerContext, query: ProcessQuery) -> None: log = get_logger("process_manager.shell") log.debug(f"Restarting with query {query}") obj.get_driver("process_manager").restart(query) @@ -306,6 +361,7 @@ def ps( width: int | None, ) -> None: log = get_logger("process_manager.shell") + log_pm_cmd(obj) log.debug(f"Running ps with query {query}") results = obj.get_driver("process_manager").ps(query) if not results: diff --git a/src/drunc/process_manager/interface/context.py b/src/drunc/process_manager/interface/context.py index d6a30f592..24c0ff9df 100644 --- a/src/drunc/process_manager/interface/context.py +++ b/src/drunc/process_manager/interface/context.py @@ -10,7 +10,9 @@ from drunc.utils.utils import resolve_localhost_to_hostname -class ProcessManagerContext(ShellContext): +class ProcessManagerContext(ShellContext): # boilerplatefest + shell_id = "process_manager_shell" + def __init__(self, *args, **kwargs): self.status_receiver = None super(ProcessManagerContext, self).__init__(*args, **kwargs) diff --git a/src/drunc/process_manager/interface/shell.py b/src/drunc/process_manager/interface/shell.py index 18f7962e2..b933ffcc1 100644 --- a/src/drunc/process_manager/interface/shell.py +++ b/src/drunc/process_manager/interface/shell.py @@ -14,6 +14,7 @@ ps, restart, terminate, + wait, ) from drunc.utils.grpc_utils import ServerUnreachable from drunc.utils.utils import ( @@ -59,6 +60,10 @@ def process_manager_shell(ctx, process_manager_address: str, log_level: str) -> # process_manager_shell_log.error(e.message) # TODO: Keep this for production branch, remove this from dev branch exit(1) + ctx.obj.get_driver("process_manager").send_msg( + f"{getpass.getuser()} connected from {ctx.obj.shell_id}" + ) + # Manually add file handler to process manager log # Not possible to initialise logger immediately as it requires # knowledge of the log path @@ -73,14 +78,18 @@ def process_manager_shell(ctx, process_manager_address: str, log_level: str) -> ) def cleanup(): + ctx.obj.get_driver("process_manager").send_msg( + f"{getpass.getuser()} disconnecting from {ctx.obj.shell_id}" + ) ctx.obj.terminate() - process_manager_log.warning( + process_manager_log.info( f"[green]{getpass.getuser()}[/green] disconnected from the process manager through a [green]drunc-process-manager-shell[/green]" ) ctx.call_on_close(cleanup) ctx.command.add_command(boot, "boot") + ctx.command.add_command(wait, "wait") ctx.command.add_command(terminate, "terminate") ctx.command.add_command(kill, "kill") ctx.command.add_command(flush, "flush") diff --git a/src/drunc/process_manager/k8s_process_manager.py b/src/drunc/process_manager/k8s_process_manager.py index c237b0725..fbd5d9405 100644 --- a/src/drunc/process_manager/k8s_process_manager.py +++ b/src/drunc/process_manager/k8s_process_manager.py @@ -13,6 +13,7 @@ from time import sleep, time # Local Application Imports +from druncschema.generic_pb2 import OutcomeFlag, OutcomeStatus from druncschema.process_manager_pb2 import ( BootRequest, LogLines, @@ -39,6 +40,7 @@ from drunc.process_manager.configuration import ( PROCESS_SHUTDOWN_ORDERING, ProcessManagerConfHandler, + ProcessManagerTypes, ) from drunc.process_manager.process_manager import ProcessManager from drunc.process_manager.utils import ( @@ -172,6 +174,8 @@ def run(self) -> None: class K8sProcessManager(ProcessManager): + pm_type = ProcessManagerTypes.K8s + def __init__(self, configuration: ProcessManagerConfHandler, **kwargs) -> None: """ Manages processes as Kubernetes Pods. @@ -1964,6 +1968,17 @@ def _logs_impl(self, log_request: LogRequest) -> LogLines: lines=[f"Could not retrieve logs: {e.reason}"], ) + def _send_msg_impl(self, msg: str, peer: str) -> OutcomeStatus: + # Note: currently exact same implementation as ssh manager + # Although there is room here to change as necessary + try: + self.log.info(f"{msg}; from {peer}") + except Exception as e: + self.log.error(f"Failed to receive message with exception {e}") + return OutcomeStatus(flag=OutcomeFlag.FAIL) + + return OutcomeStatus(flag=OutcomeFlag.SUCCESS) + def _boot_impl(self, boot_request: BootRequest) -> ProcessInstanceList: """ Handles the 'boot' command from the gRPC interface. diff --git a/src/drunc/process_manager/oks_parser.py b/src/drunc/process_manager/oks_parser.py index f679e7f1a..5feb6ba50 100644 --- a/src/drunc/process_manager/oks_parser.py +++ b/src/drunc/process_manager/oks_parser.py @@ -5,7 +5,7 @@ from drunc.exceptions import DruncException, DruncSetupException from drunc.process_manager.configuration import get_commandline_parameters -from drunc.utils.utils import get_logger +from drunc.utils.utils import file_is_read_only, get_logger if TYPE_CHECKING: import conffwk @@ -75,8 +75,13 @@ def get_full_db_path(db_path: str) -> str: err_str = f"No files found in DUNEDAQ_DB_PATH matching {db_path}." raise DruncSetupException(err_str) - # If multiple matches are found, take the first instance that matches. + # Prefer the first writable match; if every match is read-only, fall back to the first one. resolved_path = unique_matched_files[0] + for matched_file in unique_matched_files: + if not file_is_read_only(matched_file): + resolved_path = matched_file + break + log.debug(f"Path {db_path} resolved to {resolved_path}") return resolved_path diff --git a/src/drunc/process_manager/process_manager.py b/src/drunc/process_manager/process_manager.py index 524f6b800..4bd2bf4ee 100644 --- a/src/drunc/process_manager/process_manager.py +++ b/src/drunc/process_manager/process_manager.py @@ -7,9 +7,11 @@ from daqpytools.logging import LogHandlerConf, exceptions, setup_daq_ers_logger from druncschema.authoriser_pb2 import ActionType, SystemType from druncschema.description_pb2 import CommandDescription, Description +from druncschema.generic_pb2 import OutcomeStatus from druncschema.opmon.process_manager_pb2 import ProcessStatus from druncschema.process_manager_pb2 import ( BootRequest, + GenericNotificationMessage, LogLines, LogRequest, ProcessInstance, @@ -35,7 +37,7 @@ ProcessManagerConfHandler, ProcessManagerTypes, ) -from drunc.utils.utils import get_logger, pid_info_str +from drunc.utils.utils import get_logger, pid_info_str, resolve_context_peer class BadQuery(DruncCommandException): @@ -44,6 +46,8 @@ def __init__(self, txt): class ProcessManager(abc.ABC, ProcessManagerServicer): + pm_type = ProcessManagerTypes.Unknown # Used for describe (and possibly others) + def __init__( self, configuration: ProcessManagerConfHandler, name: str, session: str ): @@ -422,7 +426,7 @@ def describe(self, request: Request, context: ServicerContext) -> Description: self.log.debug(f"{self.name} running describe") response = Description( - type="process_manager", + type=self.pm_type.name, name=self.name, info=self.get_log_path(), session="no_session" if not self.session else self.session, @@ -478,6 +482,59 @@ def logs(self, request: LogRequest, context: ServicerContext) -> LogLines: return response + @abc.abstractmethod + def _send_msg_impl( + self, msg: str | None = None, peer: str | None = None + ) -> OutcomeStatus: + raise NotImplementedError + + @authentified_and_authorised( + action=ActionType.READ, system=SystemType.PROCESS_MANAGER + ) + def send_msg(self, request: Request, context: ServicerContext) -> OutcomeStatus: + self.log.debug(f"{self.name} running send_msg") + + try: + peer = context.peer() + peer_display = resolve_context_peer(peer) + except Exception: + self.log.warning("Could not determine caller peer", exc_info=True) + peer_display = "unknown" + + # Try to extract an optional GenericNotificationMessage from request.data + try: + if ( + request is not None + and hasattr(request, "data") + and request.data is not None + ): + gm = GenericNotificationMessage() + request.data.Unpack(gm) + msg_value = gm.message + except Exception as e: + self.log.debug( + f"Error while extracting send_msg payload: {e}", exc_info=True + ) + msg_value = "unknown payload" + + try: + response = self._send_msg_impl(msg_value, peer_display) + except NotImplementedError: + raise DruncNotImplementedException( + message="Implementation missing", + domain="ProcessManager.send_msg", + ) + except Exception as e: + context_msg = f"Unhandled exception in ProcessManager.send_msg: {e}" + self.log.exception(context_msg) + + raise DruncCommandException( + message=context_msg, + domain="ProcessManager.send_msg", + ) + + return response + def _ensure_one_process( self, uuids: list[str], in_boot_request: bool = False ) -> str: @@ -538,25 +595,27 @@ def _match_processes_against_query( # Filter processes based on query criteria processes = [] for uuid in available_uuids: - accepted = False + accepted = True meta = boot_request_dict[uuid].process_description.metadata # Check UUID match - if uuid in uuid_selector: - accepted = True + if uuid_selector and uuid not in uuid_selector: + accepted = False # Check name pattern match (regex) - for name_reg in name_selector: - if re.search(name_reg, meta.name): - accepted = True + + if name_selector and not any( + re.search(reg, meta.name) for reg in name_selector + ): + accepted = False # Check session match - if session_selector == meta.session: - accepted = True + if session_selector and session_selector != meta.session: + accepted = False # Check user match - if user_selector == meta.user: - accepted = True + if user_selector and user_selector != meta.user: + accepted = False if accepted: processes.append(uuid) diff --git a/src/drunc/process_manager/process_manager_driver.py b/src/drunc/process_manager/process_manager_driver.py index 1f9e50aeb..48c37b1c9 100644 --- a/src/drunc/process_manager/process_manager_driver.py +++ b/src/drunc/process_manager/process_manager_driver.py @@ -7,6 +7,7 @@ from collections.abc import Iterator from time import sleep from typing import Dict, List +from urllib.parse import urlparse import conffwk import grpc @@ -16,6 +17,7 @@ from druncschema.description_pb2 import Description from druncschema.process_manager_pb2 import ( BootRequest, + GenericNotificationMessage, LogLines, LogRequest, ProcessDescription, @@ -48,6 +50,7 @@ resolve_localhost_and_127_ip_to_network_ip, resolve_localhost_to_hostname, strip_non_drunc_loggers, + touch_and_chmod, ) @@ -83,7 +86,35 @@ def close(self) -> None: except Exception as e: self.log.error(f"Error closing gRPC channel: {e}", exc_info=True) + def send_msg(self, msg): + request = Request(token=copy_token(self.token)) + + if msg is not None: + try: + gm = GenericNotificationMessage(message=str(msg)) + request.data.Pack(gm) + except Exception: + self.log.critical("Failed to pack send_msg payload", exc_info=True) + + timeout = 10 + + try: + response = self.stub.send_msg(request, timeout=timeout) + except grpc.RpcError as e: + try: + error_details = extract_grpc_rich_error(e) + self.log.error(error_details) + except Exception as extraction_error: + self.log.critical( + f"Could not extract rich error details from gRPC error: {extraction_error}", + exc_info=True, + ) + handle_grpc_error(e) + + return response + # ----- Boot workflow ----- + def boot( self, conf_file: str, @@ -100,6 +131,9 @@ def boot( ) -> Iterator[ProcessInstanceList] | None: self.log.info(f"Booting session [green]{session_name}[/green]") + # Assume oksconflibs if no framework is defined + conf_file = f"oksconflibs:{conf_file}" if ":" not in conf_file else conf_file + # Step 1 - consolidate configuration self._consolidate_config(session_name, conf_file) @@ -109,6 +143,9 @@ def boot( # Step 3 - check for port conflicts and update configuration/DAL as needed db, session_dal = self.check_port_conflicts(db, session_dal) + # step 3.5 update localhost mapping + session_dal = self.resolve_localhost(session_dal) + # Step 4 - connect to the connection service csc, connection_server, connection_port = self._connect_to_service( session_dal, session_name @@ -158,6 +195,24 @@ def boot( previous_host = this_host last_boot_on_host_at[this_host] = time.time() + # ensures users can access the opmon files (permissions) + # This is for the opmon files of the apps + + if session_dal.opmon_uri.type == "file": + # For future, this should probably be taken from the metadata + opmon_file = ( + f"{request.process_description.process_execution_directory}/info." + + request.process_description.metadata.session + + "." + + request.process_description.metadata.name + + ".json" + ) + + self.log.debug( + f"Touching and changing permissions for {opmon_file} because opmon is of type {session_dal.opmon_uri.type}" + ) + touch_and_chmod(opmon_file) + try: response = self.stub.boot(request, timeout=timeout) yield response @@ -255,7 +310,10 @@ def _build_boot_request( override_logs: bool, pwd: str, ) -> BootRequest: - host = format_hostname(app["restriction"]) + # Run mapping to physical hostname to enable multi host usage + host = resolve_localhost_to_hostname(format_hostname(app["restriction"])) + + # this is one of the two minimal changes needed to get this working in general? name = app["name"] exe = app["type"] args = app["args"] @@ -265,6 +323,13 @@ def _build_boot_request( env["DUNE_DAQ_BASE_RELEASE"] = os.getenv("DUNE_DAQ_BASE_RELEASE") env["SPACK_RELEASES_DIR"] = os.getenv("SPACK_RELEASES_DIR") tree_id = app["tree_id"] + + # The following line is required to provide an independent method of injecting + # the hostname into the environment for applications that need it. This is the + # case for containerized applications, for which the hostname is not + # automatically injected into the environment, and standard methods like + # socket.gethostname() do not return the expected value. + env["DRUNC_HOST_NAME"] = host self.log.debug(f"{name}:\n{json.dumps(app, indent=4)}") try: @@ -371,19 +436,29 @@ def _consolidate_config(self, session_name, conf_file: str) -> str | None: ) return - def update_connectivity_port_dal( - self, - env_variables: list["conffwk.dal.Variable | conffwk.dal.VariableSet"], - new_port: int, - ) -> None: - """Process a dal::Variable object, placing key/value pairs in a dictionary""" - for item in env_variables: - if item.className() == "VariableSet": - self.update_connectivity_port_dal(item.contains, new_port) - else: - if item.className() == "Variable": - if item.name == "CONNECTION_PORT": - item.value = new_port + def resolve_localhost(self, session_dal): + def dal_localhost_mapping(dal_host: str): + if dal_host != "localhost": + return dal_host + + resolved_address = resolve_localhost_to_hostname(dal_host) + if "://" not in resolved_address: + resolved_address = "grpc://" + resolved_address + + resolved_server = urlparse(resolved_address).hostname + self.log.debug( + f"Resolved connection server 'localhost' to '{resolved_server}' to avoid K8s hairpinning." + ) + return resolved_server + + session_dal.connectivity_service.host = dal_localhost_mapping( + session_dal.connectivity_service.host + ) + session_dal.segment.controller.runs_on.runs_on.id = dal_localhost_mapping( + session_dal.segment.controller.runs_on.runs_on.id + ) + + return session_dal def check_port_conflicts( self, db: conffwk.Configuration, session_dal: "conffwk.dal.Session" @@ -513,13 +588,6 @@ def _connect_to_service( connection_server = session_dal.connectivity_service.host connection_port = session_dal.connectivity_service.service.port - if connection_server == "localhost": - resolved_server = resolve_localhost_to_hostname(connection_server) - self.log.debug( - f"Resolved connection server 'localhost' to '{resolved_server}' to avoid K8s hairpinning." - ) - connection_server = resolved_server - client = ConnectivityServiceClient( session_name, f"{connection_server}:{connection_port}" ) diff --git a/src/drunc/process_manager/ssh_process_manager.py b/src/drunc/process_manager/ssh_process_manager.py index 24b14356e..7de746666 100644 --- a/src/drunc/process_manager/ssh_process_manager.py +++ b/src/drunc/process_manager/ssh_process_manager.py @@ -3,6 +3,7 @@ import uuid from typing import List, Optional +from druncschema.generic_pb2 import OutcomeFlag, OutcomeStatus from druncschema.process_manager_pb2 import ( BootRequest, LogLines, @@ -17,12 +18,15 @@ from druncschema.request_response_pb2 import ResponseFlag from drunc.exceptions import DruncCommandException +from drunc.process_manager.configuration import ProcessManagerTypes from drunc.process_manager.process_manager import ProcessManager from drunc.processes.exit_status import ExitStatus from drunc.processes.ssh_process_lifetime_manager import ProcessLifetimeManager class SSHProcessManager(ProcessManager): + pm_type = ProcessManagerTypes.SSH_SHELL + def __init__( self, configuration, LifetimeManagerClass: ProcessLifetimeManager, **kwargs ): @@ -360,6 +364,9 @@ def __boot(self, boot_request: BootRequest, uuid: str) -> ProcessInstance: # Update hostname in boot request for this attempt self.boot_request[uuid].process_description.metadata.hostname = host + self.log.debug( + f"Attempting to start process {uuid} on host {host} via SSH lifetime manager" + ) # Start the process via SSH manager self.ssh_lifetime_manager.start_process( uuid=uuid, boot_request=self.boot_request[uuid] @@ -380,7 +387,7 @@ def __boot(self, boot_request: BootRequest, uuid: str) -> ProcessInstance: self.log.info( f"Booted '{boot_request.process_description.metadata.name}' " f"from session '{boot_request.process_description.metadata.session}' " - f"with UUID {uuid}" + f"with UUID {uuid} on host {hostname}" ) # Query current process status @@ -485,13 +492,25 @@ def _ps_impl(self, query: ProcessQuery) -> ProcessInstanceList: else: pi.remote_pid = remote_pid_result.reason ret += [pi] - - return ProcessInstanceList( + ret_fmt = ProcessInstanceList( name=self.name, token=None, values=ret, flag=ResponseFlag.EXECUTED_SUCCESSFULLY, ) + self.log.debug( + f"{self.name} returning {len(ret)} processes from ps query {query}" + ) + return ret_fmt + + def _send_msg_impl(self, msg: str, peer: str) -> OutcomeStatus: + try: + self.log.info(f"{msg}; from {peer}") + except Exception as e: + self.log.error(f"Failed to receive message with exception {e}") + return OutcomeStatus(flag=OutcomeFlag.FAIL) + + return OutcomeStatus(flag=OutcomeFlag.SUCCESS) def _boot_impl(self, boot_request: BootRequest) -> ProcessInstanceList: self.log.debug(f"{self.name} running boot command") diff --git a/src/drunc/process_manager/utils.py b/src/drunc/process_manager/utils.py index 8793cc8d4..f314af441 100644 --- a/src/drunc/process_manager/utils.py +++ b/src/drunc/process_manager/utils.py @@ -334,6 +334,9 @@ def validate_k8s_session_name(session: str) -> bool: return True +#! Note for future developers +# This can probably be removed since we've added the +# pm_type attribute in each of the process managers def get_pm_type_from_name(pm_name: str) -> ProcessManagerTypes: """ Get the ProcessManagerTypes enum value from a string name. diff --git a/src/drunc/processes/ssh_process_lifetime_manager_shell.py b/src/drunc/processes/ssh_process_lifetime_manager_shell.py index 7d25a3ff1..1d53203dc 100644 --- a/src/drunc/processes/ssh_process_lifetime_manager_shell.py +++ b/src/drunc/processes/ssh_process_lifetime_manager_shell.py @@ -426,6 +426,9 @@ def start_process(self, uuid: str, boot_request: BootRequest) -> None: hostname = boot_request.process_description.metadata.hostname user = boot_request.process_description.metadata.user log_file = boot_request.process_description.process_logs_path + self.log.debug( + f"Starting process {uuid} on {hostname} as {user} with log file {log_file}" + ) # Extract environment variables from boot request env_vars = ( @@ -439,13 +442,18 @@ def start_process(self, uuid: str, boot_request: BootRequest) -> None: for exe_arg in boot_request.process_description.executable_and_arguments: cmd += exe_arg.exec for arg in exe_arg.args: - cmd += f" {arg}" + if arg.endswith("daq_app_rte.sh"): + cmd += f" {os.getenv('DBT_AREA_ROOT')}/install/daq_app_rte.sh" + else: + cmd += f" {arg}" cmd += ";" # Remove trailing semicolon if present if cmd.endswith(";"): cmd = cmd[:-1] + self.log.debug(f"Built command for {uuid}: {cmd}: {boot_request}") + # Execute the command via SSH self._execute_bootrequest_via_ssh( uuid=uuid, @@ -886,33 +894,37 @@ def _build_ssh_arguments( # Determine if host key checking should be disabled based on configuration and # target host - disable_host_key_check = self.disable_host_key_check or ( - self.disable_localhost_host_key_check - and hostname in ("localhost", "127.0.0.1", "::1") + superuser_host = getpass.getuser() + "@" + user_host.split("@")[1] + self.log.debug( + f"Building SSH arguments for {user_host} with superuser host {superuser_host}" ) # Base SSH arguments with user@host and strict host key checking disabled # StrictHostKeyChecking=no is set to as we have an nfs backed home directory and # the known_hosts file is not shared across hosts, so we cannot rely on it for # host key verification. - arguments = [user_host, "-o", "StrictHostKeyChecking=no"] + arguments = [superuser_host, "-o", "StrictHostKeyChecking=no"] + # If TTY allocation is requested, add the -tt flag to force allocation. This is + # needed as SSH permissions are different for general users and for np04daq if use_tty: arguments.append("-tt") # If host key checking is disabled, also disable known hosts file usage and - # reduce log level to avoid cluttering logs with warnings about host key verification - if disable_host_key_check: - arguments.extend( - [ - "-o", - "LogLevel=error", - "-o", - "GlobalKnownHostsFile=/dev/null", - "-o", - "UserKnownHostsFile=/dev/null", - ] - ) + # reduce log level to avoid cluttering logs with warnings about host key + # verification + arguments.extend( + [ + "-o", + "LogLevel=error", + "-o", + "GlobalKnownHostsFile=/dev/null", + "-o", + "UserKnownHostsFile=/dev/null", + ] + ) + + self.log.debug(f"SSH arguments for {user_host}: {arguments}") return arguments @@ -1031,7 +1043,12 @@ def read_process_metadata( ) arguments.append(remote_command) + # Execute SSH command to wait for and read file (single round-trip) + self.log.debug( + f"Attempting to read metadata for {uuid} from {hostname} with timeout {timeout}s" + ) result = self.ssh(*arguments) + self.log.debug(f"Raw metadata content for {uuid} from {hostname}: {result}") json_content = str(result).strip() self.log.debug(f"Metadata content for {uuid}: {json_content!r}") @@ -1123,6 +1140,9 @@ def _execute_bootrequest_via_ssh( try: platform = os.uname().sysname.lower() is_macos = "darwin" in platform + hostname_for_gssapi = hostname + if hostname_for_gssapi == "localhost": + hostname_for_gssapi = os.uname().nodename user_host = f"{user}@{hostname}" # Build remote command with metadata file writing @@ -1160,6 +1180,7 @@ def _execute_bootrequest_via_ssh( remote_cmd += ( f"mkdir -p ${{XDG_RUNTIME_DIR:-/tmp}}/drunc ; " + f"rm {log_file}; " # delete log file so no issues on ovewriting in th next line f"{command} &> {log_file} & PID=$! ; " f"trap 'kill -HUP $PID 2>/dev/null || true; wait $PID 2>/dev/null || true' HUP TERM INT QUIT ; " f"echo '{remote_metadata_json}' > {metadata_file} ; " @@ -1169,6 +1190,39 @@ def _execute_bootrequest_via_ssh( arguments = self._build_ssh_arguments(hostname, user_host) arguments.append(remote_cmd) + # Test access to CMD + cd_path = f"{boot_request.process_description.process_execution_directory}" + touch_cmd = [ + arguments[0], # assume first arg is username@host + f"touch {cd_path}/.write_test && rm {cd_path}/.write_test", + ] + self.log.debug(f"running {touch_cmd} for CMD access test") + try: + access = self.ssh( + *touch_cmd, + _out=self.log.warning, + _err=self.log.error, + _bg=True, + _bg_exc=False, + _new_session=True, + _preexec_fn=on_parent_exit(signal.SIGTERM) + if not is_macos + else None, + ) + + access.wait() + if access.exit_code != 0: + raise RuntimeError("SSH error fails to finish successfully") + except Exception as e: + err_msg = ( + f"No access to {cd_path}" + "for multiusers to work, the above path needs elevated permissions for" + " the PM superuser to cd and write into. " + "Please change the permissions to allow for this." + ) + self.log.error(err_msg) + raise RuntimeError from e + process = self.ssh( *arguments, _out=self.log.debug, diff --git a/src/drunc/unified_shell/commands.py b/src/drunc/unified_shell/commands.py index f1c730126..6285ad3bf 100644 --- a/src/drunc/unified_shell/commands.py +++ b/src/drunc/unified_shell/commands.py @@ -1,14 +1,26 @@ import getpass import sys +from functools import update_wrapper import click from druncschema.process_manager_pb2 import ProcessInstance, ProcessQuery from drunc.controller.interface.shell_utils import controller_setup from drunc.exceptions import DruncSetupException +from drunc.process_manager.interface.cli_argument import add_query_options_no_session +from drunc.process_manager.interface.commands import ( + flush_decorators, + flush_impl, + kill_decorators, + kill_impl, + logs_decorators, + logs_impl, + restart_impl, +) from drunc.process_manager.interface.context import ProcessManagerContext +from drunc.process_manager.utils import tabulate_process_instance_list from drunc.unified_shell.context import UnifiedShellMode -from drunc.utils.shell_utils import InterruptedCommand +from drunc.utils.shell_utils import InterruptedCommand, log_pm_cmd from drunc.utils.utils import get_logger @@ -32,6 +44,7 @@ def boot( sleep_between_app_boot: int | float = 0, ) -> None: log = get_logger("unified_shell.boot") + log_pm_cmd(obj) session_name = obj.session_name user = getpass.getuser() processes = obj.get_driver("process_manager").ps( @@ -45,6 +58,7 @@ def boot( override_logs_boot = obj.override_logs else: override_logs_boot = override_logs + # The run control will validate this in the session manager in the future if len(processes.values) > 0: log.error( f"Cannot boot: session {session_name} already has {len(processes.values)} processes running. " @@ -124,6 +138,100 @@ def boot( sys.exit(1) +@click.command("terminate") +@click.pass_obj +@click.pass_context +def terminate(ctx, obj): + """ + Execute the process manager terminate command, but only do this for the current + session + """ + + log = get_logger("unified_shell.terminate") + log_pm_cmd(obj) + session_query = ProcessQuery(session=ctx.obj.session_name) + log.info(f"Terminating session [green]{ctx.obj.session_name}[/]") + obj.get_driver("process_manager").kill(session_query) + + # As the session is now terminated, we can delete the controller driver, as it is no + # longer needed. + obj.delete_driver("controller") + + +@click.command("ps") +@click.pass_obj +@click.pass_context +def ps(ctx, obj): + """ + Execute the process manager terminate command, but only do this for the current + session + """ + + log = get_logger("unified_shell.ps") + log_pm_cmd(obj) + session_query = ProcessQuery(session=ctx.obj.session_name) + log.info(f"Listing session [green]{ctx.obj.session_name}[/]") + results = obj.get_driver("process_manager").ps(session_query) + + # If there are processes running, tabulate them, otherwise log that there are no + # processes running. + if results.values: + obj.print( + tabulate_process_instance_list( + results, title=f"Processes running in session {ctx.obj.session_name}" + ), + overflow="fold", + soft_wrap=True, + ) + else: + log.info(f"No processes running in session [green]{ctx.obj.session_name}[/]") + + +def session_injector(f): + @click.pass_context + def wrapper(ctx, *args, **kwargs): + kwargs["session"] = ctx.obj.session_name + return ctx.invoke(f, *args, **kwargs) + + return update_wrapper(wrapper, f) + + +@click.command("logs") +@session_injector +@add_query_options_no_session(at_least_one=True) +@logs_decorators +def logs(obj, how_far, grep, query): + log_pm_cmd(obj) + return logs_impl(obj, how_far, grep, query) + + +@click.command("kill") +@session_injector +@add_query_options_no_session(at_least_one=True) +@kill_decorators +def kill(obj, query, width): + log_pm_cmd(obj) + return kill_impl(obj, query, width) + + +@click.command("flush") +@session_injector +@add_query_options_no_session(at_least_one=True) +@flush_decorators +def flush(obj, query, width): + log_pm_cmd(obj) + return flush_impl(obj, query, width) + + +@click.command("restart") +@session_injector +@add_query_options_no_session(at_least_one=True) +@click.pass_obj +def restart(obj, query): + log_pm_cmd(obj) + return restart_impl(obj, query) + + @click.command("start-shell") @click.pass_obj @click.pass_context @@ -135,6 +243,7 @@ def start_shell(ctx, obj): allowing you to execute commands interactively. """ log = get_logger("unified_shell.start_shell") + log_pm_cmd(obj) obj.running_mode = UnifiedShellMode.SEMIBATCH log.info("Switching to interactive mode...") diff --git a/src/drunc/unified_shell/context.py b/src/drunc/unified_shell/context.py index 87963e605..1e28ed084 100644 --- a/src/drunc/unified_shell/context.py +++ b/src/drunc/unified_shell/context.py @@ -7,6 +7,7 @@ from drunc.utils.grpc_utils import ServerTimeout, ServerUnreachable from drunc.utils.shell_utils import ShellContext +from drunc.utils.utils import resolve_localhost_to_hostname class UnifiedShellMode(Enum): @@ -16,6 +17,8 @@ class UnifiedShellMode(Enum): class UnifiedShellContext(ShellContext): # boilerplatefest + shell_id = "unified_shell" + def __init__(self): self.log = None self.status_receiver_pm = None @@ -33,7 +36,7 @@ def __init__(self): super(UnifiedShellContext, self).__init__() def reset(self, address_pm: str = ""): - self.address_pm = address_pm + self.address_pm = resolve_localhost_to_hostname(address_pm) super(UnifiedShellContext, self)._reset(name="unified_shell") def create_drivers(self, **kwargs) -> Mapping[str, object]: diff --git a/src/drunc/unified_shell/shell.py b/src/drunc/unified_shell/shell.py index eb8d67b5d..c939ffa33 100644 --- a/src/drunc/unified_shell/shell.py +++ b/src/drunc/unified_shell/shell.py @@ -48,17 +48,18 @@ get_process_manager_configuration, validate_pm_config, ) -from drunc.process_manager.interface.commands import ( +from drunc.process_manager.interface.process_manager import run_pm +from drunc.process_manager.utils import get_pm_type_from_name, validate_k8s_session_name +from drunc.unified_shell.commands import ( + boot, flush, kill, logs, ps, restart, + start_shell, terminate, ) -from drunc.process_manager.interface.process_manager import run_pm -from drunc.process_manager.utils import get_pm_type_from_name, validate_k8s_session_name -from drunc.unified_shell.commands import boot, start_shell from drunc.unified_shell.context import UnifiedShellMode from drunc.unified_shell.shell_utils import generate_fsm_sequence_command from drunc.utils.configuration import OKSKey @@ -154,18 +155,37 @@ def unified_shell( # Set up the drunc and unified_shell loggers get_root_logger(log_level) ctx.obj.log = get_logger("unified_shell", rich_handler=True) - ctx.obj.log.debug("Setting up the [green]unified_shell[/green] logger") + ctx.obj.log.info( + f"User {getpass.getuser()} [green]starting the unified_shell[/green]" + ) # Parse the process manager argument to determine if it's a config or an address + # If the process manager is already running, connect to it. process_manager_url: ParseResult = urlparse(process_manager) - internal_pm: bool = True if process_manager_url.scheme == "grpc": # i.e. if it's an address + ctx.obj.log.info( + f"[green]Connecting to an existing process manager[/] at the address {process_manager_url.netloc}" + ) internal_pm = False + ctx.obj.reset(address_pm=process_manager_url.netloc) + pm_type = ProcessManagerTypes[ + ctx.obj.get_driver("process_manager").describe().type + ] + ctx.obj.log.info( + f"[green]Connected to the {pm_type.name} process manager[/] running at address {process_manager_url.netloc}" + ) + else: + internal_pm = True + pm_type = get_pm_type_from_name(process_manager) + + ctx.obj.log.debug( + f"Process manager argument parsed, internal_pm set to {internal_pm}" + ) # If using a k8s process manager, validate the session name before proceeding - if get_pm_type_from_name( - process_manager - ) == ProcessManagerTypes.K8s and not validate_k8s_session_name(session_name): + if not validate_k8s_session_name(session_name) and ( + pm_type == ProcessManagerTypes.K8s + ): ctx.obj.log.error( f"[red]Invalid session/namespace name [bold]({session_name})[/bold][/red]. " "Must match RFC1123 label: lowercase alphanumeric or '-', start/end with " @@ -174,7 +194,13 @@ def unified_shell( sys.exit(1) # Setup configuration related context variables - ctx.obj.configuration_file = f"oksconflibs:{configuration_file}" + # Assume oksconflibs if no framework is defined + ctx.obj.configuration_file = ( + lambda path: ( + path if path.startswith("oksconflibs:") else f"oksconflibs:{path}" + ) + )(configuration_file) + ctx.obj.configuration_id = configuration_id ctx.obj.session_name = session_name @@ -183,14 +209,11 @@ def unified_shell( session_dal = db.get_dal(class_name="Session", uid=ctx.obj.configuration_id) app_log_path = session_dal.log_path - ctx.obj.log.info( - f"[green]Setting up to use the process manager[/green] with configuration " - f"[green]{process_manager}[/green] and configuration id [green]" - f'"{configuration_id}"[/green] from [green]{ctx.obj.configuration_file}[/green]' - ) - - # Establish communication with the process manager, spawning it if needed + # Start the process manager if it's an internal one if internal_pm: # Spawn the Process Manager + ctx.obj.log.info( + f"[green]Setting up the {pm_type.name} process manager[/] with configuration [green]{process_manager}[/green]" + ) ctx.obj.log.debug( f"Spawning process_manager with configuration {process_manager}" ) @@ -259,21 +282,13 @@ def unified_shell( process_manager_address = resolve_localhost_and_127_ip_to_network_ip( f"localhost:{port.value}" ) - - else: # Connect to an existing process manager at the provided address - process_manager_address = process_manager.replace( - "grpc://", "" - ) # remove the grpc scheme - ctx.obj.log.info( - f"[green]unified_shell[/green] connected to the [green]process_manager" - f"[/green] at address [green]{process_manager_address}[/green]" + ctx.obj.reset(address_pm=process_manager_address) + ctx.obj.log.debug( + f"[green]process_manager[/green] started at address [green]" + f"{process_manager_address}[/green]" ) - ctx.obj.log.debug( - f"[green]process_manager[/green] started, communicating through address [green]" - f"{process_manager_address}[/green]" - ) - ctx.obj.reset(address_pm=process_manager_address) + ctx.obj.log.info("Setting up the controller interface") # Run a simple command (describe) to check the connection with the process manager try: @@ -283,7 +298,7 @@ def unified_shell( f"[red]Could not connect to the process manager at the address: [/red]" f"[green]{process_manager_address}[/green]" ) - ctx.obj.log.debug(f"Reason: {e}") + ctx.obj.log.critical(f"Reason: {e}") if type(e) == ServerUnreachable: ctx.obj.log.error( @@ -302,21 +317,26 @@ def unified_shell( ctx.obj.pm_process.join() sys.exit(1) + ctx.obj.log.debug("Communication with the process manager verified successfully") + + ctx.obj.get_driver("process_manager").send_msg( + f"{getpass.getuser()} connected from unified shell" + ) # Add the unified shell Click commands to the CLI ctx.obj.log.debug("Adding [green]unified_shell[/green] commands") - ctx.command.add_command(boot, "boot") - ctx.obj.dynamic_commands.add("boot") + unified_shell_commands = [boot, ps, terminate] + for cmd in unified_shell_commands: + ctx.command.add_command(cmd, format_name_for_cli(cmd.name)) + ctx.obj.dynamic_commands.add(format_name_for_cli(cmd.name)) # Add the process manager Click commands to the CLI ctx.obj.log.debug("Adding [green]process_manager[/green] commands") process_manager_commands: list[click.Command] = [ kill, - terminate, flush, logs, restart, - ps, ] for cmd in process_manager_commands: ctx.command.add_command(cmd, format_name_for_cli(cmd.name)) @@ -452,11 +472,11 @@ def cleanup(): ctx.obj.log.error( f"Could not retrieve the controller status, reason: {e}" ) - ctx.obj.delete_driver("controller") # Terminate any residual processes if ctx.obj.get_driver("process_manager"): - ctx.obj.get_driver("process_manager").terminate() + session_processes = ProcessQuery(session=ctx.obj.session_name) + ctx.obj.get_driver("process_manager").kill(session_processes) # Check if any processes are still running if ( @@ -495,6 +515,9 @@ def cleanup(): ) # Remove the connection to the process manager + ctx.obj.get_driver("process_manager").send_msg( + f"{getpass.getuser()} disconnected from unified shell" + ) ctx.obj.get_driver("process_manager").close() ctx.obj.delete_driver("process_manager") diff --git a/src/drunc/utils/shell_utils.py b/src/drunc/utils/shell_utils.py index 02c9ab46f..b0a6a0b16 100644 --- a/src/drunc/utils/shell_utils.py +++ b/src/drunc/utils/shell_utils.py @@ -185,6 +185,11 @@ def __str__(self) -> str: class ShellContext: """Base class for shell contexts.""" + shell_id = None # used for logging if its a PM shell or Unified shell etc + + def get_shell_id(self): + return self.shell_id + def _reset( self, name: str, @@ -365,3 +370,32 @@ def print_status_summary(self) -> None: log.info( f"Current FSM status is [green]{current_state}[/green]. Available transitions are [green]{'[/green], [green]'.join(available_actions)}[/green]. Available sequence commands are [green]{'[/green], [green]'.join(available_sequences)}[/green]." ) + + +def log_pm_cmd(obj: ShellContext): + """Log a process-manager shell command with only explicitly provided arguments. + + The current Click command context is inspected and only parameters whose source is + ``COMMANDLINE`` are included in the log message. This keeps defaulted values out + of the message while still recording the command name, optional session name, and + shell identity. + + These are sent over via send_msg so that it can be displayed in the process manager + shell + + Args: + obj (ShellContext): Active shell context used to send the log message. + """ + + ctx_cmd = click.get_current_context(silent=True) + cmd_name = ctx_cmd.command.name if ctx_cmd else None + parms_dict = {} + for param in ctx_cmd.command.params: + name = param.name + if ctx_cmd.get_parameter_source(name) == click.core.ParameterSource.COMMANDLINE: + parms_dict[name] = f"{ctx_cmd.params[name]!r}" + + args = f" with arguments {parms_dict}" if parms_dict else "" + session = f" for session {obj.session_name}" if hasattr(obj, "session_name") else "" + msg = f"{getpass.getuser()} sent {cmd_name}{args}{session} via {obj.get_shell_id()}" + obj.get_driver("process_manager").send_msg(msg) diff --git a/src/drunc/utils/utils.py b/src/drunc/utils/utils.py index 71b07ad58..d24711b2c 100644 --- a/src/drunc/utils/utils.py +++ b/src/drunc/utils/utils.py @@ -1,6 +1,7 @@ """A set of utility functions for drunc.""" import ctypes +import ipaddress import logging import os import random @@ -363,6 +364,18 @@ def parent_death_pact(signal: int = signal.SIGHUP) -> None: raise Exception("prctl() returned nonzero retcode %d" % retcode) +# 777 PERMISSIONS ARE COMPLETELY TEMPORARY +# An established procedure for multi users will need to be discussed with sysadmins +# will be removed when done +def touch_and_chmod(filepath: str, mode=0o777): + """Makes and sets the permissions of a file. + This is used to ensure multiuser support when accessing files etc.""" + + with open(filepath, "a"): + os.utime(filepath, None) + os.chmod(filepath, mode) + + class IncorrectAddress(DruncException): """Exception raised when an address is invalid.""" @@ -706,6 +719,98 @@ def resolve_target_ip(host: str) -> str | None: return None +def resolve_context_peer(peer: str) -> str: + """Resolve a transport-qualified peer string to a display-friendly address. + + The input is expected to look like ``transport:address``. If the address contains + an IP literal, it is reverse-resolved where possible and IPv6 addresses are + re-wrapped in brackets. + + Example: + ``ipv4:10.73.136.70:41750`` -> ``np04-srv-028.cern.ch:41750`` + + Args: + peer (str): Transport-qualified peer string. + + Returns: + str: The original peer string, or a resolved ``host:port`` representation. + """ + + if not peer: + return peer + + # Some callers pass a plain host:port string without a transport prefix. + # Handle those directly instead of assuming the first token is always a transport. + if peer.startswith("[") or peer.count(":") == 1: + parsed = _parse_host_port(peer) + if parsed is not None: + host, port = parsed + resolved_host = _resolve_host(host) + return f"{resolved_host}:{port}" + + match = re.match(r"^(?P[^:]+):(?P
.+)$", peer) + if not match: + return peer + + parsed = _parse_host_port(match.group("address")) + if parsed is None: + return peer + + host, port = parsed + resolved_host = _resolve_host(host) + return f"{resolved_host}:{port}" + + +def _parse_host_port(address: str) -> tuple[str, str] | None: + """Extract a host and port from a peer address string. + + Supports bracketed IPv6 addresses such as ``[::1]:1234`` and unbracketed + ``host:port`` or ``ipv4:port`` forms. + + Args: + address (str): Address portion of a transport-qualified peer string. + + Returns: + tuple[str, str] | None: ``(host, port)`` when parsing succeeds, otherwise + ``None``. + """ + + bracket_match = re.match(r"^\[(?P[^\]]+)\]:(?P\d+)$", address) + if bracket_match: + return bracket_match.group("host"), bracket_match.group("port") + + host, sep, port = address.rpartition(":") + if sep and port.isdigit(): + return host, port + + return None + + +def _resolve_host(host: str) -> str: + """Reverse-resolve an IP host and keep IPv6 output bracketed. + + Args: + host (str): Hostname or IP literal to resolve. + + Returns: + str: The resolved hostname, or the original host if resolution fails. + """ + + try: + ip_obj = ipaddress.ip_address(host) + except ValueError: + return host + + try: + resolved_host, _, _ = socket.gethostbyaddr(str(ip_obj)) + except (socket.herror, socket.gaierror, socket.timeout, OSError): + resolved_host = host + + if ":" in resolved_host and not resolved_host.startswith("["): + return f"[{resolved_host}]" + return resolved_host + + def is_port_available(host: str, port: int, timeout: int = 2) -> bool: """Check if the given port number on a specified host is available. diff --git a/tests/conftest.py b/tests/conftest.py index 9ba6b9df5..e2e8705a0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ import getpass import logging import os +import socket import time from pathlib import Path from subprocess import Popen @@ -103,6 +104,9 @@ def boot_session( # Prepare environment variables env = os.environ.copy() env["DUNEDAQ_SESSION"] = session_name + env["DRUNC_HOST_NAME"] = ( + socket.gethostname() + ) # This works as the process is a subprocess that does not run in a container. # Load the configuration, get the DAL configuration_consolidated_file = f"oksconflibs:{configuration_consolidated_file}" diff --git a/tests/process_manager/conftest.py b/tests/process_manager/conftest.py index 6a2ba4bfd..f4aa11dd8 100644 --- a/tests/process_manager/conftest.py +++ b/tests/process_manager/conftest.py @@ -7,6 +7,8 @@ to be back in line with druncschema definitions. """ +import socket + import google.protobuf.any_pb2 import pytest from druncschema.description_pb2 import Description @@ -36,7 +38,7 @@ def app_data(): Provides a mock application dictionary with required keys. """ return { - "restriction": "localhost", + "restriction": socket.gethostname(), "name": "TestApp", "type": "binary", "args": ["--arg1"], @@ -55,7 +57,7 @@ def bootrequest(app_data): user="test_user", session="session1", name=app_data["name"], - hostname="localhost", + hostname=socket.gethostname(), tree_id=app_data["tree_id"], ), executable_and_arguments=[{"exec": "binary", "args": ["--arg1"]}], @@ -63,6 +65,7 @@ def bootrequest(app_data): **app_data["env"], "DUNE_DAQ_BASE_RELEASE": "release1", "SPACK_RELEASES_DIR": "spack_release", + "DRUNC_HOST_NAME": socket.gethostname(), }, process_execution_directory="/pwd", process_logs_path=app_data["log_path"], diff --git a/tests/process_manager/interface/test_commands.py b/tests/process_manager/interface/test_commands.py index 3571690e2..f1a5c5e4b 100644 --- a/tests/process_manager/interface/test_commands.py +++ b/tests/process_manager/interface/test_commands.py @@ -102,6 +102,10 @@ def logs(self, log_request): mock_result.lines = [] return mock_result + def send_msg(self, msg: str) -> None: + # simulate sending a message; tests don't assert on this, so store it + self._last_sent_msg = msg + class MockContext: """ @@ -115,6 +119,9 @@ def __init__(self, driver=None): def get_driver(self, name): return self.driver + def get_shell_id(self): + return "mock-shell" + def print(self, msg, justify=None, overflow=None, soft_wrap=None): self.output.append(str(msg)) @@ -230,8 +237,9 @@ def test_boot_exiting_processes_abort(boot_arguments): # check that 'boot' was never called mock_driver.boot.assert_not_called() + # conf-id-123 from session name in this file assert ( - "You already have 2 processes running, are you sure you want to boot a session?" + "You already have 2 processes running for conf-id-123, are you sure you want to boot a session?" in result.output ) @@ -260,7 +268,7 @@ def test_boot_exiting_processes_user_confirm(boot_arguments): mock_driver.boot.assert_called() assert ( - "You already have 2 processes running, are you sure you want to boot a session?" + "You already have 2 processes running for conf-id-123, are you sure you want to boot a session?" in result.output ) diff --git a/tests/process_manager/process_manager_mock_impls.py b/tests/process_manager/process_manager_mock_impls.py index 831ac2c38..2ab0773b4 100644 --- a/tests/process_manager/process_manager_mock_impls.py +++ b/tests/process_manager/process_manager_mock_impls.py @@ -9,6 +9,7 @@ from typing import Optional from unittest.mock import Mock +from druncschema.generic_pb2 import OutcomeFlag, OutcomeStatus from druncschema.process_manager_pb2 import ( BootRequest, LogLines, @@ -100,3 +101,12 @@ def _logs_impl(self, log_request: LogRequest) -> LogLines: def _flush_impl(self, query: ProcessQuery) -> ProcessInstanceList: return self._not_implemented_response() + + def _send_msg_impl( + self, msg: str | None = None, peer: str | None = None + ) -> OutcomeStatus: + """ + Returns an empty response to indicate communication is working. + Accepts an optional message parameter for compatibility with new API. + """ + return OutcomeStatus(flag=OutcomeFlag.SUCCESS) diff --git a/tests/process_manager/test_process_manager_driver.py b/tests/process_manager/test_process_manager_driver.py index f200fba22..415afef0e 100644 --- a/tests/process_manager/test_process_manager_driver.py +++ b/tests/process_manager/test_process_manager_driver.py @@ -488,7 +488,7 @@ def test_connect_to_service_success(mock_client_class, mock_driver): pytest_hostname = socket.gethostname() mock_session_dal = MagicMock() - mock_session_dal.connectivity_service.host = "localhost" + mock_session_dal.connectivity_service.host = socket.gethostname() mock_session_dal.connectivity_service.service.port = 1234 result_localhost = mock_driver._connect_to_service(mock_session_dal, "session1") diff --git a/tests/processes/test_ssh_process_lifetime_manager_common.py b/tests/processes/test_ssh_process_lifetime_manager_common.py index 0a0bab2b6..ad9b86740 100644 --- a/tests/processes/test_ssh_process_lifetime_manager_common.py +++ b/tests/processes/test_ssh_process_lifetime_manager_common.py @@ -37,6 +37,8 @@ def create_boot_request(process_name, tree_id, log_file, test_file_path): """ simple_process_script = test_file_path.parent / "simple_process.py" + work_area_root = os.getenv("DBT_AREA_ROOT", "/tmp") + boot_request = BootRequest( process_description=ProcessDescription( metadata=ProcessMetadata( @@ -46,10 +48,15 @@ def create_boot_request(process_name, tree_id, log_file, test_file_path): hostname="localhost", tree_id=tree_id, ), + process_execution_directory=work_area_root, process_logs_path=log_file, ) ) + boot_request.process_description.executable_and_arguments.add( + exec="cd", args=[work_area_root] + ) + boot_request.process_description.executable_and_arguments.add( exec=f"python3 {simple_process_script}", args=[process_name] ) diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index 94b3573d6..5094824b0 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -20,6 +20,7 @@ now_str, parent_death_pact, regex_match, + resolve_context_peer, resolve_localhost_and_127_ip_to_network_ip, resolve_localhost_to_hostname, validate_command_facility, @@ -157,6 +158,12 @@ def test_resolve_localhost_and_127_ip_to_network_ip(): assert resolved == generate_address(this_ip) +def test_resolve_context_peer(): + assert resolve_context_peer("grpc:np04-srv-028:50000") == "np04-srv-028:50000" + assert resolve_context_peer("np04-srv-028:50000") == "np04-srv-028:50000" + assert resolve_context_peer("") == "" + + def test_host_is_local(): this_ip = socket.gethostbyname(socket.gethostname()) hostname = socket.gethostname() From c86ad1b5025a6cae7856b3c13e1f7e82b61b3767 Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Thu, 16 Jul 2026 16:59:12 +0200 Subject: [PATCH 45/73] Small clean up --- src/drunc/integtest/controller_test.py | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/src/drunc/integtest/controller_test.py b/src/drunc/integtest/controller_test.py index 7f7d38d0f..3e58511ef 100644 --- a/src/drunc/integtest/controller_test.py +++ b/src/drunc/integtest/controller_test.py @@ -228,22 +228,11 @@ def test_dunerc_success(run_dunerc) -> None: def test_log_files(run_dunerc) -> None: """Checks that expected process-manager log files exist and are free of errors.""" - assert any( - f"{run_dunerc.daq_session_name}_df-01" in str(logname) - for logname in run_dunerc.log_files - ) - assert any( - f"{run_dunerc.daq_session_name}_dfo" in str(logname) - for logname in run_dunerc.log_files - ) - assert any( - f"{run_dunerc.daq_session_name}_mlt" in str(logname) - for logname in run_dunerc.log_files - ) - assert any( - f"{run_dunerc.daq_session_name}_ru" in str(logname) - for logname in run_dunerc.log_files - ) + for app_exension in ["_df-01", "_dfo", "_mlt", "_ru"]: + assert any( + f"{run_dunerc.daq_session_name}{app_exension}" in str(logname) + for logname in run_dunerc.log_files + ), f"Expected log file with extension '{app_exension}' not found." if check_for_logfile_errors: assert log_file_checks.logs_are_error_free( From fa8a14385ac5ef67c2f8c9c6d3f3d101d4c7273f Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Fri, 17 Jul 2026 18:55:31 +0200 Subject: [PATCH 46/73] Reverting the parsing method of the connectivity service address --- src/drunc/controller/controller.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/drunc/controller/controller.py b/src/drunc/controller/controller.py index de3ac41c2..357db2635 100644 --- a/src/drunc/controller/controller.py +++ b/src/drunc/controller/controller.py @@ -135,7 +135,8 @@ def __init__(self, configuration, name: str, session: str, token: Token): connection_server_host = ( self.configuration.session.connectivity_service.host ) - connection_port = os.getenv("CONNECTION_PORT") + # connection_port = os.getenv("CONNECTION_PORT") + connection_server_port = self.configuration.session.connectivity_service.port if connection_server_host == "localhost": injected_hostname = os.getenv("DRUNC_HOST_NAME") if not injected_hostname: From 86de8d2d5088e48581b8a9f0917f5e08f101ea7d Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Fri, 17 Jul 2026 19:20:06 +0200 Subject: [PATCH 47/73] Typo fix, and unsetting the DISPLAY env var from the SSH config --- src/drunc/controller/controller.py | 2 +- src/drunc/process_manager/process_manager_driver.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/drunc/controller/controller.py b/src/drunc/controller/controller.py index 357db2635..990a0e86d 100644 --- a/src/drunc/controller/controller.py +++ b/src/drunc/controller/controller.py @@ -136,7 +136,7 @@ def __init__(self, configuration, name: str, session: str, token: Token): self.configuration.session.connectivity_service.host ) # connection_port = os.getenv("CONNECTION_PORT") - connection_server_port = self.configuration.session.connectivity_service.port + connection_port = self.configuration.session.connectivity_service.service.port if connection_server_host == "localhost": injected_hostname = os.getenv("DRUNC_HOST_NAME") if not injected_hostname: diff --git a/src/drunc/process_manager/process_manager_driver.py b/src/drunc/process_manager/process_manager_driver.py index 48c37b1c9..3bae291e3 100644 --- a/src/drunc/process_manager/process_manager_driver.py +++ b/src/drunc/process_manager/process_manager_driver.py @@ -322,6 +322,9 @@ def _build_boot_request( data_path = app.get("data_path") env["DUNE_DAQ_BASE_RELEASE"] = os.getenv("DUNE_DAQ_BASE_RELEASE") env["SPACK_RELEASES_DIR"] = os.getenv("SPACK_RELEASES_DIR") + # Some edge cases throw issues with DISPLAY being set, so we remove it from the + # environment + env.pop('DISPLAY', None) tree_id = app["tree_id"] # The following line is required to provide an independent method of injecting From b04535460157d5493f1ee177279240d702ecdb61 Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Mon, 20 Jul 2026 15:34:49 +0200 Subject: [PATCH 48/73] RTE script does not need reformatting, but may need it for superuser use --- src/drunc/processes/ssh_process_lifetime_manager_shell.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/drunc/processes/ssh_process_lifetime_manager_shell.py b/src/drunc/processes/ssh_process_lifetime_manager_shell.py index 1d53203dc..1356c0fb6 100644 --- a/src/drunc/processes/ssh_process_lifetime_manager_shell.py +++ b/src/drunc/processes/ssh_process_lifetime_manager_shell.py @@ -442,10 +442,7 @@ def start_process(self, uuid: str, boot_request: BootRequest) -> None: for exe_arg in boot_request.process_description.executable_and_arguments: cmd += exe_arg.exec for arg in exe_arg.args: - if arg.endswith("daq_app_rte.sh"): - cmd += f" {os.getenv('DBT_AREA_ROOT')}/install/daq_app_rte.sh" - else: - cmd += f" {arg}" + cmd += f" {arg}" cmd += ";" # Remove trailing semicolon if present From 36708ccd49d11f23022724e9149544c88693be75 Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Mon, 20 Jul 2026 16:44:48 +0200 Subject: [PATCH 49/73] Rollback --- src/drunc/controller/controller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/drunc/controller/controller.py b/src/drunc/controller/controller.py index 990a0e86d..cda04444d 100644 --- a/src/drunc/controller/controller.py +++ b/src/drunc/controller/controller.py @@ -135,8 +135,8 @@ def __init__(self, configuration, name: str, session: str, token: Token): connection_server_host = ( self.configuration.session.connectivity_service.host ) - # connection_port = os.getenv("CONNECTION_PORT") - connection_port = self.configuration.session.connectivity_service.service.port + connection_port = os.getenv("CONNECTION_PORT") + if connection_server_host == "localhost": injected_hostname = os.getenv("DRUNC_HOST_NAME") if not injected_hostname: From ac840a039de77efbe1c0bb99e3adc65e7b3c7bc3 Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Mon, 20 Jul 2026 16:46:01 +0200 Subject: [PATCH 50/73] Bump --- src/drunc/controller/controller.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/drunc/controller/controller.py b/src/drunc/controller/controller.py index cda04444d..de3ac41c2 100644 --- a/src/drunc/controller/controller.py +++ b/src/drunc/controller/controller.py @@ -136,7 +136,6 @@ def __init__(self, configuration, name: str, session: str, token: Token): self.configuration.session.connectivity_service.host ) connection_port = os.getenv("CONNECTION_PORT") - if connection_server_host == "localhost": injected_hostname = os.getenv("DRUNC_HOST_NAME") if not injected_hostname: From 66ee09ad20b5db25289b31f951d4aaeadfe106b9 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Tue, 23 Jun 2026 15:44:31 +0200 Subject: [PATCH 51/73] add controller log override --- src/drunc/process_manager/interface/commands.py | 9 ++++++++- src/drunc/process_manager/process_manager_driver.py | 9 ++++++++- src/drunc/unified_shell/commands.py | 9 ++++++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/drunc/process_manager/interface/commands.py b/src/drunc/process_manager/interface/commands.py index 0d2cd3c43..c3d7e4d2e 100644 --- a/src/drunc/process_manager/interface/commands.py +++ b/src/drunc/process_manager/interface/commands.py @@ -31,6 +31,12 @@ default=True, help="Override logs, if --no-override-logs filenames have the timestamp of the run.", ) +@click.option( + "-cl", + "--controller-log-level", + default=None, + help="Overrides the config-defined log level of the controller", +) @click.argument("configuration-file", type=str, callback=validate_conf_string) @click.argument("configuration-id", type=str) @click.argument("session-name", type=str) @@ -42,6 +48,7 @@ def boot( configuration_file: str, configuration_id: str, override_logs: bool, + controller_log_level: bool | None, ) -> None: log = get_logger("process_manager.shell") log_pm_cmd(obj) @@ -65,7 +72,7 @@ def boot( conf_id=configuration_id, user=user, session_name=session_name, - log_level="INFO", ## Unused anyway!! + log_level=controller_log_level, override_logs=override_logs, ) for result in results: diff --git a/src/drunc/process_manager/process_manager_driver.py b/src/drunc/process_manager/process_manager_driver.py index 3bae291e3..be0e774d4 100644 --- a/src/drunc/process_manager/process_manager_driver.py +++ b/src/drunc/process_manager/process_manager_driver.py @@ -112,6 +112,9 @@ def send_msg(self, msg): handle_grpc_error(e) return response + def update_controller_logs(self, ctrl_dal, level): + ctrl_dal.controller_log_level = level + return ctrl_dal # ----- Boot workflow ----- @@ -121,7 +124,7 @@ def boot( conf_id: str, user: str, session_name: str, - log_level: str, + log_level: str | None = None, override_logs: bool = True, timeout: int | float = 60, sleep_between_app_boot: ( @@ -143,6 +146,10 @@ def boot( # Step 3 - check for port conflicts and update configuration/DAL as needed db, session_dal = self.check_port_conflicts(db, session_dal) + # Step 3.25 - Update controller dal + if log_level: + session_dal = self.update_controller_logs(session_dal, log_level) + # step 3.5 update localhost mapping session_dal = self.resolve_localhost(session_dal) diff --git a/src/drunc/unified_shell/commands.py b/src/drunc/unified_shell/commands.py index 6285ad3bf..0cf6166af 100644 --- a/src/drunc/unified_shell/commands.py +++ b/src/drunc/unified_shell/commands.py @@ -31,6 +31,12 @@ default=None, help="Manual override allows for overwriting logs or not, by appending timestamp info. Default (None) is to follow what is used in the initialisation of the unified shell.", ) +@click.option( + "-cl", + "--controller-log-level", + default=None, + help="Overrides the config-defined log level of the controller", +) @click.option( "--sleep-between-app-boot", type=float, @@ -41,6 +47,7 @@ def boot( obj: ProcessManagerContext, override_logs: bool | None, + controller_log_level: bool | None, sleep_between_app_boot: int | float = 0, ) -> None: log = get_logger("unified_shell.boot") @@ -72,7 +79,7 @@ def boot( conf_id=obj.configuration_id, user=user, session_name=session_name, - log_level="INFO", # Unused anyway !! + log_level=controller_log_level, override_logs=override_logs_boot, sleep_between_app_boot=sleep_between_app_boot, ) From 099cad998c52007cfb1c7ee5bf9e25738c2c1e25 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Tue, 21 Jul 2026 17:31:44 +0200 Subject: [PATCH 52/73] Update ps table no boot --- src/drunc/integtest/process_manager_test.py | 23 +++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/drunc/integtest/process_manager_test.py b/src/drunc/integtest/process_manager_test.py index 9b4212a09..493ff7a61 100644 --- a/src/drunc/integtest/process_manager_test.py +++ b/src/drunc/integtest/process_manager_test.py @@ -196,14 +196,25 @@ def test_boot(run_dunerc) -> None: """Checks that boot starts the managed processes and exposes UUIDs in ps.""" lines = strip_ansi(run_dunerc.completed_process.stdout).splitlines() - ps_pre_boot = get_ps_table_after_echo(lines, "pre_boot") - ps_post_boot = get_ps_table_after_echo(lines, "post_boot") - - assert not ps_pre_boot, ( - f"Expected ps table before boot to be empty, but found {len(ps_pre_boot)} row(s): " - + ", ".join(row["friendly_name"] for row in ps_pre_boot) + # Check if no processes running in session works + pre_boot_idx = require_line_containing( + lines, + "pre_boot", + error_message="Did not find the 'pre_boot' header line in stdout.", + ) + post_boot_idx = require_line_containing( + lines, + "post_boot", + error_message="Did not find the 'pos_boot' footer line in stdout.", + ) + between = lines[pre_boot_idx + 1 : post_boot_idx] + no_boot_re = "No processes running in session" + assert any(no_boot_re in line for line in between), ( + f"Did not find '{no_boot_re}' between pre_boot and post_boot.\nBetween:\n" + + "\n".join(between) ) + ps_post_boot = get_ps_table_after_echo(lines, "post_boot") assert ps_post_boot, ( "Expected ps table after boot to contain processes, but it was empty." ) From 816d6d41e32212cc607efb16b542eb84c8380b1b Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Tue, 21 Jul 2026 18:00:45 +0200 Subject: [PATCH 53/73] move ps to impl method (fixes integtest) --- .../process_manager/interface/commands.py | 71 ++++++++++++------- src/drunc/unified_shell/commands.py | 41 +++-------- 2 files changed, 55 insertions(+), 57 deletions(-) diff --git a/src/drunc/process_manager/interface/commands.py b/src/drunc/process_manager/interface/commands.py index 0d2cd3c43..4c6a14089 100644 --- a/src/drunc/process_manager/interface/commands.py +++ b/src/drunc/process_manager/interface/commands.py @@ -336,40 +336,57 @@ def restart_impl(obj: ProcessManagerContext, query: ProcessQuery) -> None: obj.get_driver("process_manager").restart(query) +def ps_decorators(f): + f = click.pass_obj(f) + f = click.option( + "-w", + "--width", + type=int, + default=None, + help="Table width. Default is automatically calculated", + )(f) + f = click.option( + "-l", + "--long-format", + is_flag=True, + type=bool, + default=False, + help="Whether to have a long output", + )(f) + + return f + + @click.command("ps") @add_query_options(at_least_one=False, all_processes_by_default=True) -@click.option( - "-l", - "--long-format", - is_flag=True, - type=bool, - default=False, - help="Whether to have a long output", -) -@click.option( - "-w", - "--width", - type=int, - default=None, - help="Table width. Default is automatically calculated", -) -@click.pass_obj -def ps( +@ps_decorators +def ps(obj, query, long_format, width): + log_pm_cmd(obj) + return ps_impl(obj, query, long_format, width) + + +def ps_impl( obj: ProcessManagerContext, query: ProcessQuery, long_format: bool, width: int | None, ) -> None: log = get_logger("process_manager.shell") - log_pm_cmd(obj) log.debug(f"Running ps with query {query}") results = obj.get_driver("process_manager").ps(query) - if not results: - return - obj.print( - tabulate_process_instance_list( - results, title="Processes running", long=long_format, width=width - ), - overflow="fold", - soft_wrap=True, - ) + + # If there are processes running, tabulate them, otherwise log that there are no + # processes running. + if results.values: + obj.print( + tabulate_process_instance_list( + results, + title=f"Processes running in session {obj.session_name}", + long=long_format, + width=width, + ), + overflow="fold", + soft_wrap=True, + ) + else: + log.info(f"No processes running in session [green]{obj.session_name}[/]") diff --git a/src/drunc/unified_shell/commands.py b/src/drunc/unified_shell/commands.py index 6285ad3bf..d9f58c4a6 100644 --- a/src/drunc/unified_shell/commands.py +++ b/src/drunc/unified_shell/commands.py @@ -15,10 +15,11 @@ kill_impl, logs_decorators, logs_impl, + ps_decorators, + ps_impl, restart_impl, ) from drunc.process_manager.interface.context import ProcessManagerContext -from drunc.process_manager.utils import tabulate_process_instance_list from drunc.unified_shell.context import UnifiedShellMode from drunc.utils.shell_utils import InterruptedCommand, log_pm_cmd from drunc.utils.utils import get_logger @@ -158,35 +159,6 @@ def terminate(ctx, obj): obj.delete_driver("controller") -@click.command("ps") -@click.pass_obj -@click.pass_context -def ps(ctx, obj): - """ - Execute the process manager terminate command, but only do this for the current - session - """ - - log = get_logger("unified_shell.ps") - log_pm_cmd(obj) - session_query = ProcessQuery(session=ctx.obj.session_name) - log.info(f"Listing session [green]{ctx.obj.session_name}[/]") - results = obj.get_driver("process_manager").ps(session_query) - - # If there are processes running, tabulate them, otherwise log that there are no - # processes running. - if results.values: - obj.print( - tabulate_process_instance_list( - results, title=f"Processes running in session {ctx.obj.session_name}" - ), - overflow="fold", - soft_wrap=True, - ) - else: - log.info(f"No processes running in session [green]{ctx.obj.session_name}[/]") - - def session_injector(f): @click.pass_context def wrapper(ctx, *args, **kwargs): @@ -196,6 +168,15 @@ def wrapper(ctx, *args, **kwargs): return update_wrapper(wrapper, f) +@click.command("ps") +@session_injector +@add_query_options_no_session(at_least_one=True) +@ps_decorators +def ps(obj, query, long_format, width): + log_pm_cmd(obj) + return ps_impl(obj, query, long_format, width) + + @click.command("logs") @session_injector @add_query_options_no_session(at_least_one=True) From 956a58915777da46baa20a0751d1360ac2aaa827 Mon Sep 17 00:00:00 2001 From: Emir Muhammad Date: Wed, 22 Jul 2026 10:23:32 +0200 Subject: [PATCH 54/73] Fix pytest --- tests/process_manager/interface/test_commands.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/process_manager/interface/test_commands.py b/tests/process_manager/interface/test_commands.py index f1a5c5e4b..9c843bdcb 100644 --- a/tests/process_manager/interface/test_commands.py +++ b/tests/process_manager/interface/test_commands.py @@ -115,6 +115,7 @@ class MockContext: def __init__(self, driver=None): self.driver = driver or MockDriver() self.output = [] + self.session_name = "mock-session" def get_driver(self, name): return self.driver From 08f80327ad19c729f0256b0390fa40d6b9bc74d8 Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Wed, 22 Jul 2026 17:21:32 +0200 Subject: [PATCH 55/73] typo fix --- src/drunc/integtest/process_manager_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/drunc/integtest/process_manager_test.py b/src/drunc/integtest/process_manager_test.py index 493ff7a61..f20ac2c1e 100644 --- a/src/drunc/integtest/process_manager_test.py +++ b/src/drunc/integtest/process_manager_test.py @@ -205,7 +205,7 @@ def test_boot(run_dunerc) -> None: post_boot_idx = require_line_containing( lines, "post_boot", - error_message="Did not find the 'pos_boot' footer line in stdout.", + error_message="Did not find the 'post_boot' footer line in stdout.", ) between = lines[pre_boot_idx + 1 : post_boot_idx] no_boot_re = "No processes running in session" From a0431e51a265a14a12109e95d886e4c357642637 Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Wed, 22 Jul 2026 18:20:01 +0200 Subject: [PATCH 56/73] Disabled X11 --- src/drunc/processes/ssh_process_lifetime_manager_shell.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/drunc/processes/ssh_process_lifetime_manager_shell.py b/src/drunc/processes/ssh_process_lifetime_manager_shell.py index 1356c0fb6..32f13c585 100644 --- a/src/drunc/processes/ssh_process_lifetime_manager_shell.py +++ b/src/drunc/processes/ssh_process_lifetime_manager_shell.py @@ -918,6 +918,8 @@ def _build_ssh_arguments( "GlobalKnownHostsFile=/dev/null", "-o", "UserKnownHostsFile=/dev/null", + "-o", + "ForwardX11=no", ] ) From 88834884098bbf1f9461486ddc0ab08e81c4a08e Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Wed, 22 Jul 2026 18:28:19 +0200 Subject: [PATCH 57/73] Force disable X11 entirely --- src/drunc/processes/ssh_process_lifetime_manager_shell.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/drunc/processes/ssh_process_lifetime_manager_shell.py b/src/drunc/processes/ssh_process_lifetime_manager_shell.py index 32f13c585..365e06c8b 100644 --- a/src/drunc/processes/ssh_process_lifetime_manager_shell.py +++ b/src/drunc/processes/ssh_process_lifetime_manager_shell.py @@ -912,14 +912,13 @@ def _build_ssh_arguments( # verification arguments.extend( [ + "-x", "-o", "LogLevel=error", "-o", "GlobalKnownHostsFile=/dev/null", "-o", "UserKnownHostsFile=/dev/null", - "-o", - "ForwardX11=no", ] ) From f119a16ff4bf3f7408ca186659bf04ce99bc3760 Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Wed, 22 Jul 2026 18:30:22 +0200 Subject: [PATCH 58/73] Explicitly unsetting the variable DISPLAY --- src/drunc/processes/ssh_process_lifetime_manager_shell.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/drunc/processes/ssh_process_lifetime_manager_shell.py b/src/drunc/processes/ssh_process_lifetime_manager_shell.py index 365e06c8b..8a2a3bf44 100644 --- a/src/drunc/processes/ssh_process_lifetime_manager_shell.py +++ b/src/drunc/processes/ssh_process_lifetime_manager_shell.py @@ -1152,6 +1152,8 @@ def _execute_bootrequest_via_ssh( cmd_env = ";".join([f'export {n}="{v}"' for n, v in env_vars.items()]) remote_cmd += cmd_env + ";" + remote_cmd += "unset DISPLAY;" + if hasattr(boot_request.process_description, "process_execution_directory"): remote_cmd += f"cd {boot_request.process_description.process_execution_directory} ; " From 7e2d16319797e8fa1c6935795767db7d79b5d000 Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Wed, 22 Jul 2026 18:32:27 +0200 Subject: [PATCH 59/73] Another DISPLAY unset --- src/drunc/processes/ssh_process_lifetime_manager_shell.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/drunc/processes/ssh_process_lifetime_manager_shell.py b/src/drunc/processes/ssh_process_lifetime_manager_shell.py index 8a2a3bf44..8869fb5b4 100644 --- a/src/drunc/processes/ssh_process_lifetime_manager_shell.py +++ b/src/drunc/processes/ssh_process_lifetime_manager_shell.py @@ -1181,6 +1181,7 @@ def _execute_bootrequest_via_ssh( remote_cmd += ( f"mkdir -p ${{XDG_RUNTIME_DIR:-/tmp}}/drunc ; " f"rm {log_file}; " # delete log file so no issues on ovewriting in th next line + "unset DISPLAY; " f"{command} &> {log_file} & PID=$! ; " f"trap 'kill -HUP $PID 2>/dev/null || true; wait $PID 2>/dev/null || true' HUP TERM INT QUIT ; " f"echo '{remote_metadata_json}' > {metadata_file} ; " From 407b442f6685ef7ed230c23e645676703fbce17f Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Wed, 22 Jul 2026 18:36:14 +0200 Subject: [PATCH 60/73] More forceful unsettting of DISPLAY --- src/drunc/processes/ssh_process_lifetime_manager_shell.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/drunc/processes/ssh_process_lifetime_manager_shell.py b/src/drunc/processes/ssh_process_lifetime_manager_shell.py index 8869fb5b4..e02644124 100644 --- a/src/drunc/processes/ssh_process_lifetime_manager_shell.py +++ b/src/drunc/processes/ssh_process_lifetime_manager_shell.py @@ -1198,6 +1198,8 @@ def _execute_bootrequest_via_ssh( f"touch {cd_path}/.write_test && rm {cd_path}/.write_test", ] self.log.debug(f"running {touch_cmd} for CMD access test") + env = os.environ.copy() + env.pop("DISPLAY", None) try: access = self.ssh( *touch_cmd, @@ -1209,6 +1211,7 @@ def _execute_bootrequest_via_ssh( _preexec_fn=on_parent_exit(signal.SIGTERM) if not is_macos else None, + _env=env, ) access.wait() @@ -1232,6 +1235,7 @@ def _execute_bootrequest_via_ssh( _bg_exc=False, _new_session=True, _preexec_fn=on_parent_exit(signal.SIGTERM) if not is_macos else None, + _env=env, ) assert isinstance(process, sh.RunningCommand), ( "Expected a RunningCommand instance from sh library" From b88b13f3ad4c8c7976e8cea5b7e412cf8942cf3e Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Wed, 22 Jul 2026 18:38:03 +0200 Subject: [PATCH 61/73] Minimizing changes --- src/drunc/processes/ssh_process_lifetime_manager_shell.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/drunc/processes/ssh_process_lifetime_manager_shell.py b/src/drunc/processes/ssh_process_lifetime_manager_shell.py index e02644124..49a141f83 100644 --- a/src/drunc/processes/ssh_process_lifetime_manager_shell.py +++ b/src/drunc/processes/ssh_process_lifetime_manager_shell.py @@ -1152,8 +1152,6 @@ def _execute_bootrequest_via_ssh( cmd_env = ";".join([f'export {n}="{v}"' for n, v in env_vars.items()]) remote_cmd += cmd_env + ";" - remote_cmd += "unset DISPLAY;" - if hasattr(boot_request.process_description, "process_execution_directory"): remote_cmd += f"cd {boot_request.process_description.process_execution_directory} ; " @@ -1181,7 +1179,6 @@ def _execute_bootrequest_via_ssh( remote_cmd += ( f"mkdir -p ${{XDG_RUNTIME_DIR:-/tmp}}/drunc ; " f"rm {log_file}; " # delete log file so no issues on ovewriting in th next line - "unset DISPLAY; " f"{command} &> {log_file} & PID=$! ; " f"trap 'kill -HUP $PID 2>/dev/null || true; wait $PID 2>/dev/null || true' HUP TERM INT QUIT ; " f"echo '{remote_metadata_json}' > {metadata_file} ; " From 5bc74f2be46e37d14b72aeeccdb7bb44f7be0197 Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Fri, 24 Jul 2026 17:53:06 +0200 Subject: [PATCH 62/73] Increasing verbosity --- .github/workflows/run_msqt.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run_msqt.yml b/.github/workflows/run_msqt.yml index 0b7112d90..6c7c85b6c 100644 --- a/.github/workflows/run_msqt.yml +++ b/.github/workflows/run_msqt.yml @@ -52,7 +52,7 @@ jobs: run: | cd nightly/NFD_DEV* || true source env.sh - daqsystemtest_integtest_bundle.sh --stop-on-failure --pytest-options "--tb=long" -k small_footprint_quick_test.py 2>&1 + daqsystemtest_integtest_bundle.sh --stop-on-failure --pytest-options "--tb=long" -k small_footprint_quick_test.py --verbosity 5 2>&1 - name: Print test log if: always() From e306835dc998804166c277afddb7661eb00997fb Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Fri, 24 Jul 2026 18:11:10 +0200 Subject: [PATCH 63/73] Show me the logs --- .github/workflows/run_msqt.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/run_msqt.yml b/.github/workflows/run_msqt.yml index 6c7c85b6c..c46d9aac3 100644 --- a/.github/workflows/run_msqt.yml +++ b/.github/workflows/run_msqt.yml @@ -53,6 +53,7 @@ jobs: cd nightly/NFD_DEV* || true source env.sh daqsystemtest_integtest_bundle.sh --stop-on-failure --pytest-options "--tb=long" -k small_footprint_quick_test.py --verbosity 5 2>&1 + cat /tmp/pytest-of-/dunedaq_integtest_bundle*.log - name: Print test log if: always() From acc3b82cbe5f62bc162ae7fc2e155ccc9925fb14 Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Fri, 24 Jul 2026 18:11:52 +0200 Subject: [PATCH 64/73] Changing the tmpdir --- .github/workflows/run_msqt.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run_msqt.yml b/.github/workflows/run_msqt.yml index c46d9aac3..fd694cd7c 100644 --- a/.github/workflows/run_msqt.yml +++ b/.github/workflows/run_msqt.yml @@ -52,8 +52,8 @@ jobs: run: | cd nightly/NFD_DEV* || true source env.sh - daqsystemtest_integtest_bundle.sh --stop-on-failure --pytest-options "--tb=long" -k small_footprint_quick_test.py --verbosity 5 2>&1 - cat /tmp/pytest-of-/dunedaq_integtest_bundle*.log + daqsystemtest_integtest_bundle.sh --stop-on-failure --pytest-options "--tb=long" -k small_footprint_quick_test.py --verbosity 5 --tmpdir . 2>&1 + cat dunedaq_integtest_bundle*.log - name: Print test log if: always() From 8901f3a35c2eac1e19f9dd729cbc9e890feae687 Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Fri, 24 Jul 2026 18:18:46 +0200 Subject: [PATCH 65/73] Skipping recommended resource check --- .github/workflows/run_msqt.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run_msqt.yml b/.github/workflows/run_msqt.yml index fd694cd7c..6568554f4 100644 --- a/.github/workflows/run_msqt.yml +++ b/.github/workflows/run_msqt.yml @@ -52,7 +52,7 @@ jobs: run: | cd nightly/NFD_DEV* || true source env.sh - daqsystemtest_integtest_bundle.sh --stop-on-failure --pytest-options "--tb=long" -k small_footprint_quick_test.py --verbosity 5 --tmpdir . 2>&1 + daqsystemtest_integtest_bundle.sh --stop-on-failure --pytest-options "--tb=long --skip-resource-checks" -k small_footprint_quick_test.py --verbosity 5 --tmpdir . 2>&1 cat dunedaq_integtest_bundle*.log - name: Print test log From c1c50d9f6d8f2fd0a233210aaadeafaac28cb674 Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Fri, 24 Jul 2026 18:35:16 +0200 Subject: [PATCH 66/73] Rerunning with skip resource checks --- .github/workflows/run_msqt.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run_msqt.yml b/.github/workflows/run_msqt.yml index 6568554f4..5a7311fb0 100644 --- a/.github/workflows/run_msqt.yml +++ b/.github/workflows/run_msqt.yml @@ -52,7 +52,7 @@ jobs: run: | cd nightly/NFD_DEV* || true source env.sh - daqsystemtest_integtest_bundle.sh --stop-on-failure --pytest-options "--tb=long --skip-resource-checks" -k small_footprint_quick_test.py --verbosity 5 --tmpdir . 2>&1 + daqsystemtest_integtest_bundle.sh --stop-on-failure --pytest-options "--skip-resource-checks" -k small_footprint_quick_test.py --verbosity 5 --tmpdir . 2>&1 cat dunedaq_integtest_bundle*.log - name: Print test log From 6e0eb7516cee252bc98998f8b7115e7f61915022 Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Mon, 27 Jul 2026 10:28:08 +0200 Subject: [PATCH 67/73] Changing the relevant log path --- .github/workflows/run_msqt.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run_msqt.yml b/.github/workflows/run_msqt.yml index 5a7311fb0..d12cd503c 100644 --- a/.github/workflows/run_msqt.yml +++ b/.github/workflows/run_msqt.yml @@ -53,7 +53,7 @@ jobs: cd nightly/NFD_DEV* || true source env.sh daqsystemtest_integtest_bundle.sh --stop-on-failure --pytest-options "--skip-resource-checks" -k small_footprint_quick_test.py --verbosity 5 --tmpdir . 2>&1 - cat dunedaq_integtest_bundle*.log + cat pytest-of-*/dunedaq_integtest_bundle*.log - name: Print test log if: always() From 456a705fc9279b4792f0967ce64bcf17f06c5801 Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Mon, 27 Jul 2026 10:36:08 +0200 Subject: [PATCH 68/73] Making debugging clearer --- .github/workflows/run_msqt.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/run_msqt.yml b/.github/workflows/run_msqt.yml index d12cd503c..9c1dda80a 100644 --- a/.github/workflows/run_msqt.yml +++ b/.github/workflows/run_msqt.yml @@ -53,6 +53,7 @@ jobs: cd nightly/NFD_DEV* || true source env.sh daqsystemtest_integtest_bundle.sh --stop-on-failure --pytest-options "--skip-resource-checks" -k small_footprint_quick_test.py --verbosity 5 --tmpdir . 2>&1 + echo "ECHOING THE INTEGRATION TEST BUNDLE LOG FILE" cat pytest-of-*/dunedaq_integtest_bundle*.log - name: Print test log From bc9eb6bbac26641038a33e6440eed46e25c5101d Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Mon, 27 Jul 2026 10:44:58 +0200 Subject: [PATCH 69/73] Updating daqsystemtest_integtest_bundle to dunedaq_integtest_bundle --- .github/workflows/run_msqt.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run_msqt.yml b/.github/workflows/run_msqt.yml index 9c1dda80a..91e2871ec 100644 --- a/.github/workflows/run_msqt.yml +++ b/.github/workflows/run_msqt.yml @@ -52,7 +52,7 @@ jobs: run: | cd nightly/NFD_DEV* || true source env.sh - daqsystemtest_integtest_bundle.sh --stop-on-failure --pytest-options "--skip-resource-checks" -k small_footprint_quick_test.py --verbosity 5 --tmpdir . 2>&1 + dunedaq_integtest_bundle.sh --stop-on-failure --pytest-options "--skip-resource-checks" -k small_footprint_quick_test.py --verbosity 5 --tmpdir . 2>&1 echo "ECHOING THE INTEGRATION TEST BUNDLE LOG FILE" cat pytest-of-*/dunedaq_integtest_bundle*.log From 40f91fd0c137c6df2fb0fd0c86e2297f0a8c0544 Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Mon, 27 Jul 2026 11:39:02 +0200 Subject: [PATCH 70/73] Adding some more docs, running some checks --- .github/workflows/run_msqt.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/run_msqt.yml b/.github/workflows/run_msqt.yml index 91e2871ec..9800f6094 100644 --- a/.github/workflows/run_msqt.yml +++ b/.github/workflows/run_msqt.yml @@ -39,12 +39,12 @@ jobs: python -m pip install --upgrade pip cd sourcecode git clone https://github.com/${{ github.event.pull_request.head.repo.owner.login }}/druncschema.git -b ${{ github.head_ref }} || git clone https://github.com/DUNE-DAQ/druncschema.git - git clone https://github.com/${{ github.event.pull_request.head.repo.owner.login }}/drunc.git -b ${{ github.head_ref }} || git clone https://github.com/DUNE-DAQ/drunc.git cd druncschema pip install .[test] - cd - + cd ../../pythoncode + git clone https://github.com/${{ github.event.pull_request.head.repo.owner.login }}/drunc.git -b ${{ github.head_ref }} || git clone https://github.com/DUNE-DAQ/drunc.git cd drunc - pip install -e .[dev] + pip install .[test] echo "DRUNC_DIR=$(pwd)" >> $GITHUB_ENV - name: Test with MSQT @@ -52,6 +52,10 @@ jobs: run: | cd nightly/NFD_DEV* || true source env.sh + echo "Check the permissions of the path used for testing" + ls -ld /tmp + echo "Run the tests as the current user: $(whoami)" + echo "Test command: dunedaq_integtest_bundle.sh --stop-on-failure --pytest-options "--skip-resource-checks" -k small_footprint_quick_test.py --verbosity 5 --tmpdir . 2>&1" dunedaq_integtest_bundle.sh --stop-on-failure --pytest-options "--skip-resource-checks" -k small_footprint_quick_test.py --verbosity 5 --tmpdir . 2>&1 echo "ECHOING THE INTEGRATION TEST BUNDLE LOG FILE" cat pytest-of-*/dunedaq_integtest_bundle*.log From d222eb17b3fd0af1d6c3de5aaf6c43abaf69975a Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Mon, 27 Jul 2026 11:45:21 +0200 Subject: [PATCH 71/73] Looking at the wrong path, retrying --- .github/workflows/run_msqt.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run_msqt.yml b/.github/workflows/run_msqt.yml index 9800f6094..3f0edfb9d 100644 --- a/.github/workflows/run_msqt.yml +++ b/.github/workflows/run_msqt.yml @@ -36,8 +36,8 @@ jobs: run: | cd nightly/NFD_DEV* || true source env.sh - python -m pip install --upgrade pip cd sourcecode + echo "Checking part of the git clone resolution path: ${{ github.event.pull_request.head.repo.owner.login }}" git clone https://github.com/${{ github.event.pull_request.head.repo.owner.login }}/druncschema.git -b ${{ github.head_ref }} || git clone https://github.com/DUNE-DAQ/druncschema.git cd druncschema pip install .[test] @@ -53,7 +53,7 @@ jobs: cd nightly/NFD_DEV* || true source env.sh echo "Check the permissions of the path used for testing" - ls -ld /tmp + ls -lahrt . echo "Run the tests as the current user: $(whoami)" echo "Test command: dunedaq_integtest_bundle.sh --stop-on-failure --pytest-options "--skip-resource-checks" -k small_footprint_quick_test.py --verbosity 5 --tmpdir . 2>&1" dunedaq_integtest_bundle.sh --stop-on-failure --pytest-options "--skip-resource-checks" -k small_footprint_quick_test.py --verbosity 5 --tmpdir . 2>&1 From cd6a467d77d96cf6d2f6a82b91d832b7d86f847f Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Mon, 27 Jul 2026 11:51:27 +0200 Subject: [PATCH 72/73] Some minor changes --- .github/actions/setup-dev-nightly/action.yml | 2 +- .github/workflows/run_msqt.yml | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/actions/setup-dev-nightly/action.yml b/.github/actions/setup-dev-nightly/action.yml index 00a89c88c..9effc60fc 100644 --- a/.github/actions/setup-dev-nightly/action.yml +++ b/.github/actions/setup-dev-nightly/action.yml @@ -10,7 +10,7 @@ runs: mkdir nightly cd nightly source /cvmfs/dunedaq.opensciencegrid.org/setup_dunedaq.sh - setup_dbt latest_v5 + setup_dbt latest dbt-create -n last_fddaq cd NFD_DEV* source env.sh diff --git a/.github/workflows/run_msqt.yml b/.github/workflows/run_msqt.yml index 3f0edfb9d..91691b0d3 100644 --- a/.github/workflows/run_msqt.yml +++ b/.github/workflows/run_msqt.yml @@ -54,9 +54,11 @@ jobs: source env.sh echo "Check the permissions of the path used for testing" ls -lahrt . + echo "Check the repo structure before running tests" + tree -L 3 echo "Run the tests as the current user: $(whoami)" - echo "Test command: dunedaq_integtest_bundle.sh --stop-on-failure --pytest-options "--skip-resource-checks" -k small_footprint_quick_test.py --verbosity 5 --tmpdir . 2>&1" - dunedaq_integtest_bundle.sh --stop-on-failure --pytest-options "--skip-resource-checks" -k small_footprint_quick_test.py --verbosity 5 --tmpdir . 2>&1 + echo 'Test command: dunedaq_integtest_bundle.sh --stop-on-failure --pytest-options "--skip-resource-checks" -k small_footprint_quick_test.py --verbosity 5 --tmpdir . 2>&1' + dunedaq_integtest_bundle.sh --stop-on-failure --pytest-options '--skip-resource-checks' -k small_footprint_quick_test.py --verbosity 5 --tmpdir . 2>&1 echo "ECHOING THE INTEGRATION TEST BUNDLE LOG FILE" cat pytest-of-*/dunedaq_integtest_bundle*.log From 8f5a00ad70872d3db9b079930f050074bf7e41d7 Mon Sep 17 00:00:00 2001 From: PawelPlesniak Date: Mon, 27 Jul 2026 12:01:04 +0200 Subject: [PATCH 73/73] Working around the tree command --- .github/workflows/run_msqt.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run_msqt.yml b/.github/workflows/run_msqt.yml index 91691b0d3..b257a4e31 100644 --- a/.github/workflows/run_msqt.yml +++ b/.github/workflows/run_msqt.yml @@ -55,7 +55,8 @@ jobs: echo "Check the permissions of the path used for testing" ls -lahrt . echo "Check the repo structure before running tests" - tree -L 3 + ls -lahrt sourcecode + ls -lahrt pythoncode echo "Run the tests as the current user: $(whoami)" echo 'Test command: dunedaq_integtest_bundle.sh --stop-on-failure --pytest-options "--skip-resource-checks" -k small_footprint_quick_test.py --verbosity 5 --tmpdir . 2>&1' dunedaq_integtest_bundle.sh --stop-on-failure --pytest-options '--skip-resource-checks' -k small_footprint_quick_test.py --verbosity 5 --tmpdir . 2>&1