Skip to content

Commit

Permalink
Merge branch 'main' into deployment-docs
Browse files Browse the repository at this point in the history
  • Loading branch information
cc-a committed Dec 13, 2024
2 parents 0c5068c + 9bcb02a commit 25c3bd6
Show file tree
Hide file tree
Showing 37 changed files with 1,594 additions and 43 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
matrix:
os:
- ubuntu-latest
python-version: ['3.11']
python-version: ['3.10']

steps:
- uses: actions/checkout@v4
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:3.11-slim-bookworm AS python
FROM python:3.10-slim-bookworm AS python

FROM python AS build

Expand All @@ -10,7 +10,7 @@ RUN /root/.local/bin/poetry config virtualenvs.create false && \

FROM python

COPY --from=build /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=build /usr/local/lib/python3.10/site-packages /usr/local/lib/python3.10/site-packages
COPY --from=build /usr/local/bin /usr/local/bin
EXPOSE 8000
COPY --chown=nobody:nogroup . /usr/src/app
Expand Down
44 changes: 44 additions & 0 deletions controller/app_tree.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Application tree information."""

from dataclasses import dataclass

from django.utils.safestring import mark_safe


@dataclass
class AppTree:
"""Application tree information."""

name: str
"""The name of the application."""

children: list["AppTree"]
"""The children of the application."""

host: str
"""The hostname of the application."""

detector: str = ""
"""The detector of the application."""

def to_list(self, indent: str = "") -> list[dict[str, str]]:
"""Convert the app tree to a list of dicts with name indentation.
Args:
indent: The string to use to indent the app name in the table.
Returns:
The list of dicts with the app tree information, indenting the name based
on the depth within the tree.
"""
table_data = [
{
"name": mark_safe(indent + self.name),
"host": self.host,
"detector": self.detector,
}
]
for child in self.children:
table_data.extend(child.to_list(indent + "⋅" + " " * 8))

return table_data
149 changes: 149 additions & 0 deletions controller/controller_interface.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,29 @@
"""Module providing functions to interact with the drunc controller."""

import functools
from typing import Any

from django.conf import settings
from drunc.connectivity_service.client import ConnectivityServiceClient
from drunc.controller.controller_driver import ControllerDriver
from drunc.utils.grpc_utils import pack_to_any
from drunc.utils.shell_utils import create_dummy_token_from_uname
from druncschema.controller_pb2 import Argument, FSMCommand, FSMResponseFlag, Status
from druncschema.generic_pb2 import bool_msg, float_msg, int_msg, string_msg
from druncschema.request_response_pb2 import Description

from process_manager.process_manager_interface import get_hostnames

from .app_tree import AppTree

MSG_TYPE = {
Argument.Type.INT: int_msg,
Argument.Type.FLOAT: float_msg,
Argument.Type.STRING: string_msg,
Argument.Type.BOOL: bool_msg,
}
"""Mapping of argument types to their protobuf message types."""


@functools.cache
def get_controller_uri() -> str:
Expand Down Expand Up @@ -36,3 +52,136 @@ def get_controller_driver() -> ControllerDriver:
def get_controller_status() -> Description:
"""Get the controller status."""
return get_controller_driver().status()


def get_fsm_state() -> str:
"""Get the finite state machine state.
Returns:
str: The state the FSM is in.
"""
return get_controller_status().data.state


def send_event( # type: ignore[misc]
event: str,
arguments: dict[str, Any],
) -> None:
"""Send an event to the controller.
Args:
event: The event to send.
arguments: The arguments for the event.
Raises:
RuntimeError: If the event failed, reporting the flag.
"""
controller = get_controller_driver()
controller.take_control()
command = FSMCommand(
command_name=event, arguments=process_arguments(event, arguments)
)
response = controller.execute_fsm_command(command)
if response.flag != FSMResponseFlag.FSM_EXECUTED_SUCCESSFULLY:
raise RuntimeError(
f"Event '{event}' failed with flag {FSMResponseFlag(response.flag)} "
f"and message '{response.data}'"
)


def get_arguments(event: str) -> list[Argument]:
"""Get the arguments required to run an event.
Args:
event: The event to get the arguments for.
Returns:
The arguments for the event.
"""
controller = get_controller_driver()
events = controller.describe_fsm().data.commands
try:
command = next(c for c in events if c.name == event)
except StopIteration:
raise ValueError(
f"Event '{event}' not found in FSM. Valid events are: "
f"{', '.join(c.name for c in events)}"
)
return command.arguments


def process_arguments( # type: ignore[misc]
event: str,
arguments: dict[str, Any],
) -> dict[str, Any]:
"""Process the arguments for an event.
Args:
event: The event to process.
arguments: The arguments to process.
Returns:
dict: The processed arguments in a form compatible with the protobuf definition.
"""
valid_args = get_arguments(event)
processed = {}
for arg in valid_args:
if arg.name not in arguments or arguments[arg.name] is None:
continue

processed[arg.name] = pack_to_any(MSG_TYPE[arg.type](value=arguments[arg.name]))

return processed


def get_app_tree(
user: str,
status: Status | None = None,
hostnames: dict[str, str] | None = None,
detectors: dict[str, str] | None = None,
) -> AppTree:
"""Get the application tree for the controller.
It recursively gets the tree of applications and their children.
Args:
user: The user to get the tree for.
status: The status to get the tree for. If None, the root controller status is
used as the starting point.
hostnames: The hostnames of the applications. If None, the hostnames are
retrieved from the process manager.
detectors: The detectors reported by the controller for each application.
Returns:
The application tree as a AppType object.
"""
status = status or get_controller_status()
hostnames = hostnames or get_hostnames(user)
detectors = detectors or get_detectors()

return AppTree(
status.name,
[get_app_tree(user, app, hostnames, detectors) for app in status.children],
hostnames.get(status.name, "unknown"),
detectors.get(status.name, ""),
)


def get_detectors(description: Description | None = None) -> dict[str, str]:
"""Get the detectors available in the controller for each application.
Returns:
The detectors available in the controller.
"""
detectors = {}
if description is None:
description = get_controller_driver().describe()

if hasattr(description.data, "info"):
detectors[description.data.name] = description.data.info

for child in description.children:
if child is not None:
detectors.update(get_detectors(child))

return detectors
47 changes: 47 additions & 0 deletions controller/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Module to create a Django form from a list of Arguments."""

from django.forms import BooleanField, CharField, Field, FloatField, Form, IntegerField
from druncschema.controller_pb2 import Argument

from . import controller_interface as ci


def get_form_for_event(event: str) -> type[Form]:
"""Creates a form from a list of Arguments.
We loop over the arguments and create a form field for each one. The field
type is determined by the argument type. The initial value is set to the
default value of the argument, which needs decoding as it is received in binary
form. If the argument is mandatory, the field is required. Finally, the form class
is created dynamically and returned.
Args:
event: Event to get the form for.
Returns:
A form class including the required arguments.
"""
data = ci.get_arguments(event)
fields: dict[str, Field] = {}
for item in data:
name = item.name
mandatory = item.presence == Argument.Presence.MANDATORY
initial = item.default_value.value.decode()
match item.type:
case Argument.Type.INT:
initial = int(initial) if initial else initial
fields[name] = IntegerField(required=mandatory, initial=initial)
case Argument.Type.FLOAT:
initial = float(initial) if initial else initial
fields[name] = FloatField(required=mandatory, initial=initial)
case Argument.Type.STRING:
# Remove the new line and end of string characters causing trouble
# when submitting the form
initial = initial.strip().replace(chr(4), "")
fields[name] = CharField(required=mandatory, initial=initial)
case Argument.Type.BOOL:
# We assume this is provided as an integer, 1 or 0
initial = bool(int(initial)) if initial else initial
fields[name] = BooleanField(required=mandatory, initial=initial)

return type("DynamicForm", (Form,), fields)
37 changes: 37 additions & 0 deletions controller/fsm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Module that implements `drunc` finite state machine."""

STATES: dict[str, list[str]] = {
"initial": ["conf"],
"configured": ["scrap", "start"],
"ready": ["enable_triggers", "drain_dataflow"],
"running": ["disable_triggers"],
"dataflow_drained": ["stop_trigger_sources"],
"trigger_sources_stopped": ["stop"],
}

EVENTS: dict[str, str] = {
"conf": "configured",
"scrap": "initial",
"start": "ready",
"enable_triggers": "running",
"disable_triggers": "ready",
"drain_dataflow": "dataflow_drained",
"stop_trigger_sources": "trigger_sources_stopped",
"stop": "configured",
}


def get_fsm_architecture() -> dict[str, dict[str, str]]:
"""Return the FSM states and events as a dictionary.
The states will be the keys and the valid events for each state a list of
values with their corresponding target state. All in all, this provides the whole
architecture of the FSM.
Returns:
The states and events as a dictionary.
"""
return {
state: {event: EVENTS[event] for event in events}
for state, events in STATES.items()
}
Loading

0 comments on commit 25c3bd6

Please sign in to comment.