Merge branch 'main' into SIENTIAPDE-988-criar-ingestor-opc

This commit is contained in:
2025-06-09 16:45:45 -03:00
committed by GitHub
24 changed files with 1735 additions and 444 deletions

3
.coveragerc Normal file
View File

@@ -0,0 +1,3 @@
[run]
omit =
ingestor/app.py

View File

@@ -80,4 +80,5 @@ jobs:
-Dsonar.host.url=$SONAR_HOST_URL \ -Dsonar.host.url=$SONAR_HOST_URL \
-Dsonar.token=$SONAR_TOKEN \ -Dsonar.token=$SONAR_TOKEN \
-Dsonar.python.version=3.11 \ -Dsonar.python.version=3.11 \
-Dsonar.projectVersion=1.0.0 -Dsonar.projectVersion=1.2.0 \
-Dsonar.coverage.exclusions=ingestor/app.py

18
.vscode/settings.json vendored
View File

@@ -1,7 +1,13 @@
{ {
"python.testing.pytestArgs": [ "python.testing.pytestArgs": ["."],
"." "python.testing.unittestEnabled": false,
], "python.testing.pytestEnabled": true,
"python.testing.unittestEnabled": false, "sonarlint.connectedMode.project": {
"python.testing.pytestEnabled": true "connectionId": "sonardev-sientia-ai",
} "projectKey": "Aignosi_sientia-dataops-opc-ingestor_1642c8bf-a148-4911-8362-0903d7fef99a"
},
"python.languageServer": "Pylance",
"python.analysis.typeCheckingMode": "standard",
"editor.suggestSelection": "first",
"windsurfPyright.disableLanguageServices": true
}

View File

@@ -1,4 +1,4 @@
# sientia-dataops-opc-gateway # sientia-dataops-opc-ingestor
OPC gateway to manage Scouter pipelines OPC gateway to manage Scouter pipelines
## Local tests ## Local tests
@@ -25,6 +25,20 @@ docker compose build --ssh default=$HOME/.ssh/id_ed25519_docker
docker compose up -d docker compose up -d
``` ```
### Run Ingestor
The ingestor can be run as a docker container or as a python script.
As a docker container:
Uncomment the ingestor service in docker-compose.yaml
```
docker compose up -d ingestor
```
As a python script:
```
python -m ingestor.app
```
### Populate redis server ### Populate redis server
Create venv with python3.11 Create venv with python3.11
``` ```

View File

@@ -1,19 +1,19 @@
version: '3.8' version: '3.8'
services: services:
ingestor: # ingestor:
build: # build:
context: . # context: .
environment: # environment:
HOSTNAME: ingestor # HOSTNAME: ingestor
container_name: ingestor # container_name: ingestor
depends_on: # depends_on:
- kafka # - kafka
- redis # - redis
networks: # networks:
- kafka-net # - kafka-net
env_file: # env_file:
- .env # - .env
zookeeper: zookeeper:
image: confluentinc/cp-zookeeper:latest image: confluentinc/cp-zookeeper:latest
@@ -88,7 +88,7 @@ services:
GIT_BRANCH: ${SIMULATOR_GIT_BRANCH} GIT_BRANCH: ${SIMULATOR_GIT_BRANCH}
container_name: simulator container_name: simulator
ports: ports:
- "4840:4840" - "4841:4840"
depends_on: depends_on:
- kafka - kafka
- redis - redis

View File

@@ -1,19 +1,82 @@
from time import sleep import os
import signal
import traceback
from threading import Event
from time import sleep, time
from prometheus_client import start_http_server
import ingestor.metrics as metrics
from ingestor.ingestor import Ingestor from ingestor.ingestor import Ingestor
exit_signal = Event()
POD_ID = os.getenv("HOSTNAME", "localhost")
def main(): def main():
start_prometheus_server()
ingestor = Ingestor() ingestor = Ingestor()
try:
ingestor.prepare_ingestor()
except Exception as e:
metrics.APP_ERRORS_TOTAL.labels(
pod_id=POD_ID).inc() # Increment errors
print(f"Failed to prepare ingestor: {e}")
exit_signal.set()
ingestor.logger.info("Ingestor prepared. Starting main loop.")
ingestor.prepare_ingestor() while not exit_signal.is_set():
start_time = time() # Start loop timer
try:
ingestor.loop()
metrics.APP_LOOP_COUNT.labels(
pod_id=POD_ID).inc() # Increment loop counter
while True: exit_signal.wait(ingestor.poll_interval)
ingestor.loop() except KeyboardInterrupt: # Handle Ctrl+C gracefully
print("KeyboardInterrupt received. Setting exit_signal flag.")
exit_signal.set()
except Exception:
print("Exception in main loop. Setting exit_signal flag.")
traceback.print_exc()
metrics.APP_ERRORS_TOTAL.labels(
pod_id=POD_ID).inc() # Increment errors
exit_signal.set()
finally:
# Record loop duration
duration = time() - start_time
metrics.APP_LOOP_DURATION.labels(pod_id=POD_ID).observe(duration)
# Sleep for poll interval ingestor.shutdown()
sleep(ingestor.poll_interval) metrics.APP_UP.labels(pod_id=POD_ID).set(0) # Mark app as DOWN
ingestor.logger.info("Main loop exit_signaled.")
# Give Prometheus a chance to scrape one last time before exiting (optional)
sleep(5)
os._exit(0)
def signal_handler(_signum, _frame):
print(f"Received signal {_signum}. Setting exit_signal flag.")
exit_signal.set()
def start_prometheus_server():
try:
port = int(os.getenv("HTTP_SERVER_PORT", 4840))
start_http_server(port)
print(f"Prometheus server started on port {port}.")
metrics.APP_UP.labels(pod_id=POD_ID).set(1) # Mark app as UP
except Exception as e:
print(f"Failed to start Prometheus server: {e}")
os._exit(1)
if __name__ == "__main__": if __name__ == "__main__":
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGHUP, signal_handler)
main() main()

View File

@@ -1,8 +1,13 @@
from logging import Formatter, StreamHandler, getLogger from logging import Formatter, StreamHandler, getLogger
from os import getenv from os import getenv
from copy import deepcopy
from typing import Dict, Any
from sientia_do.notifications.handlers import NotificationHandler
from ingestor.managers.ingestor_manager import IngestorManager from ingestor.managers.ingestor_manager import IngestorManager
from sientia_do.notifications.handlers import NotificationHandler
import ingestor.metrics as metrics
class Ingestor: class Ingestor:
@@ -10,7 +15,8 @@ class Ingestor:
""" """
Initializes the ingestor with configuration values retrieved from environment variables. Initializes the ingestor with configuration values retrieved from environment variables.
Environment Variables: Environment Variables:
KAFKA_SERVERS (str): Comma-separated list of Kafka server addresses. Defaults to "localhost:9092". KAFKA_SERVERS (str): Comma-separated list of Kafka server addresses.
Defaults to "localhost:9092".
REDIS_HOST (str): Hostname of the Redis server. Defaults to "localhost". REDIS_HOST (str): Hostname of the Redis server. Defaults to "localhost".
REDIS_PORT (int): Port number of the Redis server. Defaults to 6379. REDIS_PORT (int): Port number of the Redis server. Defaults to 6379.
LEASE_TTL (int): Time-to-live for leases in seconds. Defaults to 10. LEASE_TTL (int): Time-to-live for leases in seconds. Defaults to 10.
@@ -29,13 +35,13 @@ class Ingestor:
kafka_servers = getenv("KAFKA_SERVERS", "localhost:9092") kafka_servers = getenv("KAFKA_SERVERS", "localhost:9092")
self.redis_host = getenv("REDIS_HOST", "localhost") self.redis_host = getenv("REDIS_HOST", "localhost")
self.redis_port = int(getenv("REDIS_PORT", 6379)) self.redis_port = int(getenv("REDIS_PORT", "6379"))
self.redis_username = getenv("REDIS_USERNAME", None) self.redis_username = getenv("REDIS_USERNAME", None)
self.redis_password = getenv("REDIS_PASSWORD", None) self.redis_password = getenv("REDIS_PASSWORD", None)
self.lease_ttl = int(getenv("LEASE_TTL", 10)) self.lease_ttl = int(getenv("LEASE_TTL", "10"))
self.heartbeat_ttl = int(getenv("HEARTBEAT_TTL", 20)) self.heartbeat_ttl = int(getenv("HEARTBEAT_TTL", "20"))
self.pod_id = getenv("HOSTNAME", "localhost") self.pod_id = getenv("HOSTNAME", "localhost")
self.poll_interval = int(getenv("POLL_INTERVAL", 5)) self.poll_interval = int(getenv("POLL_INTERVAL", "5"))
self.kafka_servers = kafka_servers.split(",") self.kafka_servers = kafka_servers.split(",")
self.logger = None self.logger = None
@@ -45,9 +51,20 @@ class Ingestor:
logger=self.logger, logger=self.logger,
project_name="OPC_INGESTOR" project_name="OPC_INGESTOR"
) )
# build args for build notificarions components
# call build notifications components self.notification_handler.base_notification.pipeline = 'OPC_INGESTOR'
self.notification_handler.base_notification.trigger = 'INGESTOR'
self.notification_handler.base_notification.model_name = '-'
self.notification_handler.base_notification.model_id = '-'
self.ingestor_manager = None
def shutdown(self):
if self.ingestor_manager:
self.ingestor_manager.shutdown()
def __del__(self):
self.shutdown()
def init_logger(self): def init_logger(self):
""" """
@@ -64,7 +81,7 @@ class Ingestor:
logger.setLevel(getenv("LOG_LEVEL", "INFO")) logger.setLevel(getenv("LOG_LEVEL", "INFO"))
handler = StreamHandler() handler = StreamHandler()
formatter = Formatter( formatter = Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s') "%(asctime)s - %(name)s - %(levelname)s - %(message)s")
handler.setFormatter(formatter) handler.setFormatter(formatter)
logger.addHandler(handler) logger.addHandler(handler)
@@ -114,9 +131,17 @@ class Ingestor:
""" """
self.ingestor_manager = IngestorManager( self.ingestor_manager = IngestorManager(
self.kafka_servers, self.redis_host, self.redis_port, self.lease_ttl, self.kafka_servers,
self.heartbeat_ttl, self.pod_id, self.poll_interval, self.logger, self.redis_host,
self.notification_handler, self.redis_username, self.redis_password self.redis_port,
self.lease_ttl,
self.heartbeat_ttl,
self.pod_id,
self.poll_interval,
self.logger,
self.notification_handler,
self.redis_username,
self.redis_password,
) )
# Declare ingestor ative # Declare ingestor ative
@@ -124,10 +149,14 @@ class Ingestor:
# Get slot lease # Get slot lease
acquired = self.ingestor_manager.get_slot_leases() acquired = self.ingestor_manager.get_slot_leases()
self.logger.info(f"Acquired slots: {acquired}") self.logger.info("Acquired slots: %s", acquired)
self.handle_acquired_tags(acquired) self.handle_acquired_tags(acquired)
metrics.SLOTS_MANAGED.labels(pod_id=self.pod_id).set(
len(self.ingestor_manager.managed_tags)
) # Set initial
def manage_no_slots(self, number_of_slots: int): def manage_no_slots(self, number_of_slots: int):
""" """
Manages the scenario where there are no slots assigned to the ingestor. Manages the scenario where there are no slots assigned to the ingestor.
@@ -143,11 +172,11 @@ class Ingestor:
# This ingestor is active and has no slots, so we need to try to # This ingestor is active and has no slots, so we need to try to
# Get slot lease # Get slot lease
acquired = self.ingestor_manager.get_slot_leases(1) self.ingestor_manager.get_slot_leases(1)
self.handle_acquired_tags(acquired) def manage_leases(
self, available_slots: int, lacking_ingestors: int, slot_diff: int
def manage_leases(self, available_slots: int, lacking_ingestors: int, slot_diff: int): ):
""" """
Manages the allocation and deallocation of slot leases for ingestors based on Manages the allocation and deallocation of slot leases for ingestors based on
the number of available slots, lacking ingestors, and slot differences. the number of available slots, lacking ingestors, and slot differences.
@@ -167,16 +196,14 @@ class Ingestor:
if available_slots > 0 and lacking_ingestors > 0: if available_slots > 0 and lacking_ingestors > 0:
# Some ingestors are innactive, so theres "available_slots" slots available # Some ingestors are innactive, so theres "available_slots" slots available
self.logger.info(f"Slots available: {available_slots}") self.logger.info("Slots available: %s", available_slots)
# Get slot lease # Get slot lease
acquired = self.ingestor_manager.get_slot_leases(available_slots) self.ingestor_manager.get_slot_leases(available_slots)
self.handle_acquired_tags(acquired) elif lacking_ingestors <= 0 and slot_diff > 0:
elif lacking_ingestors == 0 and slot_diff > 0: self.logger.info("Extra slots available: %s", slot_diff)
self.logger.info(f"Extra slots available: {slot_diff}")
# There's enough slots for all ingestors, but this ingestor has more than one slot # There's enough slots for all ingestors, but this ingestor has more than one slot
# So we need to drop the extra leases # So we need to drop the extra leases
@@ -184,6 +211,53 @@ class Ingestor:
self.ingestor_manager.drop_slot_leases(overleases) self.ingestor_manager.drop_slot_leases(overleases)
for lease in overleases:
self.ingestor_manager.unsubscribe_slot(lease)
self.ingestor_manager.managed_tags.pop(lease)
# Update metric after removal
metrics.SLOTS_MANAGED.labels(pod_id=self.pod_id).set(
len(self.ingestor_manager.managed_tags)
)
def update_ingestor_manager(self, old_managed_tags: Dict[str, Any]):
"""
Updates the ingestor manager with the new managed tags.
Args:
old_managed_tags (Dict[str, Any]): The old managed tags.
"""
self.logger.debug(
"Current managed tags: %s", self.ingestor_manager.managed_tags
)
self.ingestor_manager.update_opc_servers()
new_managed_tags = deepcopy(self.ingestor_manager.managed_tags)
self.logger.debug(
"Comparing new managed tags %s with old managed tags %s",
new_managed_tags,
old_managed_tags,
)
for slot, config in new_managed_tags.items():
if slot not in old_managed_tags:
self.ingestor_manager.subscribe_to_tags({slot: config})
continue
if config != old_managed_tags[slot]:
self.ingestor_manager.unsubscribe_slot(slot)
self.ingestor_manager.subscribe_to_tags({slot: config})
for slot in old_managed_tags.keys():
if slot not in new_managed_tags:
self.ingestor_manager.unsubscribe_slot(slot)
# Ensure the gauge is updated after any potential changes here
metrics.SLOTS_MANAGED.labels(pod_id=self.pod_id).set(
len(self.ingestor_manager.managed_tags)
)
def loop(self): def loop(self):
""" """
Executes the main loop for managing ingestors and slots. Executes the main loop for managing ingestors and slots.
@@ -207,33 +281,56 @@ class Ingestor:
self.logger.info("Polling for slot updates...") self.logger.info("Polling for slot updates...")
# Get active ingestors # Get active ingestors
current_managed_tags = deepcopy(self.ingestor_manager.managed_tags)
ingestors = self.ingestor_manager.get_active_ingestors() ingestors = self.ingestor_manager.get_active_ingestors()
number_of_ingestors = len(ingestors) number_of_ingestors = len(ingestors)
number_of_leases = self.ingestor_manager.get_number_of_leases() number_of_leases = self.ingestor_manager.get_number_of_leases()
number_of_slots = self.ingestor_manager.get_number_of_slots() number_of_slots = self.ingestor_manager.get_number_of_slots()
# Update active ingestors gauge
metrics.ACTIVE_INGESTORS.set(number_of_ingestors)
# Handle no slots # Handle no slots
self.logger.debug("Managing no slots...")
self.manage_no_slots(number_of_slots) self.manage_no_slots(number_of_slots)
available_slots = number_of_slots - number_of_leases available_slots = number_of_slots - number_of_leases
lacking_ingestors = number_of_slots - number_of_ingestors lacking_ingestors = number_of_slots - number_of_ingestors
slot_diff = len(self.ingestor_manager.managed_tags) - 1 slot_diff = len(self.ingestor_manager.managed_tags) - 1
self.logger.debug("Managing leases...")
self.manage_leases(available_slots, lacking_ingestors, slot_diff) self.manage_leases(available_slots, lacking_ingestors, slot_diff)
# Update managed slots gauge
metrics.SLOTS_MANAGED.labels(pod_id=self.pod_id).set(
len(self.ingestor_manager.managed_tags)
)
self.logger.debug( self.logger.debug(
f"Active ingestors: {ingestors}, " "Active ingestors: %s, "
f"Number of slots: {number_of_slots}, " "Number of slots: %s, "
f"Number of leases: {number_of_leases}, " "Number of leases: %s, "
f"Managed tags: {self.ingestor_manager.managed_tags}" "Managed tags: %s, "
f"Managed servers: {self.ingestor_manager.opc_managers}" "Managed servers: %s",
ingestors,
number_of_slots,
number_of_leases,
self.ingestor_manager.managed_tags,
self.ingestor_manager.opc_managers,
) )
if not self.ingestor_manager.managed_tags: if not self.ingestor_manager.managed_tags:
# No slots acquired # No slots acquired
self.logger.info("No slots acquired in this loop") self.logger.info("No slots acquired in this loop")
# Update opc servers # Update opc servers
self.logger.debug("Updating slot config...")
self.ingestor_manager.update_slot_config() self.ingestor_manager.update_slot_config()
# Check OPC cycles # Check OPC cycles
self.logger.debug("Checking OPC servers integrity...")
self.ingestor_manager.check_opc_servers_integrity() self.ingestor_manager.check_opc_servers_integrity()
self.logger.debug("Updating managed tags...")
self.update_ingestor_manager(current_managed_tags)

View File

@@ -6,11 +6,17 @@ from kafka.errors import NoBrokersAvailable
from sientia_do.notifications.handlers import NotificationHandler from sientia_do.notifications.handlers import NotificationHandler
from sientia_do.notifications.models import NotificationLevel from sientia_do.notifications.models import NotificationLevel
import traceback import traceback
import ingestor.metrics as metrics
import os
class DataManager(): class DataManager:
def __init__(self, kafka_servers: str, logger: Logger, def __init__(
notification_handler: NotificationHandler) -> None: self,
kafka_servers: str,
logger: Logger,
notification_handler: NotificationHandler,
) -> None:
""" """
Initializes the DataManager instance with a Kafka producer. Initializes the DataManager instance with a Kafka producer.
This constructor attempts to establish a connection to the specified Kafka servers This constructor attempts to establish a connection to the specified Kafka servers
@@ -23,47 +29,63 @@ class DataManager():
NoBrokersAvailable: If the connection to Kafka servers fails after 3 attempts. NoBrokersAvailable: If the connection to Kafka servers fails after 3 attempts.
""" """
self.pod_id = os.getenv("HOSTNAME", "localhost")
self.kafka_producer = None self.kafka_producer = None
for i in range(0, 3): for i in range(0, 3):
logger.info( logger.info(
f"Trying ({i}) to initializing DataManager with Kafka servers: {kafka_servers}") f"Trying ({i}) to initializing DataManager with Kafka servers: {kafka_servers}"
)
try: try:
self.kafka_producer = KafkaProducer( self.kafka_producer = KafkaProducer(
bootstrap_servers=kafka_servers, bootstrap_servers=kafka_servers,
value_serializer=lambda v: json.dumps(v).encode( value_serializer=lambda v: json.dumps(v).encode(
'utf-8'), # Serialize JSON messages "utf-8"
key_serializer=lambda k: str( ), # Serialize JSON messages
k).encode('utf-8') if k else None, key_serializer=lambda k: str(k).encode("utf-8") if k else None,
) )
# Kafka connected
metrics.KAFKA_CONNECTION_STATUS.labels(pod_id=self.pod_id).set(1)
break break
except NoBrokersAvailable: except NoBrokersAvailable:
logger.error( logger.error(
f"Kafka servers {kafka_servers} are not available. Retrying...") f"Kafka servers {kafka_servers} are not available. Retrying..."
)
sleep(5) sleep(5)
else: else:
# Kafka not connected
metrics.KAFKA_CONNECTION_STATUS.labels(pod_id=self.pod_id).set(0)
logger.error( logger.error(
f"Failed to connect to Kafka servers {kafka_servers} after 3 attempts.") f"Failed to connect to Kafka servers {kafka_servers} after 3 attempts."
)
raise NoBrokersAvailable( raise NoBrokersAvailable(
f"Failed to connect to Kafka servers {kafka_servers} after 3 attempts.") f"Failed to connect to Kafka servers {kafka_servers} after 3 attempts."
)
logger.info( logger.info(f"DataManager initialized with Kafka servers: {kafka_servers}")
f"DataManager initialized with Kafka servers: {kafka_servers}")
self.logger = logger self.logger = logger
self.notification_handler = notification_handler self.notification_handler = notification_handler
def __del__(self): def shutdown(self):
"""Destructor to close the producer connection.""" """Closes the Kafka producer connection."""
print("Closing Kafka producer...")
if self.kafka_producer: if self.kafka_producer:
self.kafka_producer.flush(timeout=10) try:
self.kafka_producer.close() self.kafka_producer.flush(timeout=10)
self.kafka_producer.close()
# Mark as disconnected
metrics.KAFKA_CONNECTION_STATUS.labels(pod_id=self.pod_id).set(0)
except Exception as e:
self.logger.error(f"Error closing Kafka producer: {e}")
else: else:
print("Kafka producer is already closed or not initialized.") self.logger.warning("Kafka producer is already closed or not initialized.")
def __del__(self):
self.shutdown()
def delivery_report(self, msg: str): def delivery_report(self, msg: str):
"""Callback for delivery reports from Kafka.""" """Callback for delivery reports from Kafka."""
self.logger.debug( self.logger.debug(
f"Record successfully produced to {msg.topic} [{msg.partition}] at offset {msg.offset}") f"Record successfully produced to {msg.topic} [{msg.partition}] at offset {msg.offset}"
)
def delivery_error(self, err: str): def delivery_error(self, err: str):
"""Callback for delivery reports from Kafka.""" """Callback for delivery reports from Kafka."""
@@ -86,22 +108,22 @@ class DataManager():
try: try:
self.logger.debug( self.logger.debug(f"Publishing message to topic {topic}: {data}")
f"Publishing message to topic {topic}: {data}") self.kafka_producer.send(topic=topic, value=data).add_callback(
self.kafka_producer.send( self.delivery_report
topic=topic, value=data).add_callback( ).add_errback(self.delivery_error)
self.delivery_report).add_errback(
self.delivery_error)
self.kafka_producer.flush(timeout=10) self.kafka_producer.flush(timeout=10)
metrics.KAFKA_MESSAGES_SENT.labels(pod_id=self.pod_id, topic=topic).inc()
except Exception as e: except Exception as e:
metrics.KAFKA_MESSAGES_ERRORS.labels(pod_id=self.pod_id, topic=topic).inc()
trace = traceback.format_exc() trace = traceback.format_exc()
self.notification_handler.build_and_send_notification( self.notification_handler.build_and_send_notification(
notification_id=f"KAFKA_PRODUCER_ERROR_{topic}", notification_id=f"KAFKA_PRODUCER_ERROR_{topic}",
message=f"Error publishing message to topic {topic}: {e}", message=f"Error publishing message to topic {topic}: {e}",
block="kafka_producer", block="kafka_producer",
level=NotificationLevel.ERROR, level=NotificationLevel.ERROR,
attachment_content=trace attachment_content=trace,
) )
self.logger.error(trace) self.logger.error(trace)

View File

@@ -2,11 +2,12 @@ from logging import Logger
import traceback import traceback
from typing import Dict, List from typing import Dict, List
from copy import deepcopy from copy import deepcopy
from sientia_do.notifications.handlers import NotificationHandler
from sientia_do.notifications.models import NotificationLevel
from ingestor.managers.data_manager import DataManager from ingestor.managers.data_manager import DataManager
from ingestor.managers.opc_manager import OpcManager from ingestor.managers.opc_manager import OpcManager
from ingestor.managers.resource_manager import ResourceManager from ingestor.managers.resource_manager import ResourceManager
from sientia_do.notifications.models import Notification, NotificationLevel import ingestor.metrics as metrics
from sientia_do.notifications.handlers import NotificationHandler
class IngestorManager(): class IngestorManager():
@@ -29,8 +30,10 @@ class IngestorManager():
self.opc_servers = {} self.opc_servers = {}
self.notification_handler = notification_handler self.notification_handler = notification_handler
self.pod_id = pod_id
def initialize_opc_from_config(self, server_config: dict, data_manager: DataManager, logger: Logger) -> OpcManager | None: def initialize_opc_from_config(self, server_config: dict,
data_manager: DataManager, logger: Logger) -> OpcManager | None:
""" """
Initializes an OPC Manager instance using the provided server configuration. Initializes an OPC Manager instance using the provided server configuration.
Args: Args:
@@ -53,9 +56,11 @@ class IngestorManager():
self.logger.info( self.logger.info(
f"Initializing OpcManager at {server_config['url']}") f"Initializing OpcManager at {server_config['url']}")
manager = OpcManager( manager = OpcManager(
server_config['name'], server_config['url'], data_manager, logger, server_config['server_uri'], server_config['name'], server_config['url'],
self.notification_handler, server_config.get('cert_path'), server_config.get( data_manager, logger, server_config['server_uri'],
'private_key_path'), server_config.get('server_cert_path') self.notification_handler, self.pod_id, server_config.get('cert_path'),
server_config.get('private_key_path'),
server_config.get('server_cert_path')
) )
manager.config = server_config manager.config = server_config
@@ -76,6 +81,14 @@ class IngestorManager():
return manager return manager
def shutdown(self):
for _server_name, server in self.opc_managers.items():
server.disconnect()
self.data_manager.shutdown()
def __del__(self):
self.shutdown()
def update_opc_servers(self): def update_opc_servers(self):
""" """
Updates the OPC (OLE for Process Control) server connections managed by the ingestor. Updates the OPC (OLE for Process Control) server connections managed by the ingestor.
@@ -91,7 +104,8 @@ class IngestorManager():
4. Disconnects and removes OPC servers that are no longer registered. 4. Disconnects and removes OPC servers that are no longer registered.
Attributes: Attributes:
self.managed_tags (dict): A nested dictionary containing slot and server configurations. self.managed_tags (dict): A nested dictionary containing slot and server configurations.
self.opc_managers (dict): A dictionary mapping server names to their OPC manager instances. self.opc_managers (dict): A dictionary mapping server names to their
OPC manager instances.
self.data_manager: An object responsible for managing data operations. self.data_manager: An object responsible for managing data operations.
self.logger: A logging object for recording warnings and other messages. self.logger: A logging object for recording warnings and other messages.
Raises: Raises:
@@ -101,27 +115,44 @@ class IngestorManager():
""" """
registered_servers = [] registered_servers = []
for slot, slot_config in self.managed_tags.items(): current_managed_tags = deepcopy(self.managed_tags)
for _slot, slot_config in current_managed_tags.items():
for server, server_config in slot_config.items(): for server, server_config in slot_config.items():
registered_servers.append(server) registered_servers.append(server)
server_config = server_config.copy() server_config = deepcopy(server_config)
server_config.pop('tags', None) server_config.pop('tags', None)
server_instance = None server_instance = self.opc_managers.get(server, None)
if server not in self.opc_managers: if server_instance is None:
self.logger.debug(
f"Initializing OPC manager for server {server}"
)
server_instance = self.initialize_opc_from_config( server_instance = self.initialize_opc_from_config(
server_config, self.data_manager, self.logger server_config, self.data_manager, self.logger
) )
elif self.opc_managers[server].config != server_config: elif server_instance.config != server_config:
self.opc_managers[server].disconnect() self.logger.warning(
f"Reinitializing OPC manager for server {server}"
)
server_instance.disconnect()
del self.opc_managers[server] del self.opc_managers[server]
server_instance = self.initialize_opc_from_config( server_instance = self.initialize_opc_from_config(
server_config, self.data_manager, self.logger server_config, self.data_manager, self.logger
) )
else:
self.logger.debug(
f"OPC manager for server {server} is already initialized and up to date"
)
if server_instance is not None: if server_instance is not None:
self.opc_managers[server] = server_instance self.opc_managers[server] = server_instance
else:
self.logger.warning(
f"Failed to initialize OPC manager for server {server}, "
f"removing server from managed tags."
)
_a = [self.managed_tags[slot].pop(server, None)
for slot, _value in current_managed_tags.items()]
for server in list(self.opc_managers.keys()): for server in list(self.opc_managers.keys()):
if server not in registered_servers: if server not in registered_servers:
@@ -132,6 +163,8 @@ class IngestorManager():
self.opc_managers[server].disconnect() self.opc_managers[server].disconnect()
self.opc_managers.pop(server, None) self.opc_managers.pop(server, None)
metrics.OPC_MANAGERS_ACTIVE.labels(pod_id=self.pod_id).set(len(self.opc_managers))
def check_opc_servers_integrity(self): def check_opc_servers_integrity(self):
""" """
Checks the integrity of the OPC servers and updates the OPC servers if necessary. Checks the integrity of the OPC servers and updates the OPC servers if necessary.
@@ -143,18 +176,13 @@ class IngestorManager():
if is_lost: if is_lost:
self.logger.warning( self.logger.warning(
f"OPC server {server} is lost. " f"OPC server {server} is lost. "
f"Desconnecting from server." f"Server will be disconnected."
) )
opc_manager.disconnect()
self.opc_managers[server] = self.initialize_opc_from_config( for slot, _config in self.managed_tags.items():
opc_manager.config, self.data_manager, self.logger self.managed_tags[slot].pop(server, None)
)
self.update_opc_servers() metrics.OPC_MANAGERS_ACTIVE.labels(pod_id=self.pod_id).set(len(self.opc_managers))
for slot, slot_config in self.managed_tags.items():
if server in slot_config:
self.manage_server(
slot, server, slot_config[server], slot_config[server]['tags']
)
def declare_active(self): def declare_active(self):
""" """
@@ -188,6 +216,7 @@ class IngestorManager():
leases = self.resource_manager.get_all_leases() leases = self.resource_manager.get_all_leases()
self.number_of_slots = len(leases) if leases else 0 self.number_of_slots = len(leases) if leases else 0
metrics.LEASES_TOTAL.set(self.number_of_slots)
return self.number_of_slots return self.number_of_slots
def get_number_of_slots(self) -> int: def get_number_of_slots(self) -> int:
@@ -201,6 +230,7 @@ class IngestorManager():
slots = self.resource_manager.get_all_slots() slots = self.resource_manager.get_all_slots()
self.number_of_slots = len(slots) if slots else 0 self.number_of_slots = len(slots) if slots else 0
metrics.SLOTS_TOTAL.set(self.number_of_slots)
return self.number_of_slots return self.number_of_slots
def get_slot_leases(self, max_slots: int = 1) -> Dict: def get_slot_leases(self, max_slots: int = 1) -> Dict:
@@ -212,14 +242,16 @@ class IngestorManager():
Dict: A dictionary where the keys are the slot identifiers (as strings) Dict: A dictionary where the keys are the slot identifiers (as strings)
and the values are the leased slot details. and the values are the leased slot details.
Behavior: Behavior:
- Iterates through available slots and attempts to lease them using the resource manager. - Iterates through available slots and attempts to lease
them using the resource manager.
- Logs the leasing of each slot. - Logs the leasing of each slot.
- Updates the `managed_tags` attribute with the acquired slots. - Updates the `managed_tags` attribute with the acquired slots.
- Stops leasing once the specified `max_slots` are acquired. - Stops leasing once the specified `max_slots` are acquired.
- If unable to acquire the requested number of slots, logs a warning and returns the slots that were leased. - If unable to acquire the requested number of slots,
logs a warning and returns the slots that were leased.
Notes: Notes:
- If a slot is leased but its details cannot be retrieved (i.e., `get_tag_slot` returns None), - If a slot is leased but its details cannot be retrieved
that slot is skipped. (i.e., `get_tag_slot` returns None), that slot is skipped.
""" """
acquired = {} acquired = {}
@@ -230,9 +262,11 @@ class IngestorManager():
if slots is None: if slots is None:
continue continue
acquired[str(i)] = slots acquired[str(i)] = slots
metrics.SLOTS_ACQUIRED.labels(pod_id=self.pod_id).inc()
if len(acquired) >= max_slots: if len(acquired) >= max_slots:
self.managed_tags.update(acquired) self.managed_tags.update(acquired)
metrics.SLOTS_MANAGED.labels(pod_id=self.pod_id).set(len(self.managed_tags))
return acquired return acquired
self.logger.warning( self.logger.warning(
@@ -240,6 +274,7 @@ class IngestorManager():
f"Only {acquired} slots were leased." f"Only {acquired} slots were leased."
) )
self.managed_tags.update(acquired) self.managed_tags.update(acquired)
metrics.SLOTS_MANAGED.labels(pod_id=self.pod_id).set(len(self.managed_tags))
return acquired return acquired
def unsubscribe_slot(self, slot: str): def unsubscribe_slot(self, slot: str):
@@ -280,33 +315,17 @@ class IngestorManager():
removed_slots = [] removed_slots = []
update = {} update = {}
for slot, slot_config in self.managed_tags.items(): for slot, _slot_config in self.managed_tags.items():
self.resource_manager.renew_tag_lease(slot) self.resource_manager.renew_tag_lease(slot)
update = self.resource_manager.get_tag_slot(slot) update = self.resource_manager.get_tag_slot(slot)
if update is None: if update is None:
self.logger.warning(
f"Slot {slot} configuration not found. "
f"Removing slot from managed tags."
)
self.unsubscribe_slot(slot)
removed_slots.append(slot) removed_slots.append(slot)
continue continue
if update != slot_config: self.managed_tags[slot] = update
self.logger.info(
f"Slot {slot} configuration updated. "
f"Old: {slot_config}, New: {update}"
)
self.managed_tags[slot] = update
self.unsubscribe_slot(slot)
self.update_opc_servers()
self.subscribe_to_tags({slot: update})
for slot in removed_slots: for slot in removed_slots:
del self.managed_tags[slot] self.managed_tags.pop(slot, None)
self.update_opc_servers()
def drop_slot_leases(self, ids: List[str]) -> None: def drop_slot_leases(self, ids: List[str]) -> None:
""" """
@@ -321,8 +340,8 @@ class IngestorManager():
None None
""" """
for _id in ids: self.resource_manager.drop_tag_lease(lease_id)
self.resource_manager.drop_tag_lease(_id) metrics.SLOTS_RELEASED.labels(pod_id=self.pod_id).inc()
def manage_server(self, slot: str, server: str, server_config: dict, tags: dict) -> int: def manage_server(self, slot: str, server: str, server_config: dict, tags: dict) -> int:
""" """
@@ -381,6 +400,7 @@ class IngestorManager():
tags_to_sub tags_to_sub
) )
except Exception as e: except Exception as e:
metrics.OPC_SUBSCRIPTION_ERRORS.labels(pod_id=self.pod_id, server=server, slot=slot).inc()
trace = traceback.format_exc() trace = traceback.format_exc()
self.notification_handler.build_and_send_notification( self.notification_handler.build_and_send_notification(
notification_id=f'OPC_SUBSCRIPTION_ERROR_{slot}:{server}', notification_id=f'OPC_SUBSCRIPTION_ERROR_{slot}:{server}',

View File

@@ -7,11 +7,12 @@ from asyncua.sync import Client
from sientia_do.notifications.models import NotificationLevel from sientia_do.notifications.models import NotificationLevel
from sientia_do.notifications.handlers import NotificationHandler from sientia_do.notifications.handlers import NotificationHandler
from ingestor.managers.data_manager import DataManager from ingestor.managers.data_manager import DataManager
import ingestor.metrics as metrics
class OpcManager(): class OpcManager():
def __init__(self, name: str, url: str, data_manager: DataManager, def __init__(self, name: str, url: str, data_manager: DataManager,
logger: Logger, server_uri: str, notification_handler: NotificationHandler, logger: Logger, server_uri: str, notification_handler: NotificationHandler, pod_id: str,
cert_path: str = None, private_key_path: str = None, server_cert_path: str = None): cert_path: str = None, private_key_path: str = None, server_cert_path: str = None):
self.url = url self.url = url
self.name = name self.name = name
@@ -26,8 +27,10 @@ class OpcManager():
self.nodes = {} self.nodes = {}
self.subscriptions = {} self.subscriptions = {}
self.data_manager = data_manager self.data_manager = data_manager
self.notification_handler = notification_handler self.notification_handler = notification_handler
self.pod_id = pod_id
metrics.OPC_CONNECTION_STATUS.labels(pod_id=self.pod_id, server_name=self.name, server_url=self.url).set(0)
metrics.OPC_TAGS_SUBSCRIBED.labels(pod_id=self.pod_id, server_name=self.name).set(0)
def __str__(self): def __str__(self):
return f"OpcManager(name={self.name}, url={self.url}, server_uri={self.server_uri})\n" \ return f"OpcManager(name={self.name}, url={self.url}, server_uri={self.server_uri})\n" \
@@ -82,11 +85,20 @@ class OpcManager():
Exception: If the connection to the OPC server fails. Exception: If the connection to the OPC server fails.
""" """
self.client = Client(self.url) metrics.OPC_CONNECTIONS_TOTAL.labels(pod_id=self.pod_id, server_name=self.name).inc()
if self.cert_path: try:
self.set_security() self.client = Client(self.url)
self.logger.info('Starting connection...') if self.cert_path:
self.client.connect() self.set_security()
self.logger.info(f'Starting connection to {self.name}...')
self.client.connect()
metrics.OPC_CONNECTION_STATUS.labels(pod_id=self.pod_id, server_name=self.name, server_url=self.url).set(1)
self.logger.info(f'Connection to {self.name} successful.')
except Exception as e:
metrics.OPC_CONNECTION_STATUS.labels(pod_id=self.pod_id, server_name=self.name, server_url=self.url).set(0)
metrics.OPC_CONNECTIONS_FAILED.labels(pod_id=self.pod_id, server_name=self.name).inc()
self.logger.error(f"Failed to connect to {self.name}: {e}")
raise
def create_subscription(self, name: str, period: int = 500): def create_subscription(self, name: str, period: int = 500):
""" """
@@ -105,11 +117,14 @@ class OpcManager():
if not self.client: if not self.client:
raise ValueError("Client not connected. Call connect first.") raise ValueError("Client not connected. Call connect first.")
try:
p = period if period != None else 500 p = period if period is not None else 500
self.subscriptions[name] = self.client.create_subscription( self.subscriptions[name] = self.client.create_subscription(p, self)
p, self) self.logger.info(f'Subscription {name} created on {self.name}.')
self.logger.info('Subscription created.') metrics.OPC_SUBSCRIPTIONS_CREATED.labels(pod_id=self.pod_id, server_name=self.name, slot_name=name).inc()
except Exception as e:
self.logger.error(f"Failed to create subscription {name} on {self.name}: {e}")
raise
def subscribe(self, subscription: str, nodes: dict, collect_period: int): def subscribe(self, subscription: str, nodes: dict, collect_period: int):
""" """
@@ -129,14 +144,13 @@ class OpcManager():
""" """
if not self.subscriptions.get(subscription): if not self.subscriptions.get(subscription):
raise ValueError( raise ValueError("Subscription not created. Call create_subscription first.")
"Subscription not created. Call create_subscription first.")
self.logger.info(f"Subscribing to {subscription}...") self.logger.info(f"Subscribing to {subscription} on {self.name}...")
self.logger.info(f"Subscribing to nodes: {nodes}") self.logger.info(f"Subscribing to nodes: {nodes}")
self.addr_nodes = [self.client.get_node( self.addr_nodes = [self.client.get_node(n) for n in nodes if n not in self.nodes]
n) for n in nodes if n not in self.nodes]
self.nodes.update(nodes) self.nodes.update(nodes)
metrics.OPC_TAGS_SUBSCRIBED.labels(pod_id=self.pod_id, server_name=self.name).set(len(self.nodes))
self.collect_period = collect_period self.collect_period = collect_period
for node, config in self.nodes.items(): for node, config in self.nodes.items():
@@ -188,14 +202,24 @@ class OpcManager():
self.logger.warning("Client already disconnected.") self.logger.warning("Client already disconnected.")
return return
try: try:
[self.subscriptions[sub].delete() for sub in self.subscriptions] _a = [self.subscriptions[sub].delete()
for sub in self.subscriptions]
self.logger.warning("Deleted all subscriptions.") self.logger.warning("Deleted all subscriptions.")
del self.client
self.client = None
self.logger.warning("Disconnected from OPC UA server.")
except Exception as sub_error: except Exception as sub_error:
self.logger.error(f"Failed to clean up subscription: {sub_error}") self.logger.error(f"Failed to clean up subscription: {sub_error}")
try:
self.client.disconnect()
except Exception as conn_error:
self.logger.error(
f"Failed to disconnect from OPC UA server: {conn_error}")
finally:
del self.client
self.client = None
metrics.OPC_CONNECTION_STATUS.labels(pod_id=self.pod_id, server_name=self.name, server_url=self.url).set(0)
metrics.OPC_TAGS_SUBSCRIBED.labels(pod_id=self.pod_id, server_name=self.name).set(0)
self.logger.warning("Disconnected from OPC UA server.")
def datachange_notification(self, node, _val, data): def datachange_notification(self, node, _val, data):
""" """
Handles data change notifications for monitored OPC UA nodes. Handles data change notifications for monitored OPC UA nodes.
@@ -223,13 +247,12 @@ class OpcManager():
tag = str(node) tag = str(node)
self.logger.debug( self.logger.debug(
f"Data change notification received for tag: {tag} after {self.nodes[tag]['cycle_rule']['cycle_count']} cycles") f"Data change notification received for tag:"
f"{tag} after {self.nodes[tag]['cycle_rule']['cycle_count']} cycles")
self.logger.debug(
f"Resetting cycle count for tag: {tag} after {self.non_receive_count} OPC cycles")
self.nodes[tag]['cycle_rule']['cycle_count'] = 0 self.nodes[tag]['cycle_rule']['cycle_count'] = 0
self.non_receive_count = 0 self.non_receive_count = 0
metrics.OPC_CYCLES_WITHOUT_DATA.labels(pod_id=self.pod_id, server_name=self.name).set(0)
data = { data = {
'tag': tag, 'tag': tag,
@@ -238,12 +261,13 @@ class OpcManager():
'value': value 'value': value
} }
[self.data_manager.publish(e, data) _a = [self.data_manager.publish(e, data)
for e in self.nodes[tag]['topics']] for e in self.nodes[tag]['topics']]
def check_cycles(self): def check_cycles(self):
""" """
Checks the cycle counts for all monitored nodes and sends notifications if thresholds are exceeded. Checks the cycle counts for all monitored nodes and sends
notifications if thresholds are exceeded.
This method iterates through all monitored nodes and updates their cycle counts based on This method iterates through all monitored nodes and updates their cycle counts based on
configured increments. If a node's cycle count exceeds a threshold (5 cycles), it triggers configured increments. If a node's cycle count exceeds a threshold (5 cycles), it triggers
@@ -251,7 +275,8 @@ class OpcManager():
""" """
for node, config in self.nodes.items(): for node, config in self.nodes.items():
self.nodes[node]['cycle_rule']['cycle_count'] += self.nodes[node]['cycle_rule']['cycle_increment'] self.nodes[node]['cycle_rule']['cycle_count'] += config[
'cycle_rule']['cycle_increment']
if self.nodes[node]['cycle_rule']['cycle_count'] >= 5: if self.nodes[node]['cycle_rule']['cycle_count'] >= 5:
name = config['tag_name'] name = config['tag_name']
cycles = self.nodes[node]['cycle_rule']['cycle_count'] cycles = self.nodes[node]['cycle_rule']['cycle_count']
@@ -270,14 +295,17 @@ class OpcManager():
""" """
self.non_receive_count += 1 self.non_receive_count += 1
metrics.OPC_CYCLES_WITHOUT_DATA.labels(pod_id=self.pod_id, server_name=self.name).set(self.non_receive_count)
if self.non_receive_count >= 5: if self.non_receive_count >= 5:
self.notification_handler.build_and_send_notification( self.notification_handler.build_and_send_notification(
notification_id=f'OPC_LISTENNING_STOPPED__{self.name}', notification_id=f'OPC_LISTENNING_STOPPED__{self.name}',
message=f'{self.non_receive_count} cycles without receive from OPC {self.name}. Tags: {json.dumps(self.nodes)}', message=f'{self.non_receive_count} cycles without '
f'receive from OPC {self.name}. Tags: {json.dumps(self.nodes)}',
block="opc_manager", block="opc_manager",
level=NotificationLevel.ERROR level=NotificationLevel.ERROR
) )
if self.non_receive_count >= 15: if self.non_receive_count >= 15:
metrics.OPC_RECONNECTIONS_TOTAL.labels(pod_id=self.pod_id, server_name=self.name).inc()
self.notification_handler.build_and_send_notification( self.notification_handler.build_and_send_notification(
notification_id=f'OPC_CONNECTION_RETRY__{self.name}', notification_id=f'OPC_CONNECTION_RETRY__{self.name}',
message=f'Retrying to connect to server {self.name}', message=f'Retrying to connect to server {self.name}',

View File

@@ -1,17 +1,59 @@
import json import json
from typing import List from typing import List
from redis import Redis from redis import Redis
from time import time
import ingestor.metrics as metrics
class ResourceManager: class ResourceManager:
def __init__(self, host: str, port: int, def __init__(
lease_ttl: int, heartbeat_ttl: int, pod_id: str, self,
username: str = None, password: str = None) -> None: host: str,
self.redis = Redis(host=host, port=port, decode_responses=True, port: int,
username=username, password=password) lease_ttl: int,
heartbeat_ttl: int,
pod_id: str,
username: str | None = None,
password: str | None = None,
) -> None:
self.pod_id = pod_id
try:
self.redis = Redis(
host=host,
port=port,
decode_responses=True,
username=username,
password=password,
)
self.redis.ping()
metrics.REDIS_CONNECTION_STATUS.labels(pod_id=self.pod_id).set(1)
except Exception as e:
print(f"Failed to connect to Redis: {e}")
metrics.REDIS_CONNECTION_STATUS.labels(pod_id=self.pod_id).set(0)
raise
self.lease_ttl = lease_ttl self.lease_ttl = lease_ttl
self.heartbeat_ttl = heartbeat_ttl self.heartbeat_ttl = heartbeat_ttl
self.pod_id = pod_id
def _execute_redis_op(self, operation_name: str, func, *args, **kwargs):
"""Wrapper to execute Redis operations and record metrics."""
start_time = time()
try:
result = func(*args, **kwargs)
metrics.REDIS_OPERATIONS_TOTAL.labels(
pod_id=self.pod_id, operation=operation_name
).inc()
duration = time() - start_time
metrics.REDIS_OPERATIONS_DURATION.labels(
pod_id=self.pod_id, operation=operation_name
).observe(duration)
return result
except Exception as e:
metrics.REDIS_OPERATIONS_ERRORS.labels(
pod_id=self.pod_id, operation=operation_name
).inc()
print(f"Error in Redis operation '{operation_name}': {e}")
raise
def get(self, key: str) -> dict: def get(self, key: str) -> dict:
""" """
@@ -19,11 +61,11 @@ class ResourceManager:
Args: Args:
key (str): The key to look up in Redis. key (str): The key to look up in Redis.
Returns: Returns:
dict: The value associated with the key, parsed as a dictionary, dict: The value associated with the key, parsed as a dictionary,
or None if the key does not exist or the value is empty. or None if the key does not exist or the value is empty.
""" """
history = self.redis.get(key) history = self._execute_redis_op("get", self.redis.get, key)
return json.loads(history) if history else None return json.loads(history) if history else None
def get_tag_slot(self, id: str) -> dict: def get_tag_slot(self, id: str) -> dict:
@@ -48,14 +90,19 @@ class ResourceManager:
None None
""" """
self.redis.set( self._execute_redis_op(
f"heartbeat:ingestor:{self.pod_id}", 1, ex=self.heartbeat_ttl) "set",
self.redis.set,
f"heartbeat:ingestor:{self.pod_id}",
1,
ex=self.heartbeat_ttl,
)
def lease_tag(self, tag_id: str) -> bool: def lease_tag(self, tag_id: str) -> bool:
""" """
Attempts to lease a tag by setting a key in Redis with a specified TTL (time-to-live). Attempts to lease a tag by setting a key in Redis with a specified TTL (time-to-live).
This method uses the Redis `SET` command with the `NX` option to ensure that the key This method uses the Redis `SET` command with the `NX` option to ensure that the key
is only set if it does not already exist. The key is set with an expiration time is only set if it does not already exist. The key is set with an expiration time
defined by `lease_ttl`. defined by `lease_ttl`.
Args: Args:
tag_id (str): The unique identifier of the tag to be leased. tag_id (str): The unique identifier of the tag to be leased.
@@ -63,8 +110,14 @@ class ResourceManager:
bool: True if the lease was successfully acquired, False otherwise. bool: True if the lease was successfully acquired, False otherwise.
""" """
return self.redis.set( return self._execute_redis_op(
f"lease:opc_tags:{tag_id}", self.pod_id, nx=True, ex=self.lease_ttl) "set_nx",
self.redis.set,
f"lease:opc_tags:{tag_id}",
self.pod_id,
nx=True,
ex=self.lease_ttl,
)
def renew_tag_lease(self, tag_id: str) -> bool: def renew_tag_lease(self, tag_id: str) -> bool:
""" """
@@ -78,12 +131,14 @@ class ResourceManager:
bool: True if the lease was successfully renewed, False otherwise. bool: True if the lease was successfully renewed, False otherwise.
""" """
current = self.redis.get( current = self._execute_redis_op(
f"lease:opc_tags:{tag_id}") "get", self.redis.get, f"lease:opc_tags:{tag_id}"
)
if current == self.pod_id: if current == self.pod_id:
self.redis.expire(f"lease:opc_tags:{tag_id}", self.lease_ttl) self._execute_redis_op(
"expire", self.redis.expire, f"lease:opc_tags:{tag_id}", self.lease_ttl
)
return True return True
return False return False
def drop_tag_lease(self, tag_id: str) -> None: def drop_tag_lease(self, tag_id: str) -> None:
@@ -96,7 +151,7 @@ class ResourceManager:
None None
""" """
self.redis.delete(f"lease:opc_tags:{tag_id}") self._execute_redis_op("delete", self.redis.delete, f"lease:opc_tags:{tag_id}")
def get_all_ingestors(self) -> List[str]: def get_all_ingestors(self) -> List[str]:
""" """
@@ -107,7 +162,7 @@ class ResourceManager:
list: A list of active ingestors. list: A list of active ingestors.
""" """
return self.redis.keys("heartbeat:ingestor:*") return self._execute_redis_op("keys", self.redis.keys, "heartbeat:ingestor:*")
def get_all_slots(self) -> List[str]: def get_all_slots(self) -> List[str]:
""" """
@@ -118,7 +173,7 @@ class ResourceManager:
int: The number of slots available. int: The number of slots available.
""" """
return self.redis.keys("slot:opc_tags:*") return self._execute_redis_op("keys", self.redis.keys, "slot:opc_tags:*")
def get_all_leases(self) -> List[str]: def get_all_leases(self) -> List[str]:
""" """
@@ -129,4 +184,4 @@ class ResourceManager:
list: A list of active leases. list: A list of active leases.
""" """
return self.redis.keys("lease:opc_tags:*") return self._execute_redis_op("keys", self.redis.keys, "lease:opc_tags:*")

148
ingestor/metrics.py Normal file
View File

@@ -0,0 +1,148 @@
from prometheus_client import Counter, Gauge, Histogram
POD_ID_LABEL = ["pod_id"]
SERVER_LABELS = ["pod_id", "server_name", "server_url"]
KAFKA_LABELS = ["pod_id", "topic"]
REDIS_LABELS = ["pod_id", "operation"]
NOTIFICATION_LABELS = ["pod_id", "level", "block"]
# --- General Application Metrics ---
APP_LOOP_COUNT = Counter(
"app_main_loop_total",
"Total number of times the application main loop has run",
POD_ID_LABEL,
)
APP_LOOP_DURATION = Histogram(
"app_main_loop_duration_seconds",
"Duration of the application main loop in seconds",
POD_ID_LABEL,
)
APP_ERRORS_TOTAL = Counter(
"app_errors_total",
"Total number of unhandled errors in the main loop",
POD_ID_LABEL,
)
APP_UP = Gauge(
"app_up",
"Indicates if the application is running (1) or shutting down (0)",
POD_ID_LABEL,
)
# --- Ingestor Manager Metrics ---
ACTIVE_INGESTORS = Gauge(
"ingestor_active_total",
"Number of active ingestors reported by Redis",
)
SLOTS_TOTAL = Gauge(
"ingestor_slots_total",
"Total number of slots configured in Redis",
)
LEASES_TOTAL = Gauge(
"ingestor_leases_total",
"Total number of leases (allocated slots) in Redis",
)
SLOTS_MANAGED = Gauge(
"ingestor_slots_managed_current",
"Number of slots currently managed by this ingestor instance",
POD_ID_LABEL,
)
SLOTS_ACQUIRED = Counter(
"ingestor_slots_acquired_total",
"Total number of slots acquired by this instance",
POD_ID_LABEL,
)
SLOTS_RELEASED = Counter(
"ingestor_slots_released_total",
"Total number of slots released by this instance",
POD_ID_LABEL,
)
OPC_MANAGERS_ACTIVE = Gauge(
"ingestor_opc_managers_active",
"Number of active OPC Managers in this instance",
POD_ID_LABEL,
)
OPC_SUBSCRIPTION_ERRORS = Counter(
"ingestor_opc_subscription_errors_total",
"Errors when trying to subscribe to OPC tags",
["pod_id", "server", "slot"],
)
# --- OPC Manager Metrics ---
OPC_CONNECTIONS_TOTAL = Counter(
"opc_connections_initiated_total",
"Total connection attempts to OPC servers",
["pod_id", "server_name"],
)
OPC_CONNECTIONS_FAILED = Counter(
"opc_connections_failed_total",
"Total failed connection attempts to OPC servers",
["pod_id", "server_name"],
)
OPC_CONNECTION_STATUS = Gauge(
"opc_connection_status",
"Connection status with the OPC server (1=connected, 0=disconnected)",
SERVER_LABELS,
)
OPC_SUBSCRIPTIONS_CREATED = Counter(
"opc_subscriptions_created_total",
"Total OPC subscriptions created",
["pod_id", "server_name", "slot_name"],
)
OPC_TAGS_SUBSCRIBED = Gauge(
"opc_tags_subscribed_current",
"Current number of OPC tags subscribed on a server",
["pod_id", "server_name"],
)
OPC_CYCLES_WITHOUT_DATA = Gauge(
"opc_cycles_without_data",
"Current number of cycles without receiving data from a server",
["pod_id", "server_name"],
)
OPC_RECONNECTIONS_TOTAL = Counter(
"opc_reconnections_tried_total",
"Reconnection attempts to an OPC server after a loss",
["pod_id", "server_name"],
)
# --- Data Manager (Kafka) Metrics ---
KAFKA_MESSAGES_SENT = Counter(
"kafka_messages_sent_total", "Total messages sent to Kafka", KAFKA_LABELS
)
KAFKA_MESSAGES_ERRORS = Counter(
"kafka_messages_errors_total",
"Total errors sending messages to Kafka",
KAFKA_LABELS,
)
KAFKA_CONNECTION_STATUS = Gauge(
"kafka_connection_status",
"Connection status with Kafka (1=connected, 0=disconnected)",
POD_ID_LABEL,
)
# --- Resource Manager (Redis) Metrics ---
REDIS_OPERATIONS_TOTAL = Counter(
"redis_operations_total", "Total number of Redis operations performed", REDIS_LABELS
)
REDIS_OPERATIONS_ERRORS = Counter(
"redis_operations_errors_total",
"Total number of errors in Redis operations",
REDIS_LABELS,
)
REDIS_OPERATIONS_DURATION = Histogram(
"redis_operations_duration_seconds",
"Duration of Redis operations in seconds",
REDIS_LABELS,
)
REDIS_CONNECTION_STATUS = Gauge(
"redis_connection_status",
"Connection status with Redis (1=connected, 0=disconnected)",
POD_ID_LABEL,
)
# --- Notification Metrics ---
NOTIFICATIONS_SENT = Counter(
"notifications_sent_total",
"Total number of notifications sent",
NOTIFICATION_LABELS,
)

View File

@@ -1,3 +1,4 @@
asyncua==1.1.5 asyncua==1.1.5
redis redis
git+ssh://git@github.com/Aignosi/sientia-dataops-library.git git+ssh://git@github.com/Aignosi/sientia-dataops-library.git@1.1.14
prometheus_client

View File

@@ -1,21 +1,26 @@
import redis
import json import json
import os import redis
# Redis connection settings # Redis connection settings
redis_host = "localhost" REDIS_HOST = "localhost"
redis_port = 6379 REDIS_PORT = 6379
REDIS_USERNAME = None # "default"
REDIS_PASSWORD = None # "bdnZOpcyiL"
OPC_URL = "opc.tcp://sientia-opc-simulator-service.sientia-opc.svc.cluster.local:4841"
OPC_URL = "opc.tcp://localhost:4841"
# Connect to Redis # Connect to Redis
r = redis.Redis(host=redis_host, port=redis_port, r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT,
decode_responses=True, username='default', password='bdnZOpcyiL') decode_responses=True, username=REDIS_USERNAME, password=REDIS_PASSWORD)
# Define the key pattern to target # Define the key pattern to target
pattern = "slot:opc_tags:*" PATTERN = "slot:opc_tags:*"
# Step 1: Find and delete matching keys # Step 1: Find and delete matching keys
print("🔍 Searching for keys matching:", pattern) print("🔍 Searching for keys matching:", PATTERN)
for key in r.scan_iter(match=pattern): for key in r.scan_iter(match=PATTERN):
r.delete(key) r.delete(key)
print(f"❌ Deleted: {key}") print(f"❌ Deleted: {key}")
@@ -25,7 +30,7 @@ new_data = {
"slot:opc_tags:1": { "slot:opc_tags:1": {
"server1": { "server1": {
"name": "server1", "name": "server1",
"url": "opc.tcp://sientia-opc-simulator-service.sientia-opc.svc.cluster.local:4840", "url": OPC_URL,
"server_uri": "http://opcua-server.simulator", "server_uri": "http://opcua-server.simulator",
"tags": { "tags": {
'ns=2;i=2': { 'ns=2;i=2': {

View File

View File

@@ -108,31 +108,45 @@ def test___init___failure_max_attempts(kafka):
assert False, "Expected NoBrokersAvailable exception was not raised." assert False, "Expected NoBrokersAvailable exception was not raised."
def test___del___has_producer(data_manager): def test_shutdown_has_producer(data_manager):
flush_mock = MagicMock() flush_mock = MagicMock()
close_mock = MagicMock() close_mock = MagicMock()
data_manager.kafka_producer.flush = flush_mock data_manager.kafka_producer.flush = flush_mock
data_manager.kafka_producer.close = close_mock data_manager.kafka_producer.close = close_mock
data_manager.__del__() data_manager.shutdown()
flush_mock.assert_called_once() flush_mock.assert_called_once()
close_mock.assert_called_once() close_mock.assert_called_once()
@patch("ingestor.managers.data_manager.print") def test_shutdown_no_producer(data_manager):
def test___del___no_producer(print, data_manager):
data_manager.kafka_producer = None data_manager.kafka_producer = None
# Call the __del__ method data_manager.shutdown()
data_manager.__del__()
# Check if the print statement was called data_manager.logger.warning.assert_any_call(
print.assert_any_call(
"Kafka producer is already closed or not initialized." "Kafka producer is already closed or not initialized."
) )
def test_shutdown_exception(data_manager):
data_manager.kafka_producer.flush = MagicMock(
side_effect=Exception("Test error"))
data_manager.kafka_producer.close = MagicMock()
data_manager.shutdown()
data_manager.logger.error.assert_called_once_with(
"Error closing Kafka producer: Test error"
)
def test___del__(data_manager):
data_manager.shutdown = MagicMock()
data_manager.__del__()
data_manager.shutdown.assert_called_once()
def test_delivery_report(data_manager): def test_delivery_report(data_manager):
msg = MagicMock() msg = MagicMock()
msg.topic = "test_topic" msg.topic = "test_topic"

View File

@@ -60,7 +60,8 @@ def test_initialize_opc_from_config(opc_manager, ingestor_manager):
'server_uri': 'http://opcua-server.simulator', 'server_uri': 'http://opcua-server.simulator',
'cert_path': '/path/to/cert', 'cert_path': '/path/to/cert',
'private_key_path': '/path/to/private_key', 'private_key_path': '/path/to/private_key',
'server_cert_path': '/path/to/server_cert' 'server_cert_path': '/path/to/server_cert',
'pod_id': 'test_pod'
} }
opc_manager.return_value = MagicMock() opc_manager.return_value = MagicMock()
@@ -70,6 +71,7 @@ def test_initialize_opc_from_config(opc_manager, ingestor_manager):
opc_manager.assert_called_once_with( opc_manager.assert_called_once_with(
server_config['name'], server_config['url'], ingestor_manager.data_manager, ingestor_manager.logger, server_config['name'], server_config['url'], ingestor_manager.data_manager, ingestor_manager.logger,
server_config['server_uri'], ingestor_manager.notification_handler, server_config['server_uri'], ingestor_manager.notification_handler,
server_config['pod_id'],
server_config['cert_path'], server_config['private_key_path'], server_config['cert_path'], server_config['private_key_path'],
server_config['server_cert_path'] server_config['server_cert_path']
) )
@@ -109,7 +111,8 @@ def test_initialize_opc_from_config_exception(traceback_mock, opc_manager, inges
@patch('ingestor.managers.ingestor_manager.OpcManager') @patch('ingestor.managers.ingestor_manager.OpcManager')
def test_update_opc_servers(opc_manager, ingestor_manager): @patch('ingestor.managers.ingestor_manager.metrics')
def test_update_opc_servers(metrics, opc_manager, ingestor_manager):
manager1 = MagicMock( manager1 = MagicMock(
config={"config": "config1"}) config={"config": "config1"})
manager2 = MagicMock( manager2 = MagicMock(
@@ -173,6 +176,9 @@ def test_update_opc_servers(opc_manager, ingestor_manager):
assert ingestor_manager.opc_managers['server2'] != mock assert ingestor_manager.opc_managers['server2'] != mock
metrics.OPC_MANAGERS_ACTIVE.labels.assert_called_once_with(pod_id=ingestor_manager.pod_id)
metrics.OPC_MANAGERS_ACTIVE.labels.return_value.set.assert_called_once_with(len(ingestor_manager.opc_managers))
def test_declare_active(ingestor_manager): def test_declare_active(ingestor_manager):
ingestor_manager.resource_manager.ingestor_heartbeat = MagicMock() ingestor_manager.resource_manager.ingestor_heartbeat = MagicMock()
@@ -194,36 +200,44 @@ def test_get_active_ingestors_empty(ingestor_manager):
ingestor_manager.resource_manager.get_all_ingestors.assert_called_once() ingestor_manager.resource_manager.get_all_ingestors.assert_called_once()
def test_get_number_of_leases_success(ingestor_manager): @patch('ingestor.managers.ingestor_manager.metrics')
def test_get_number_of_leases_success(metrics, ingestor_manager):
ingestor_manager.resource_manager.get_all_leases = MagicMock( ingestor_manager.resource_manager.get_all_leases = MagicMock(
return_value=["lease1", "lease2"]) return_value=["lease1", "lease2"])
result = ingestor_manager.get_number_of_leases() result = ingestor_manager.get_number_of_leases()
assert result == 2 assert result == 2
ingestor_manager.resource_manager.get_all_leases.assert_called_once() ingestor_manager.resource_manager.get_all_leases.assert_called_once()
metrics.LEASES_TOTAL.set.assert_called_once_with(2)
def test_get_number_of_leases_empty(ingestor_manager): @patch('ingestor.managers.ingestor_manager.metrics')
def test_get_number_of_leases_empty(metrics, ingestor_manager):
ingestor_manager.resource_manager.get_all_leases = MagicMock( ingestor_manager.resource_manager.get_all_leases = MagicMock(
return_value=None) return_value=None)
result = ingestor_manager.get_number_of_leases() result = ingestor_manager.get_number_of_leases()
assert result == 0 assert result == 0
ingestor_manager.resource_manager.get_all_leases.assert_called_once() ingestor_manager.resource_manager.get_all_leases.assert_called_once()
metrics.LEASES_TOTAL.set.assert_called_once_with(0)
def test_get_number_of_slots_success(ingestor_manager): @patch('ingestor.managers.ingestor_manager.metrics')
def test_get_number_of_slots_success(metrics, ingestor_manager):
ingestor_manager.resource_manager.get_all_slots = MagicMock( ingestor_manager.resource_manager.get_all_slots = MagicMock(
return_value=["slot1", "slot2"]) return_value=["slot1", "slot2"])
result = ingestor_manager.get_number_of_slots() result = ingestor_manager.get_number_of_slots()
assert result == 2 assert result == 2
ingestor_manager.resource_manager.get_all_slots.assert_called_once() ingestor_manager.resource_manager.get_all_slots.assert_called_once()
metrics.SLOTS_TOTAL.set.assert_called_once_with(2)
def test_get_number_of_slots_empty(ingestor_manager): @patch('ingestor.managers.ingestor_manager.metrics')
def test_get_number_of_slots_empty(metrics, ingestor_manager):
ingestor_manager.resource_manager.get_all_slots = MagicMock( ingestor_manager.resource_manager.get_all_slots = MagicMock(
return_value=None) return_value=None)
result = ingestor_manager.get_number_of_slots() result = ingestor_manager.get_number_of_slots()
assert result == 0 assert result == 0
ingestor_manager.resource_manager.get_all_slots.assert_called_once() ingestor_manager.resource_manager.get_all_slots.assert_called_once()
metrics.SLOTS_TOTAL.set.assert_called_once_with(0)
def test_get_slot_leases_1_success(ingestor_manager): def test_get_slot_leases_1_success(ingestor_manager):
@@ -331,27 +345,23 @@ def test_update_slot_config(ingestor_manager):
"config": "new_config"} "config": "new_config"}
assert "slot3" not in ingestor_manager.managed_tags assert "slot3" not in ingestor_manager.managed_tags
assert ingestor_manager.update_opc_servers.call_count == 2
ingestor_manager.subscribe_to_tags.assert_called_once_with(
{'slot1': {"config": "updated_config"}}
)
ingestor_manager.unsubscribe_slot.assert_any_call("slot3")
ingestor_manager.unsubscribe_slot.assert_any_call("slot1")
assert ingestor_manager.unsubscribe_slot.call_count == 2
ingestor_manager.resource_manager.renew_tag_lease.assert_any_call("slot1") ingestor_manager.resource_manager.renew_tag_lease.assert_any_call("slot1")
ingestor_manager.resource_manager.renew_tag_lease.assert_any_call("slot3") ingestor_manager.resource_manager.renew_tag_lease.assert_any_call("slot3")
ingestor_manager.resource_manager.renew_tag_lease.assert_any_call("slot2") ingestor_manager.resource_manager.renew_tag_lease.assert_any_call("slot2")
assert ingestor_manager.resource_manager.renew_tag_lease.call_count == 3 assert ingestor_manager.resource_manager.renew_tag_lease.call_count == 3
def test_drop_slot_leases(ingestor_manager): @patch('ingestor.managers.ingestor_manager.metrics')
def test_drop_slot_leases(metrics, ingestor_manager):
ingestor_manager.resource_manager.drop_tag_lease = MagicMock() ingestor_manager.resource_manager.drop_tag_lease = MagicMock()
ingestor_manager.drop_slot_leases(["1", "2"]) ingestor_manager.drop_slot_leases(["1", "2"])
ingestor_manager.resource_manager.drop_tag_lease.assert_any_call("1") ingestor_manager.resource_manager.drop_tag_lease.assert_any_call("1")
ingestor_manager.resource_manager.drop_tag_lease.assert_any_call("2") ingestor_manager.resource_manager.drop_tag_lease.assert_any_call("2")
metrics.SLOTS_RELEASED.labels.assert_any_call(pod_id=ingestor_manager.pod_id)
metrics.SLOTS_RELEASED.labels.return_value.inc.assert_any_call()
def test_manage_server_no_server(ingestor_manager): def test_manage_server_no_server(ingestor_manager):
ingestor_manager.opc_managers = { ingestor_manager.opc_managers = {
@@ -497,7 +507,8 @@ def test_subscribe_to_tags(ingestor_manager):
'server3', None) 'server3', None)
def test_check_opc_servers_integrity_all_healthy(ingestor_manager): @patch('ingestor.managers.ingestor_manager.metrics')
def test_check_opc_servers_integrity_all_healthy(metrics, ingestor_manager):
# Setup mock OPC managers # Setup mock OPC managers
opc_manager1 = MagicMock() opc_manager1 = MagicMock()
opc_manager1.check_cycles.return_value = None opc_manager1.check_cycles.return_value = None
@@ -529,6 +540,9 @@ def test_check_opc_servers_integrity_all_healthy(ingestor_manager):
# Verify that no reinitialization was needed # Verify that no reinitialization was needed
ingestor_manager.initialize_opc_from_config.assert_not_called() ingestor_manager.initialize_opc_from_config.assert_not_called()
metrics.OPC_MANAGERS_ACTIVE.labels.assert_called_once_with(pod_id=ingestor_manager.pod_id)
metrics.OPC_MANAGERS_ACTIVE.labels.return_value.set.assert_called_once_with(len(ingestor_manager.opc_managers))
def test_check_opc_servers_integrity_server_lost(ingestor_manager): def test_check_opc_servers_integrity_server_lost(ingestor_manager):
# Setup mock OPC manager that will be lost # Setup mock OPC manager that will be lost
@@ -546,27 +560,9 @@ def test_check_opc_servers_integrity_server_lost(ingestor_manager):
ingestor_manager.initialize_opc_from_config = MagicMock( ingestor_manager.initialize_opc_from_config = MagicMock(
return_value=new_manager) return_value=new_manager)
# Mock update_opc_servers and manage_server
ingestor_manager.update_opc_servers = MagicMock()
ingestor_manager.manage_server = MagicMock()
# Call the method # Call the method
ingestor_manager.check_opc_servers_integrity() ingestor_manager.check_opc_servers_integrity()
# Verify that the lost server was disconnected
opc_manager.disconnect.assert_called_once()
# Verify that a new manager was initialized
ingestor_manager.initialize_opc_from_config.assert_called_once_with(
opc_manager.config, ingestor_manager.data_manager, ingestor_manager.logger
)
# Verify that the new manager was assigned
assert ingestor_manager.opc_managers["server1"] == new_manager
# Verify that update_opc_servers was called
ingestor_manager.update_opc_servers.assert_called_once()
def test_check_opc_servers_integrity_server_lost_with_tags(ingestor_manager): def test_check_opc_servers_integrity_server_lost_with_tags(ingestor_manager):
# Setup mock OPC manager that will be lost # Setup mock OPC manager that will be lost
@@ -594,30 +590,5 @@ def test_check_opc_servers_integrity_server_lost_with_tags(ingestor_manager):
ingestor_manager.initialize_opc_from_config = MagicMock( ingestor_manager.initialize_opc_from_config = MagicMock(
return_value=new_manager) return_value=new_manager)
# Mock update_opc_servers and manage_server
ingestor_manager.update_opc_servers = MagicMock()
ingestor_manager.manage_server = MagicMock()
# Call the method # Call the method
ingestor_manager.check_opc_servers_integrity() ingestor_manager.check_opc_servers_integrity()
# Verify that the lost server was disconnected
opc_manager.disconnect.assert_called_once()
# Verify that a new manager was initialized
ingestor_manager.initialize_opc_from_config.assert_called_once_with(
opc_manager.config, ingestor_manager.data_manager, ingestor_manager.logger
)
# Verify that the new manager was assigned
assert ingestor_manager.opc_managers["server1"] == new_manager
# Verify that update_opc_servers was called
ingestor_manager.update_opc_servers.assert_called_once()
# Verify that manage_server was called with the correct tags
ingestor_manager.manage_server.assert_called_once_with(
"slot1", "server1",
{"config": "config1", "tags": {"tag1": "value1"}},
{"tag1": "value1"}
)

View File

@@ -1,6 +1,7 @@
import json import json
from datetime import datetime from datetime import datetime
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from prometheus_client import Gauge
from pytest import fixture from pytest import fixture
from asyncua.crypto.security_policies import SecurityPolicyBasic256 from asyncua.crypto.security_policies import SecurityPolicyBasic256
@@ -9,57 +10,65 @@ from ingestor.managers.opc_manager import OpcManager
from sientia_do.notifications.models import NotificationLevel from sientia_do.notifications.models import NotificationLevel
tags = { tags = {
'ns=3;i=1001': { "ns=3;i=1001": {
'aggregation_function': 'LTS', "aggregation_function": "LTS",
'frequency': 1000, "frequency": 1000,
'max_value': 100, "max_value": 100,
'min_value': 0, "min_value": 0,
'tag_name': 'Counter' "tag_name": "Counter",
}, },
'ns=3;i=1003': { "ns=3;i=1003": {
'aggregation_function': 'AVG', "aggregation_function": "AVG",
'frequency': 1000, "frequency": 1000,
'max_value': 100, "max_value": 100,
'min_value': 0, "min_value": 0,
'tag_name': 'Random' "tag_name": "Random",
},
"ns=3;i=1004": {
"aggregation_function": "MDN",
"frequency": 1000,
"max_value": 100,
"min_value": 0,
"tag_name": "Sawtooth",
}, },
'ns=3;i=1004': {
'aggregation_function': 'MDN',
'frequency': 1000,
'max_value': 100,
'min_value': 0,
'tag_name': 'Sawtooth'
}
} }
@fixture @fixture
def raw_opc_manager(): def raw_opc_manager():
return OpcManager( return OpcManager(
'TestConnector', 'opc.tcp://localhost:4840', MagicMock(), "TestConnector",
MagicMock(), 'opc.tcp://localhost:4840', MagicMock() "opc.tcp://localhost:4840",
MagicMock(),
MagicMock(),
"opc.tcp://localhost:4840",
MagicMock(),
"localhost",
) )
@fixture @fixture
def opc_manager(raw_opc_manager): def opc_manager(raw_opc_manager):
raw_opc_manager.client = MagicMock() raw_opc_manager.client = MagicMock()
raw_opc_manager.cert_path = 'cert.pem' raw_opc_manager.cert_path = "cert.pem"
raw_opc_manager.private_key_path = 'private_key.pem' raw_opc_manager.private_key_path = "private_key.pem"
raw_opc_manager.server_cert_path = 'server_cert.pem' raw_opc_manager.server_cert_path = "server_cert.pem"
return raw_opc_manager return raw_opc_manager
@fixture @fixture
def opc_manager_subscribed(opc_manager): def opc_manager_subscribed(opc_manager):
opc_manager.subscriptions['sub1'] = MagicMock() opc_manager.subscriptions["sub1"] = MagicMock()
return opc_manager return opc_manager
def test___str__(opc_manager): def test___str__(opc_manager):
assert str(opc_manager) == 'OpcManager(name=TestConnector, url=opc.tcp://localhost:4840, server_uri=opc.tcp://localhost:4840)\nnodes={}, subscriptions={}' assert (
str(opc_manager)
== "OpcManager(name=TestConnector, url=opc.tcp://localhost:4840, server_uri=opc.tcp://localhost:4840)\nnodes={}, subscriptions={}"
)
def test_set_security_success(opc_manager): def test_set_security_success(opc_manager):
@@ -71,7 +80,7 @@ def test_set_security_success(opc_manager):
SecurityPolicyBasic256, SecurityPolicyBasic256,
certificate=opc_manager.cert_path, certificate=opc_manager.cert_path,
private_key=opc_manager.private_key_path, private_key=opc_manager.private_key_path,
server_certificate=opc_manager.server_cert_path server_certificate=opc_manager.server_cert_path,
) )
assert opc_manager.client.secure_channel_timeout == 10000000 assert opc_manager.client.secure_channel_timeout == 10000000
@@ -85,16 +94,19 @@ def test_set_security_no_cert(opc_manager):
try: try:
opc_manager.set_security() opc_manager.set_security()
except ValueError as e: except ValueError as e:
assert str( assert (
e) == "Certificate and private key paths must be provided for secure connection." str(e)
== "Certificate and private key paths must be provided for secure connection."
)
else: else:
assert False, "ValueError not raised" assert False, "ValueError not raised"
assert opc_manager.client.set_security.call_count == 0 assert opc_manager.client.set_security.call_count == 0
@patch('ingestor.managers.opc_manager.Client') @patch("ingestor.managers.opc_manager.metrics")
def test_connect_no_security(client, raw_opc_manager): @patch("ingestor.managers.opc_manager.Client")
def test_connect_no_security(client, mock_metrics, raw_opc_manager):
raw_opc_manager.set_security = MagicMock() raw_opc_manager.set_security = MagicMock()
raw_opc_manager.connect() raw_opc_manager.connect()
@@ -102,13 +114,25 @@ def test_connect_no_security(client, raw_opc_manager):
client.assert_called_once_with(raw_opc_manager.url) client.assert_called_once_with(raw_opc_manager.url)
raw_opc_manager.client.connect.assert_called_once() raw_opc_manager.client.connect.assert_called_once()
raw_opc_manager.set_security.assert_not_called() raw_opc_manager.set_security.assert_not_called()
mock_metrics.OPC_CONNECTIONS_TOTAL.labels.assert_called_once_with(
pod_id=raw_opc_manager.pod_id,
server_name=raw_opc_manager.name
)
mock_metrics.OPC_CONNECTIONS_TOTAL.labels.return_value.inc.assert_called_once()
mock_metrics.OPC_CONNECTION_STATUS.labels.assert_called_once_with(
pod_id=raw_opc_manager.pod_id,
server_name=raw_opc_manager.name,
server_url=raw_opc_manager.url
)
mock_metrics.OPC_CONNECTION_STATUS.labels.return_value.set.assert_called_once_with(1)
mock_metrics.OPC_CONNECTIONS_FAILED.labels.assert_not_called()
@patch('ingestor.managers.opc_manager.Client') @patch("ingestor.managers.opc_manager.Client")
def test_connect_with_security(client, raw_opc_manager): def test_connect_with_security(client, raw_opc_manager):
raw_opc_manager.cert_path = 'cert.pem' raw_opc_manager.cert_path = "cert.pem"
raw_opc_manager.private_key_path = 'private_key.pem' raw_opc_manager.private_key_path = "private_key.pem"
raw_opc_manager.server_cert_path = 'server_cert.pem' raw_opc_manager.server_cert_path = "server_cert.pem"
raw_opc_manager.set_security = MagicMock() raw_opc_manager.set_security = MagicMock()
raw_opc_manager.connect() raw_opc_manager.connect()
@@ -118,9 +142,48 @@ def test_connect_with_security(client, raw_opc_manager):
raw_opc_manager.set_security.assert_called_once() raw_opc_manager.set_security.assert_called_once()
@patch("ingestor.managers.opc_manager.Client")
@patch("ingestor.managers.opc_manager.metrics")
def test_connect_exception_handling_and_metrics(
mock_metrics_module, mock_opc_client_class, raw_opc_manager
):
mock_client_instance = mock_opc_client_class.return_value
simulated_error_message = "Erro de conexão simulado"
mock_client_instance.connect.side_effect = Exception(simulated_error_message)
opc_manager_instance = raw_opc_manager
opc_manager_instance.cert_path = None
with pytest.raises(Exception, match=simulated_error_message):
opc_manager_instance.connect()
mock_metrics_module.OPC_CONNECTIONS_TOTAL.labels.assert_called_once_with(
pod_id=opc_manager_instance.pod_id, server_name=opc_manager_instance.name
)
mock_metrics_module.OPC_CONNECTIONS_TOTAL.labels.return_value.inc.assert_called_once()
mock_metrics_module.OPC_CONNECTION_STATUS.labels.assert_called_once_with(
pod_id=opc_manager_instance.pod_id,
server_name=opc_manager_instance.name,
server_url=opc_manager_instance.url,
)
mock_metrics_module.OPC_CONNECTION_STATUS.labels.return_value.set.assert_called_once_with(
0
)
mock_metrics_module.OPC_CONNECTIONS_FAILED.labels.assert_called_once_with(
pod_id=opc_manager_instance.pod_id, server_name=opc_manager_instance.name
)
mock_metrics_module.OPC_CONNECTIONS_FAILED.labels.return_value.inc.assert_called_once()
opc_manager_instance.logger.error.assert_called_once_with(
f"Failed to connect to {opc_manager_instance.name}: {simulated_error_message}"
)
def test_create_subscription_no_client(raw_opc_manager): def test_create_subscription_no_client(raw_opc_manager):
try: try:
raw_opc_manager.create_subscription('sub1') raw_opc_manager.create_subscription("sub1")
except ValueError as e: except ValueError as e:
assert str(e) == "Client not connected. Call connect first." assert str(e) == "Client not connected. Call connect first."
else: else:
@@ -128,55 +191,95 @@ def test_create_subscription_no_client(raw_opc_manager):
def test_create_subscription_success_has_period(opc_manager): def test_create_subscription_success_has_period(opc_manager):
opc_manager.create_subscription('sub1', 1000) opc_manager.create_subscription("sub1", 1000)
opc_manager.client.create_subscription.assert_called_once_with( opc_manager.client.create_subscription.assert_called_once_with(1000, opc_manager)
1000, opc_manager) assert opc_manager.subscriptions["sub1"] is not None
assert opc_manager.subscriptions['sub1'] is not None
def test_create_subscription_success_no_period(opc_manager): def test_create_subscription_success_no_period(opc_manager):
opc_manager.create_subscription('sub1', None) opc_manager.create_subscription("sub1", None)
opc_manager.client.create_subscription.assert_called_once_with( opc_manager.client.create_subscription.assert_called_once_with(500, opc_manager)
500, opc_manager) assert opc_manager.subscriptions["sub1"] is not None
assert opc_manager.subscriptions['sub1'] is not None
def test_subscribe_no_subscription(opc_manager): @patch("ingestor.managers.opc_manager.metrics")
def test_create_subscription_with_metrics(metrics, opc_manager):
opc_manager.create_subscription("sub1", 1000)
metrics.OPC_SUBSCRIPTIONS_CREATED.labels.assert_called_once_with(
pod_id=opc_manager.pod_id, server_name=opc_manager.name, slot_name="sub1"
)
metrics.OPC_SUBSCRIPTIONS_CREATED.labels.return_value.inc.assert_called_once()
@patch("ingestor.managers.opc_manager.metrics")
def test_create_subscription_exception_during_client_call(
mock_metrics_module, raw_opc_manager
):
opc_manager_instance = raw_opc_manager
opc_manager_instance.client = MagicMock()
subscription_name = "test_sub_client_error"
simulated_period = 750
simulated_error_message = "Falha ao criar subscrição no cliente OPC"
opc_manager_instance.client.create_subscription.side_effect = Exception(
simulated_error_message
)
with pytest.raises(Exception, match=simulated_error_message):
opc_manager_instance.create_subscription(
subscription_name, period=simulated_period
)
opc_manager_instance.client.create_subscription.assert_called_once_with(
simulated_period, opc_manager_instance
)
opc_manager_instance.logger.error.assert_called_once_with(
f"Failed to create subscription {subscription_name} on {opc_manager_instance.name}: {simulated_error_message}"
)
mock_metrics_module.OPC_SUBSCRIPTIONS_CREATED.labels.assert_not_called()
@patch("ingestor.managers.opc_manager.metrics")
def test_subscribe_no_subscription(metrics, opc_manager):
try: try:
opc_manager.subscribe('sub1', tags, 1000) opc_manager.subscribe("sub1", tags, 1000)
except ValueError as e: except ValueError as e:
assert str( assert str(e) == "Subscription not created. Call create_subscription first."
e) == "Subscription not created. Call create_subscription first."
else: else:
assert False, "ValueError not raised" assert False, "ValueError not raised"
metrics.OPC_TAGS_SUBSCRIBED.labels.assert_not_called()
def test_subscribe_success(opc_manager_subscribed): def test_subscribe_success(opc_manager_subscribed):
opc_manager_subscribed.nodes = { opc_manager_subscribed.nodes = {"ns=3;i=1001": "data"}
'ns=3;i=1001': 'data'
}
opc_manager_subscribed.subscribe('sub1', tags, 1000) opc_manager_subscribed.subscribe("sub1", tags, 1000)
assert opc_manager_subscribed.nodes == tags assert opc_manager_subscribed.nodes == tags
assert opc_manager_subscribed.addr_nodes == [ assert opc_manager_subscribed.addr_nodes == [
opc_manager_subscribed.client.get_node(n) for n in tags if n != 'ns=3;i=1001'] opc_manager_subscribed.client.get_node(n) for n in tags if n != "ns=3;i=1001"
]
def test_unsubscribe_no_subscription(opc_manager): def test_unsubscribe_no_subscription(opc_manager):
opc_manager.unsubscribe('sub1') opc_manager.unsubscribe("sub1")
opc_manager.logger.warning.assert_called_once_with( opc_manager.logger.warning.assert_called_once_with(
"Subscription 'sub1' not found. Cannot unsubscribe.") "Subscription 'sub1' not found. Cannot unsubscribe."
assert opc_manager.subscriptions.get('sub1') is None )
assert opc_manager.subscriptions.get("sub1") is None
def test_unsubscribe_success(opc_manager_subscribed): def test_unsubscribe_success(opc_manager_subscribed):
opc_manager_subscribed.unsubscribe('sub1') opc_manager_subscribed.unsubscribe("sub1")
opc_manager_subscribed.subscriptions.get('sub1') is None opc_manager_subscribed.subscriptions.get("sub1") is None
def test_disconnect_success(opc_manager_subscribed): def test_disconnect_success(opc_manager_subscribed):
@@ -184,72 +287,123 @@ def test_disconnect_success(opc_manager_subscribed):
opc_manager_subscribed.disconnect() opc_manager_subscribed.disconnect()
opc_manager_subscribed.subscriptions['sub1'].delete.assert_called_once() opc_manager_subscribed.subscriptions["sub1"].delete.assert_called_once()
assert opc_manager_subscribed.client is None assert opc_manager_subscribed.client is None
def test_disconnect_error(opc_manager_subscribed): def test_disconnect_error_unsubscribe(opc_manager_subscribed):
opc_manager_subscribed.client = MagicMock() opc_manager_subscribed.client = MagicMock()
opc_manager_subscribed.subscriptions['sub1'] = MagicMock( opc_manager_subscribed.subscriptions["sub1"] = MagicMock(
delete=MagicMock(side_effect=Exception("Test error")) delete=MagicMock(side_effect=Exception("Test error"))
) )
opc_manager_subscribed.disconnect() opc_manager_subscribed.disconnect()
opc_manager_subscribed.subscriptions['sub1'].delete.assert_called_once() opc_manager_subscribed.subscriptions["sub1"].delete.assert_called_once()
opc_manager_subscribed.client.disconnect.assert_not_called() opc_manager_subscribed.client = None
opc_manager_subscribed.logger.error.assert_called_once_with( opc_manager_subscribed.logger.error.assert_called_once_with(
"Failed to clean up subscription: Test error") "Failed to clean up subscription: Test error"
)
def test_datachange_notification(opc_manager_subscribed): def test_disconnect_error(opc_manager_subscribed):
opc_manager_subscribed.client = MagicMock()
opc_manager_subscribed.client.disconnect = MagicMock(
side_effect=Exception("Test error")
)
opc_manager_subscribed.disconnect()
opc_manager_subscribed.subscriptions["sub1"].delete.assert_called_once()
opc_manager_subscribed.client = None
opc_manager_subscribed.logger.error.assert_called_once_with(
"Failed to disconnect from OPC UA server: Test error"
)
@patch('ingestor.managers.opc_manager.metrics')
def test_disconnect_metrics_on_successful_path(mock_metrics_module, raw_opc_manager):
mock_metrics_module.OPC_CONNECTION_STATUS.reset_mock()
mock_metrics_module.OPC_TAGS_SUBSCRIBED.reset_mock()
raw_opc_manager.client = MagicMock()
mock_sub1 = MagicMock()
mock_sub2 = MagicMock()
raw_opc_manager.subscriptions = {"sub1": mock_sub1, "sub2": mock_sub2}
raw_opc_manager.disconnect()
mock_metrics_module.OPC_CONNECTION_STATUS.labels.assert_called_once_with(
pod_id=raw_opc_manager.pod_id,
server_name=raw_opc_manager.name,
server_url=raw_opc_manager.url
)
mock_metrics_module.OPC_CONNECTION_STATUS.labels.return_value.set.assert_called_once_with(0)
mock_metrics_module.OPC_TAGS_SUBSCRIBED.labels.assert_called_once_with(
pod_id=raw_opc_manager.pod_id,
server_name=raw_opc_manager.name
)
mock_metrics_module.OPC_TAGS_SUBSCRIBED.labels.return_value.set.assert_called_once_with(0)
@patch('ingestor.managers.opc_manager.metrics')
def test_datachange_notification(metrics, opc_manager_subscribed):
data = MagicMock( data = MagicMock(
monitored_item=MagicMock( monitored_item=MagicMock(
Value=MagicMock( Value=MagicMock(
Value=MagicMock(Value=42), Value=MagicMock(Value=42),
SourceTimestamp=datetime.strptime( SourceTimestamp=datetime.strptime(
'2021-01-01T00:00:00', '%Y-%m-%dT%H:%M:%S') "2021-01-01T00:00:00", "%Y-%m-%dT%H:%M:%S"
))) ),
)
)
)
opc_manager_subscribed.nodes = { opc_manager_subscribed.nodes = {
'ns=3;i=1001': { "ns=3;i=1001": {
'tag_name': 'Counter', "tag_name": "Counter",
'cycle_rule': { "cycle_rule": {"cycle_increment": 1.0, "cycle_count": 2},
'cycle_increment': 1.0, "topics": ["topic1", "topic2"],
'cycle_count': 2
},
'topics': ['topic1', 'topic2']
} }
} }
opc_manager_subscribed.datachange_notification( metrics.OPC_CYCLES_WITHOUT_DATA.reset_mock()
'ns=3;i=1001', None, data)
opc_manager_subscribed.datachange_notification("ns=3;i=1001", None, data)
opc_manager_subscribed.data_manager.publish.assert_any_call( opc_manager_subscribed.data_manager.publish.assert_any_call(
'topic1', { "topic1",
'tag': 'ns=3;i=1001', {
'name': 'Counter', "tag": "ns=3;i=1001",
'timestamp': '2021-01-01 00:00:00', "name": "Counter",
'value': 42 "timestamp": "2021-01-01 00:00:00",
}) "value": 42,
},
)
opc_manager_subscribed.data_manager.publish.assert_any_call( opc_manager_subscribed.data_manager.publish.assert_any_call(
'topic2', { "topic2",
'tag': 'ns=3;i=1001', {
'name': 'Counter', "tag": "ns=3;i=1001",
'timestamp': '2021-01-01 00:00:00', "name": "Counter",
'value': 42 "timestamp": "2021-01-01 00:00:00",
}) "value": 42,
assert opc_manager_subscribed.nodes['ns=3;i=1001']['cycle_rule']['cycle_count'] == 0 },
)
assert opc_manager_subscribed.nodes["ns=3;i=1001"]["cycle_rule"]["cycle_count"] == 0
metrics.OPC_CYCLES_WITHOUT_DATA.labels.assert_called_once_with(
pod_id=opc_manager_subscribed.pod_id,
server_name=opc_manager_subscribed.name
)
metrics.OPC_CYCLES_WITHOUT_DATA.labels.return_value.set.assert_called_once_with(0)
def test_check_cycles_no_notification(opc_manager): def test_check_cycles_no_notification(opc_manager):
# Setup: node with cycle_count just below threshold # Setup: node with cycle_count just below threshold
opc_manager.nodes = { opc_manager.nodes = {
'ns=3;i=1001': { "ns=3;i=1001": {
'tag_name': 'Counter', "tag_name": "Counter",
'cycle_rule': { "cycle_rule": {"cycle_increment": 1.0, "cycle_count": 3.0},
'cycle_increment': 1.0,
'cycle_count': 3.0
}
} }
} }
opc_manager.notification_handler.build_and_send_notification = MagicMock() opc_manager.notification_handler.build_and_send_notification = MagicMock()
@@ -257,20 +411,18 @@ def test_check_cycles_no_notification(opc_manager):
opc_manager.check_cycles() opc_manager.check_cycles()
# After one increment, cycle_count = 4.0, still below threshold # After one increment, cycle_count = 4.0, still below threshold
assert opc_manager.nodes['ns=3;i=1001']['cycle_rule']['cycle_count'] == pytest.approx( assert opc_manager.nodes["ns=3;i=1001"]["cycle_rule"][
4.0) "cycle_count"
] == pytest.approx(4.0)
opc_manager.notification_handler.build_and_send_notification.assert_not_called() opc_manager.notification_handler.build_and_send_notification.assert_not_called()
def test_check_cycles_triggers_notification(opc_manager): def test_check_cycles_triggers_notification(opc_manager):
# Setup: node with cycle_count just below threshold, increment will cross threshold # Setup: node with cycle_count just below threshold, increment will cross threshold
opc_manager.nodes = { opc_manager.nodes = {
'ns=3;i=1001': { "ns=3;i=1001": {
'tag_name': 'Counter', "tag_name": "Counter",
'cycle_rule': { "cycle_rule": {"cycle_increment": 2.5, "cycle_count": 3.0},
'cycle_increment': 2.5,
'cycle_count': 3.0
}
} }
} }
opc_manager.notification_handler.build_and_send_notification = MagicMock() opc_manager.notification_handler.build_and_send_notification = MagicMock()
@@ -278,19 +430,23 @@ def test_check_cycles_triggers_notification(opc_manager):
opc_manager.check_cycles() opc_manager.check_cycles()
# After increment, cycle_count = 5.5, should trigger notification # After increment, cycle_count = 5.5, should trigger notification
assert opc_manager.nodes['ns=3;i=1001']['cycle_rule']['cycle_count'] == pytest.approx( assert opc_manager.nodes["ns=3;i=1001"]["cycle_rule"][
5.5) "cycle_count"
] == pytest.approx(5.5)
opc_manager.notification_handler.build_and_send_notification.assert_called_once_with( opc_manager.notification_handler.build_and_send_notification.assert_called_once_with(
notification_id='TAG_ns=3;i=1001:Counter_LISTENNING_STOPPED', notification_id="TAG_ns=3;i=1001:Counter_LISTENNING_STOPPED",
message='5.5 cycles without receive from ns=3;i=1001:Counter', message="5.5 cycles without receive from ns=3;i=1001:Counter",
block="opc_manager", block="opc_manager",
level=NotificationLevel.WARNING level=NotificationLevel.WARNING,
) )
def test_check_opc_listenning_no_notification(opc_manager): @patch('ingestor.managers.opc_manager.metrics')
def test_check_opc_listenning_no_notification(metrics, opc_manager):
opc_manager.non_receive_count = 3 opc_manager.non_receive_count = 3
opc_manager.notification_handler.build_and_send_notification = MagicMock() opc_manager.notification_handler.build_and_send_notification = MagicMock()
metrics.OPC_CYCLES_WITHOUT_DATA.reset_mock()
metrics.OPC_RECONNECTIONS_TOTAL.reset_mock()
result = opc_manager.check_opc_listenning() result = opc_manager.check_opc_listenning()
@@ -298,6 +454,13 @@ def test_check_opc_listenning_no_notification(opc_manager):
opc_manager.notification_handler.build_and_send_notification.assert_not_called() opc_manager.notification_handler.build_and_send_notification.assert_not_called()
assert result is False assert result is False
metrics.OPC_CYCLES_WITHOUT_DATA.labels.assert_called_once_with(
pod_id=opc_manager.pod_id,
server_name=opc_manager.name
)
metrics.OPC_CYCLES_WITHOUT_DATA.labels.return_value.set.assert_called_once_with(opc_manager.non_receive_count)
metrics.OPC_RECONNECTIONS_TOTAL.labels.assert_not_called()
def test_check_opc_listenning_warning_notification(opc_manager): def test_check_opc_listenning_warning_notification(opc_manager):
opc_manager.non_receive_count = 4 opc_manager.non_receive_count = 4
@@ -307,17 +470,20 @@ def test_check_opc_listenning_warning_notification(opc_manager):
assert opc_manager.non_receive_count == 5 assert opc_manager.non_receive_count == 5
opc_manager.notification_handler.build_and_send_notification.assert_called_once_with( opc_manager.notification_handler.build_and_send_notification.assert_called_once_with(
notification_id=f'OPC_LISTENNING_STOPPED__{opc_manager.name}', notification_id=f"OPC_LISTENNING_STOPPED__{opc_manager.name}",
message=f'5 cycles without receive from OPC {opc_manager.name}. Tags: {json.dumps(opc_manager.nodes)}', message=f"5 cycles without receive from OPC {opc_manager.name}. Tags: {json.dumps(opc_manager.nodes)}",
block="opc_manager", block="opc_manager",
level=NotificationLevel.ERROR level=NotificationLevel.ERROR,
) )
assert result is False assert result is False
def test_check_opc_listenning_error_notification_and_retry(opc_manager): @patch('ingestor.managers.opc_manager.metrics')
def test_check_opc_listenning_error_notification_and_retry(metrics, opc_manager):
opc_manager.non_receive_count = 14 opc_manager.non_receive_count = 14
opc_manager.notification_handler.build_and_send_notification = MagicMock() opc_manager.notification_handler.build_and_send_notification = MagicMock()
metrics.OPC_CYCLES_WITHOUT_DATA.reset_mock()
metrics.OPC_RECONNECTIONS_TOTAL.reset_mock()
result = opc_manager.check_opc_listenning() result = opc_manager.check_opc_listenning()
@@ -327,16 +493,56 @@ def test_check_opc_listenning_error_notification_and_retry(opc_manager):
calls = opc_manager.notification_handler.build_and_send_notification.call_args_list calls = opc_manager.notification_handler.build_and_send_notification.call_args_list
# First call: 5 cycles warning # First call: 5 cycles warning
assert calls[0].kwargs == dict( assert calls[0].kwargs == dict(
notification_id=f'OPC_LISTENNING_STOPPED__{opc_manager.name}', notification_id=f"OPC_LISTENNING_STOPPED__{opc_manager.name}",
message=f'15 cycles without receive from OPC {opc_manager.name}. Tags: {json.dumps(opc_manager.nodes)}', message=f"15 cycles without receive from OPC {opc_manager.name}. Tags: {json.dumps(opc_manager.nodes)}",
block="opc_manager", block="opc_manager",
level=NotificationLevel.ERROR level=NotificationLevel.ERROR,
) )
# Second call: 15 cycles retry # Second call: 15 cycles retry
assert calls[1].kwargs == dict( assert calls[1].kwargs == dict(
notification_id=f'OPC_CONNECTION_RETRY__{opc_manager.name}', notification_id=f"OPC_CONNECTION_RETRY__{opc_manager.name}",
message=f'Retrying to connect to server {opc_manager.name}', message=f"Retrying to connect to server {opc_manager.name}",
block="opc_manager", block="opc_manager",
level=NotificationLevel.ERROR level=NotificationLevel.ERROR,
) )
assert result is True assert result is True
metrics.OPC_CYCLES_WITHOUT_DATA.labels.assert_called_once_with(
pod_id=opc_manager.pod_id,
server_name=opc_manager.name
)
metrics.OPC_CYCLES_WITHOUT_DATA.labels.return_value.set.assert_called_once_with(opc_manager.non_receive_count)
metrics.OPC_RECONNECTIONS_TOTAL.labels.assert_called_once_with(
pod_id=opc_manager.pod_id,
server_name=opc_manager.name
)
metrics.OPC_RECONNECTIONS_TOTAL.labels.return_value.inc.assert_called_once()
@patch("ingestor.managers.opc_manager.metrics")
def test_init_metrics_calls_correct_metric_methods(mock_metrics):
opc_manager = OpcManager(
name="TestInitConnector",
url="opc.tcp://init.test:4840",
data_manager=MagicMock(),
logger=MagicMock(),
server_uri="opc.tcp://init.test:4840/uri",
notification_handler=MagicMock(),
pod_id="init_pod_localhost",
)
mock_metrics.OPC_CONNECTION_STATUS.labels.assert_called_once_with(
pod_id=opc_manager.pod_id,
server_name=opc_manager.name,
server_url=opc_manager.url,
)
mock_metrics.OPC_CONNECTION_STATUS.labels.return_value.set.assert_called_once_with(0)
mock_metrics.OPC_TAGS_SUBSCRIBED.labels.assert_called_once_with(
pod_id=opc_manager.pod_id,
server_name=opc_manager.name
)
mock_metrics.OPC_TAGS_SUBSCRIBED.labels.return_value.set.assert_called_once_with(0)

View File

@@ -1,109 +1,190 @@
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from pytest import fixture from pytest import fixture, raises
from ingestor.managers.resource_manager import ResourceManager from ingestor.managers.resource_manager import ResourceManager
@fixture @fixture
@patch('ingestor.managers.resource_manager.Redis') @patch("ingestor.managers.resource_manager.Redis")
def resource_manager(redis): def resource_manager(redis):
return ResourceManager( return ResourceManager("localhost", 6379, 10, 10, "pod_id")
'localhost', 6379, 10, 10, 'pod_id'
)
def test_get_success(resource_manager): def test_get_success(resource_manager):
resource_manager.redis.get.return_value = '{"key": "value"}' resource_manager.redis.get.return_value = '{"key": "value"}'
result = resource_manager.get('key') result = resource_manager.get("key")
assert result == {"key": "value"} assert result == {"key": "value"}
resource_manager.redis.get.assert_called_once_with('key') resource_manager.redis.get.assert_called_once_with("key")
def test_get_failure(resource_manager): def test_get_failure(resource_manager):
resource_manager.redis.get.return_value = None resource_manager.redis.get.return_value = None
result = resource_manager.get('key') result = resource_manager.get("key")
assert result is None assert result is None
resource_manager.redis.get.assert_called_once_with('key') resource_manager.redis.get.assert_called_once_with("key")
def test_get_tag_slot(resource_manager): def test_get_tag_slot(resource_manager):
resource_manager.get = MagicMock(return_value={"tag": "slot"}) resource_manager.get = MagicMock(return_value={"tag": "slot"})
result = resource_manager.get_tag_slot('id') result = resource_manager.get_tag_slot("id")
assert result == {"tag": "slot"} assert result == {"tag": "slot"}
resource_manager.get.assert_called_once_with('slot:opc_tags:id') resource_manager.get.assert_called_once_with("slot:opc_tags:id")
def test_ingestor_heartbeat(resource_manager): def test_ingestor_heartbeat(resource_manager):
resource_manager.redis.set.return_value = True resource_manager.redis.set.return_value = True
resource_manager.ingestor_heartbeat() resource_manager.ingestor_heartbeat()
resource_manager.redis.set.assert_called_once_with( resource_manager.redis.set.assert_called_once_with(
'heartbeat:ingestor:pod_id', 1, ex=10 "heartbeat:ingestor:pod_id", 1, ex=10
) )
def test_lease_tag(resource_manager): def test_lease_tag(resource_manager):
resource_manager.redis.set.return_value = True resource_manager.redis.set.return_value = True
output = resource_manager.lease_tag('tag_id') output = resource_manager.lease_tag("tag_id")
assert output is True assert output is True
resource_manager.redis.set.assert_called_once_with( resource_manager.redis.set.assert_called_once_with(
'lease:opc_tags:tag_id', 'pod_id', nx=True, ex=10 "lease:opc_tags:tag_id", "pod_id", nx=True, ex=10
) )
def test_renew_tag_lease_success(resource_manager): def test_renew_tag_lease_success(resource_manager):
resource_manager.redis.get.return_value = 'pod_id' resource_manager.redis.get.return_value = "pod_id"
resource_manager.redis.expire.return_value = True resource_manager.redis.expire.return_value = True
result = resource_manager.renew_tag_lease('tag_id') result = resource_manager.renew_tag_lease("tag_id")
assert result is True assert result is True
resource_manager.redis.get.assert_called_once_with( resource_manager.redis.get.assert_called_once_with("lease:opc_tags:tag_id")
'lease:opc_tags:tag_id' resource_manager.redis.expire.assert_called_once_with("lease:opc_tags:tag_id", 10)
)
resource_manager.redis.expire.assert_called_once_with(
'lease:opc_tags:tag_id', 10
)
def test_renew_tag_lease_failure(resource_manager): def test_renew_tag_lease_failure(resource_manager):
resource_manager.redis.get.return_value = 'other_pod_id' resource_manager.redis.get.return_value = "other_pod_id"
resource_manager.redis.expire.return_value = False resource_manager.redis.expire.return_value = False
result = resource_manager.renew_tag_lease('tag_id') result = resource_manager.renew_tag_lease("tag_id")
assert result is False assert result is False
resource_manager.redis.get.assert_called_once_with( resource_manager.redis.get.assert_called_once_with("lease:opc_tags:tag_id")
'lease:opc_tags:tag_id'
)
resource_manager.redis.expire.assert_not_called() resource_manager.redis.expire.assert_not_called()
def test_drop_tag_lease(resource_manager): def test_drop_tag_lease(resource_manager):
resource_manager.redis.delete.return_value = True resource_manager.redis.delete.return_value = True
resource_manager.drop_tag_lease('tag_id') resource_manager.drop_tag_lease("tag_id")
resource_manager.redis.delete.assert_called_once_with( resource_manager.redis.delete.assert_called_once_with("lease:opc_tags:tag_id")
'lease:opc_tags:tag_id'
)
def test_get_all_ingestors(resource_manager): def test_get_all_ingestors(resource_manager):
resource_manager.redis.keys.return_value = ['ingestor1', 'ingestor2'] resource_manager.redis.keys.return_value = ["ingestor1", "ingestor2"]
result = resource_manager.get_all_ingestors() result = resource_manager.get_all_ingestors()
assert result == ['ingestor1', 'ingestor2'] assert result == ["ingestor1", "ingestor2"]
resource_manager.redis.keys.assert_called_once_with( resource_manager.redis.keys.assert_called_once_with("heartbeat:ingestor:*")
'heartbeat:ingestor:*'
)
def test_get_all_slots(resource_manager): def test_get_all_slots(resource_manager):
resource_manager.redis.keys.return_value = ['slot1', 'slot2'] resource_manager.redis.keys.return_value = ["slot1", "slot2"]
result = resource_manager.get_all_slots() result = resource_manager.get_all_slots()
assert result == ['slot1', 'slot2'] assert result == ["slot1", "slot2"]
resource_manager.redis.keys.assert_called_once_with( resource_manager.redis.keys.assert_called_once_with("slot:opc_tags:*")
'slot:opc_tags:*'
)
def test_get_all_leases(resource_manager): def test_get_all_leases(resource_manager):
resource_manager.redis.keys.return_value = ['lease1', 'lease2'] resource_manager.redis.keys.return_value = ["lease1", "lease2"]
result = resource_manager.get_all_leases() result = resource_manager.get_all_leases()
assert result == ['lease1', 'lease2'] assert result == ["lease1", "lease2"]
resource_manager.redis.keys.assert_called_once_with( resource_manager.redis.keys.assert_called_once_with("lease:opc_tags:*")
'lease:opc_tags:*'
)
def test_init_connection_failure(monkeypatch):
# Mock Redis to raise an exception during initialization
mock_redis = MagicMock()
mock_redis.side_effect = Exception("Connection failed")
monkeypatch.setattr("ingestor.managers.resource_manager.Redis", mock_redis)
# Test that the exception is raised and metrics are set properly
with patch("ingestor.metrics.REDIS_CONNECTION_STATUS") as mock_metrics:
mock_status = MagicMock()
mock_metrics.labels.return_value = mock_status
with raises(Exception, match="Connection failed"):
ResourceManager("localhost", 6379, 10, 10, "pod_id")
mock_metrics.labels.assert_called_once_with(pod_id="pod_id")
mock_status.set.assert_called_once_with(0)
def test_init_ping_failure(monkeypatch):
# Mock Redis ping to raise an exception
mock_redis_instance = MagicMock()
mock_redis_instance.ping.side_effect = Exception("Ping failed")
mock_redis_class = MagicMock(return_value=mock_redis_instance)
monkeypatch.setattr("ingestor.managers.resource_manager.Redis", mock_redis_class)
# Test that the exception is raised and metrics are set properly
with patch("ingestor.metrics.REDIS_CONNECTION_STATUS") as mock_metrics:
mock_status = MagicMock()
mock_metrics.labels.return_value = mock_status
with raises(Exception, match="Ping failed"):
ResourceManager("localhost", 6379, 10, 10, "pod_id")
mock_metrics.labels.assert_called_once_with(pod_id="pod_id")
mock_status.set.assert_called_once_with(0)
def test_execute_redis_op_success(resource_manager):
# Mock the Redis operation and time function
mock_func = MagicMock(return_value="test_result")
with patch("ingestor.managers.resource_manager.time", side_effect=[100, 100.5]):
with patch("ingestor.metrics.REDIS_OPERATIONS_TOTAL") as mock_total:
with patch("ingestor.metrics.REDIS_OPERATIONS_DURATION") as mock_duration:
mock_total_labels = MagicMock()
mock_duration_labels = MagicMock()
mock_total.labels.return_value = mock_total_labels
mock_duration.labels.return_value = mock_duration_labels
# Execute the operation
result = resource_manager._execute_redis_op(
"test_op", mock_func, "arg1", kwarg1="value1"
)
# Verify the result and metrics
assert result == "test_result"
mock_func.assert_called_once_with("arg1", kwarg1="value1")
mock_total.labels.assert_called_once_with(
pod_id="pod_id", operation="test_op"
)
mock_total_labels.inc.assert_called_once()
mock_duration.labels.assert_called_once_with(
pod_id="pod_id", operation="test_op"
)
mock_duration_labels.observe.assert_called_once_with(
0.5
)
def test_execute_redis_op_exception(resource_manager):
# Mock the Redis operation to raise an exception
mock_func = MagicMock(side_effect=Exception("Operation failed"))
with patch("ingestor.managers.resource_manager.time", return_value=100):
with patch("ingestor.metrics.REDIS_OPERATIONS_ERRORS") as mock_errors:
with patch("builtins.print") as mock_print:
mock_errors_labels = MagicMock()
mock_errors.labels.return_value = mock_errors_labels
# Execute the operation and expect an exception
with raises(Exception, match="Operation failed"):
resource_manager._execute_redis_op("test_op", mock_func, "arg1")
# Verify metrics and error handling
mock_errors.labels.assert_called_once_with(
pod_id="pod_id", operation="test_op"
)
mock_errors_labels.inc.assert_called_once()
mock_print.assert_called_once_with(
"Error in Redis operation 'test_op': Operation failed"
)

277
tests/unit/test_app.py Normal file
View File

@@ -0,0 +1,277 @@
import pytest
from unittest.mock import patch, MagicMock, call
from threading import Event
import signal as signal_module # To avoid conflict with mock names
import os
# Import the 'app' module to be tested
from ingestor import app
# Custom exception to catch os._exit calls
class OsExitCalled(Exception):
def __init__(self, code):
super().__init__(f"os._exit({code}) called")
self.code = code
# Helper function for the os_exit mock's side_effect
def raise_os_exit_with_code(exit_code):
raise OsExitCalled(exit_code)
@pytest.fixture
def mock_app_env(monkeypatch):
"""Fixture to mock dependencies of app.main and app.signal_handler."""
mocks = {
"start_http_server": MagicMock(),
"Ingestor": MagicMock(),
"metrics_APP_UP_labels_set": MagicMock(),
"metrics_APP_LOOP_COUNT_labels_inc": MagicMock(),
"metrics_APP_LOOP_DURATION_labels_observe": MagicMock(),
"metrics_APP_ERRORS_TOTAL_labels_inc": MagicMock(),
"os_exit": MagicMock(side_effect=raise_os_exit_with_code), # CORRECTED
"time_time": MagicMock(),
"time_sleep": MagicMock(),
"signal_signal": MagicMock(),
"traceback_print_exc": MagicMock(),
"mock_exit_signal": MagicMock(spec=Event),
}
monkeypatch.setattr(app, "start_http_server", mocks["start_http_server"])
monkeypatch.setattr(app, "Ingestor", mocks["Ingestor"])
monkeypatch.setattr(
app.metrics.APP_UP,
"labels",
MagicMock(return_value=MagicMock(set=mocks["metrics_APP_UP_labels_set"])),
)
monkeypatch.setattr(
app.metrics.APP_LOOP_COUNT,
"labels",
MagicMock(
return_value=MagicMock(inc=mocks["metrics_APP_LOOP_COUNT_labels_inc"])
),
)
monkeypatch.setattr(
app.metrics.APP_LOOP_DURATION,
"labels",
MagicMock(
return_value=MagicMock(
observe=mocks["metrics_APP_LOOP_DURATION_labels_observe"]
)
),
)
monkeypatch.setattr(
app.metrics.APP_ERRORS_TOTAL,
"labels",
MagicMock(
return_value=MagicMock(inc=mocks["metrics_APP_ERRORS_TOTAL_labels_inc"])
),
)
monkeypatch.setattr(app.os, "_exit", mocks["os_exit"])
monkeypatch.setattr(app, "time", mocks["time_time"])
monkeypatch.setattr(app, "sleep", mocks["time_sleep"])
monkeypatch.setattr(app.signal, "signal", mocks["signal_signal"])
monkeypatch.setattr(app.traceback, "print_exc", mocks["traceback_print_exc"])
monkeypatch.setattr(app, "exit_signal", mocks["mock_exit_signal"])
monkeypatch.setattr(app, "POD_ID", "test_pod")
mock_ingestor_instance = mocks["Ingestor"].return_value
mock_ingestor_instance.poll_interval = 0.01
mock_ingestor_instance.logger = MagicMock()
return mocks
def test_main_successful_run_one_loop(mock_app_env, capsys):
"""Test a successful run where the loop executes once and then exits gracefully."""
mock_ingestor_instance = mock_app_env["Ingestor"].return_value
mock_exit_signal = mock_app_env["mock_exit_signal"]
mock_exit_signal.is_set.side_effect = [False, True]
mock_app_env["time_time"].side_effect = [10.0, 11.5]
with pytest.raises(OsExitCalled) as excinfo:
app.main()
assert excinfo.value.code == 0
mock_app_env["start_http_server"].assert_called_once_with(4840)
app.metrics.APP_UP.labels.assert_any_call(pod_id="test_pod")
set_calls = mock_app_env["metrics_APP_UP_labels_set"].call_args_list
assert call(1) in set_calls
assert call(0) in set_calls
assert set_calls.index(call(1)) < set_calls.index(call(0))
mock_app_env["Ingestor"].assert_called_once_with()
mock_ingestor_instance.prepare_ingestor.assert_called_once()
mock_ingestor_instance.logger.info.assert_any_call(
"Ingestor prepared. Starting main loop."
)
mock_ingestor_instance.loop.assert_called_once()
app.metrics.APP_LOOP_COUNT.labels.assert_called_with(pod_id="test_pod")
mock_app_env["metrics_APP_LOOP_COUNT_labels_inc"].assert_called_once()
mock_exit_signal.wait.assert_called_once_with(mock_ingestor_instance.poll_interval)
app.metrics.APP_LOOP_DURATION.labels.assert_called_with(pod_id="test_pod")
mock_app_env["metrics_APP_LOOP_DURATION_labels_observe"].assert_called_once_with(
1.5
)
mock_ingestor_instance.shutdown.assert_called_once()
mock_app_env["time_sleep"].assert_called_once_with(5)
# CORREÇÃO APLICADA ABAIXO:
mock_ingestor_instance.logger.info.assert_any_call("Main loop exit_signaled.")
captured = capsys.readouterr()
assert "Prometheus server started on port 4840." in captured.out
def test_main_prometheus_server_fails_to_start(mock_app_env, capsys):
"""Test the scenario where starting the Prometheus server fails."""
mock_app_env["start_http_server"].side_effect = OSError("Port already in use")
with pytest.raises(OsExitCalled) as excinfo:
app.main()
assert excinfo.value.code == 1
# Verify that APP_UP.labels(...).set(1) was NOT called.
# The mock for .set is mock_app_env['metrics_APP_UP_labels_set']
# We need to check if it was called with 1.
# A more robust way is to check if the specific .labels(pod_id="test_pod") mock was ever called
# and then its .set(1) method.
# For simplicity here, we check if metrics_APP_UP_labels_set was ever called with 1.
# Check that .set(1) was not called. .set(0) definitely not called.
called_with_1 = False
for call_args in mock_app_env["metrics_APP_UP_labels_set"].call_args_list:
if call_args == call(1):
called_with_1 = True
break
assert (
not called_with_1
), "APP_UP.set(1) should not have been called if server start failed"
mock_app_env["Ingestor"].assert_not_called()
captured = capsys.readouterr()
assert "Failed to start Prometheus server: Port already in use" in captured.out
def test_main_loop_exception_handling(mock_app_env, capsys):
"""Test that an exception in ingestor.loop() is handled gracefully."""
mock_ingestor_instance = mock_app_env["Ingestor"].return_value
mock_exit_signal = mock_app_env["mock_exit_signal"]
mock_exit_signal.is_set.side_effect = [False, True]
mock_ingestor_instance.loop.side_effect = Exception("Test loop exception")
mock_app_env["time_time"].side_effect = [10.0, 10.1]
with pytest.raises(OsExitCalled) as excinfo:
app.main()
assert excinfo.value.code == 0
mock_ingestor_instance.loop.assert_called_once()
mock_app_env["traceback_print_exc"].assert_called_once()
app.metrics.APP_ERRORS_TOTAL.labels.assert_called_with(pod_id="test_pod")
mock_app_env["metrics_APP_ERRORS_TOTAL_labels_inc"].assert_called_once()
mock_exit_signal.set.assert_called_once()
mock_app_env["metrics_APP_LOOP_COUNT_labels_inc"].assert_not_called()
app.metrics.APP_LOOP_DURATION.labels.assert_called_with(pod_id="test_pod")
mock_app_env["metrics_APP_LOOP_DURATION_labels_observe"].assert_called_once_with(
pytest.approx(0.1)
)
mock_ingestor_instance.shutdown.assert_called_once()
captured = capsys.readouterr()
assert "Exception in main loop. Setting exit_signal flag." in captured.out
def test_main_keyboard_interrupt_handling(mock_app_env, capsys):
"""Test that KeyboardInterrupt in ingestor.loop() is handled."""
mock_ingestor_instance = mock_app_env["Ingestor"].return_value
mock_exit_signal = mock_app_env["mock_exit_signal"]
mock_exit_signal.is_set.side_effect = [False, True]
mock_ingestor_instance.loop.side_effect = KeyboardInterrupt()
mock_app_env["time_time"].side_effect = [10.0, 10.1]
with pytest.raises(OsExitCalled) as excinfo:
app.main()
assert excinfo.value.code == 0
mock_ingestor_instance.loop.assert_called_once()
mock_exit_signal.set.assert_called_once()
mock_ingestor_instance.shutdown.assert_called_once()
captured = capsys.readouterr()
assert "KeyboardInterrupt received. Setting exit_signal flag." in captured.out
mock_app_env["metrics_APP_ERRORS_TOTAL_labels_inc"].assert_not_called()
def test_signal_handler_sets_exit_signal(mock_app_env):
"""Test that the signal_handler function calls exit_signal.set()."""
mock_exit_signal_set = mock_app_env["mock_exit_signal"].set
app.signal_handler(signal_module.SIGINT, None)
mock_exit_signal_set.assert_called_once()
def test_main_multiple_loop_iterations(mock_app_env):
"""Test the main loop runs for a few iterations."""
mock_ingestor_instance = mock_app_env["Ingestor"].return_value
mock_exit_signal = mock_app_env["mock_exit_signal"]
mock_exit_signal.is_set.side_effect = [False, False, False, True]
mock_app_env["time_time"].side_effect = [10.0, 10.1, 10.2, 10.3, 10.4, 10.5]
with pytest.raises(OsExitCalled) as excinfo:
app.main()
assert excinfo.value.code == 0
assert mock_ingestor_instance.loop.call_count == 3
assert app.metrics.APP_LOOP_COUNT.labels.call_count == 3
app.metrics.APP_LOOP_COUNT.labels.assert_called_with(
pod_id="test_pod"
) # Checks last call or any call
assert mock_app_env["metrics_APP_LOOP_COUNT_labels_inc"].call_count == 3
assert mock_exit_signal.wait.call_count == 3
assert app.metrics.APP_LOOP_DURATION.labels.call_count == 3
app.metrics.APP_LOOP_DURATION.labels.assert_called_with(pod_id="test_pod")
duration_calls = mock_app_env[
"metrics_APP_LOOP_DURATION_labels_observe"
].call_args_list
assert duration_calls[0] == call(pytest.approx(0.1, abs=1e-9))
assert duration_calls[1] == call(pytest.approx(0.1, abs=1e-9))
assert duration_calls[2] == call(pytest.approx(0.1, abs=1e-9))
mock_ingestor_instance.shutdown.assert_called_once()
def test_main_pod_id_used_in_metrics(mock_app_env):
"""Test that the POD_ID from app module is used in metric labels."""
mock_exit_signal = mock_app_env["mock_exit_signal"]
mock_exit_signal.is_set.side_effect = [False, True]
mock_app_env["time_time"].side_effect = [10.0, 11.0]
with pytest.raises(OsExitCalled):
app.main()
app.metrics.APP_UP.labels.assert_any_call(pod_id="test_pod")
app.metrics.APP_LOOP_COUNT.labels.assert_any_call(pod_id="test_pod")
app.metrics.APP_LOOP_DURATION.labels.assert_any_call(pod_id="test_pod")
# APP_ERRORS_TOTAL would be checked similarly if it were called in this flow.
# Check the .set() / .inc() calls on the mocks returned by .labels()
mock_app_env["metrics_APP_UP_labels_set"].assert_any_call(1)
mock_app_env["metrics_APP_UP_labels_set"].assert_any_call(0)
mock_app_env["metrics_APP_LOOP_COUNT_labels_inc"].assert_called_once()

View File

@@ -1,4 +1,5 @@
from unittest.mock import ANY, MagicMock, patch from unittest.mock import ANY, MagicMock, patch, call
from os import getenv
from pytest import fixture from pytest import fixture
from ingestor.ingestor import Ingestor from ingestor.ingestor import Ingestor
@@ -24,13 +25,13 @@ def test___init__(notification_handler, init_logger, getenv):
getenv.assert_any_call("KAFKA_SERVERS", "localhost:9092") getenv.assert_any_call("KAFKA_SERVERS", "localhost:9092")
getenv.assert_any_call("REDIS_HOST", "localhost") getenv.assert_any_call("REDIS_HOST", "localhost")
getenv.assert_any_call("REDIS_PORT", 6379) getenv.assert_any_call("REDIS_PORT", '6379')
getenv.assert_any_call("REDIS_USERNAME", None) getenv.assert_any_call("REDIS_USERNAME", None)
getenv.assert_any_call("REDIS_PASSWORD", None) getenv.assert_any_call("REDIS_PASSWORD", None)
getenv.assert_any_call("LEASE_TTL", 10) getenv.assert_any_call("LEASE_TTL", '10')
getenv.assert_any_call("HEARTBEAT_TTL", 20) getenv.assert_any_call("HEARTBEAT_TTL", '20')
getenv.assert_any_call("HOSTNAME", "localhost") getenv.assert_any_call("HOSTNAME", "localhost")
getenv.assert_any_call("POLL_INTERVAL", 5) getenv.assert_any_call("POLL_INTERVAL", '5')
assert ingestor.kafka_servers == ["localhost:9092", "localhost:35"] assert ingestor.kafka_servers == ["localhost:9092", "localhost:35"]
assert ingestor.redis_host == "localhost1" assert ingestor.redis_host == "localhost1"
@@ -46,11 +47,7 @@ def test___init__(notification_handler, init_logger, getenv):
notification_handler.assert_called_once_with( notification_handler.assert_called_once_with(
servers=["localhost:9092", "localhost:35"], servers=["localhost:9092", "localhost:35"],
logger=ingestor.logger, logger=ingestor.logger,
project_name="OPC_INGESTOR", project_name="OPC_INGESTOR"
pipeline_name="-",
trigger_name="-",
model_name="-",
model="-"
) )
@@ -58,7 +55,7 @@ def test___init__(notification_handler, init_logger, getenv):
@patch("ingestor.ingestor.getenv") @patch("ingestor.ingestor.getenv")
@patch("ingestor.ingestor.Ingestor.init_logger") @patch("ingestor.ingestor.Ingestor.init_logger")
@patch("ingestor.ingestor.NotificationHandler") @patch("ingestor.ingestor.NotificationHandler")
def ingestor(notification_handler, init_logger, getenv): def ingestor(_notification_handler, _init_logger, _getenv):
ing = Ingestor() ing = Ingestor()
ing.logger = MagicMock() ing.logger = MagicMock()
@@ -82,7 +79,8 @@ def test_init_logger(formatter, stream_handler, get_logger, ingestor):
stream_handler.assert_called_once() stream_handler.assert_called_once()
formatter.assert_called_once_with( formatter.assert_called_once_with(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s') '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
ingestor.logger.setLevel.assert_called_once_with("INFO") ingestor.logger.setLevel.assert_called_once_with(
getenv("LOG_LEVEL", "INFO"))
ingestor.logger.addHandler.assert_called_once_with( ingestor.logger.addHandler.assert_called_once_with(
stream_handler.return_value) stream_handler.return_value)
stream_handler.return_value.setFormatter.assert_called_once_with( stream_handler.return_value.setFormatter.assert_called_once_with(
@@ -172,8 +170,6 @@ def test_manage_slots_none_available_none_available(ingestor_manager_started):
ingestor_manager_started.ingestor_manager.get_slot_leases.assert_called_once_with( ingestor_manager_started.ingestor_manager.get_slot_leases.assert_called_once_with(
1) 1)
ingestor_manager_started.handle_acquired_tags.assert_called_once_with(
ingestor_manager_started.ingestor_manager.get_slot_leases.return_value)
def test_manage_leases_no_available_slots_no_extra_slots(ingestor_manager_started): def test_manage_leases_no_available_slots_no_extra_slots(ingestor_manager_started):
@@ -193,8 +189,6 @@ def test_manage_leases_available_slots_innactive_ingestors(ingestor_manager_star
ingestor_manager_started.ingestor_manager.get_slot_leases.assert_called_once_with( ingestor_manager_started.ingestor_manager.get_slot_leases.assert_called_once_with(
2) 2)
ingestor_manager_started.handle_acquired_tags.assert_called_once_with(
ingestor_manager_started.ingestor_manager.get_slot_leases.return_value)
ingestor_manager_started.ingestor_manager.drop_slot_leases.assert_not_called() ingestor_manager_started.ingestor_manager.drop_slot_leases.assert_not_called()
@@ -267,3 +261,35 @@ def test_loop_no_managed(ingestor_manager_started):
ingestor_manager_started.ingestor_manager.update_slot_config.assert_called_once() ingestor_manager_started.ingestor_manager.update_slot_config.assert_called_once()
ingestor_manager_started.logger.info.assert_any_call( ingestor_manager_started.logger.info.assert_any_call(
"No slots acquired in this loop") "No slots acquired in this loop")
def test_update_ingestor_manager(ingestor_manager_started):
ingestor_manager_started.ingestor_manager.managed_tags = {
"slot_to_create": "new_config",
"slot_to_update": "new_config",
"slot_to_do_nothing": "old_config"
}
old_managed_tags = {
"slot_to_update": "old_config",
"slot_to_delete": "old_config",
"slot_to_do_nothing": "old_config"
}
ingestor_manager_started.update_ingestor_manager(old_managed_tags)
ingestor_manager_started.ingestor_manager.update_opc_servers.assert_called_once()
ingestor_manager_started.ingestor_manager.subscribe_to_tags.assert_has_calls(
[
call(
{"slot_to_create": "new_config"}),
call(
{"slot_to_update": "new_config"})
]
)
ingestor_manager_started.ingestor_manager.unsubscribe_slot.assert_has_calls(
[
call("slot_to_update"),
call("slot_to_delete")
]
)

253
tests/unit/test_metrics.py Normal file
View File

@@ -0,0 +1,253 @@
# tests/unit/test_metrics.py
import pytest
from prometheus_client import Counter, Gauge, Histogram
import ingestor.metrics as metrics
# --- Test Functions for Each Metric (Corrected for v0.22.0 _name behavior) ---
def test_app_loop_count():
"""Verify the definition of APP_LOOP_COUNT."""
assert metrics.APP_LOOP_COUNT is not None
assert isinstance(metrics.APP_LOOP_COUNT, Counter)
assert metrics.APP_LOOP_COUNT._name == "app_main_loop" # REMOVED _total
assert set(metrics.APP_LOOP_COUNT._labelnames) == {"pod_id"}
def test_app_loop_duration():
"""Verify the definition of APP_LOOP_DURATION."""
assert metrics.APP_LOOP_DURATION is not None
assert isinstance(metrics.APP_LOOP_DURATION, Histogram)
assert (
metrics.APP_LOOP_DURATION._name == "app_main_loop_duration_seconds"
) # Histograms don't have _total
assert set(metrics.APP_LOOP_DURATION._labelnames) == {"pod_id"}
def test_app_errors_total():
"""Verify the definition of APP_ERRORS_TOTAL."""
assert metrics.APP_ERRORS_TOTAL is not None
assert isinstance(metrics.APP_ERRORS_TOTAL, Counter)
assert metrics.APP_ERRORS_TOTAL._name == "app_errors" # REMOVED _total
assert set(metrics.APP_ERRORS_TOTAL._labelnames) == {"pod_id"}
def test_app_up():
"""Verify the definition of APP_UP."""
assert metrics.APP_UP is not None
assert isinstance(metrics.APP_UP, Gauge)
assert metrics.APP_UP._name == "app_up" # Gauges don't have _total
assert set(metrics.APP_UP._labelnames) == {"pod_id"}
def test_active_ingestors():
"""Verify the definition of ACTIVE_INGESTORS."""
assert metrics.ACTIVE_INGESTORS is not None
assert isinstance(metrics.ACTIVE_INGESTORS, Gauge)
assert metrics.ACTIVE_INGESTORS._name == "ingestor_active_total"
assert set(metrics.ACTIVE_INGESTORS._labelnames) == set()
def test_slots_total():
"""Verify the definition of SLOTS_TOTAL."""
assert metrics.SLOTS_TOTAL is not None
assert isinstance(metrics.SLOTS_TOTAL, Gauge)
assert metrics.SLOTS_TOTAL._name == "ingestor_slots_total"
assert set(metrics.SLOTS_TOTAL._labelnames) == set()
def test_leases_total():
"""Verify the definition of LEASES_TOTAL."""
assert metrics.LEASES_TOTAL is not None
assert isinstance(metrics.LEASES_TOTAL, Gauge)
assert metrics.LEASES_TOTAL._name == "ingestor_leases_total"
assert set(metrics.LEASES_TOTAL._labelnames) == set()
def test_slots_managed():
"""Verify the definition of SLOTS_MANAGED."""
assert metrics.SLOTS_MANAGED is not None
assert isinstance(metrics.SLOTS_MANAGED, Gauge)
assert metrics.SLOTS_MANAGED._name == "ingestor_slots_managed_current"
assert set(metrics.SLOTS_MANAGED._labelnames) == {"pod_id"}
def test_slots_acquired():
"""Verify the definition of SLOTS_ACQUIRED."""
assert metrics.SLOTS_ACQUIRED is not None
assert isinstance(metrics.SLOTS_ACQUIRED, Counter)
assert metrics.SLOTS_ACQUIRED._name == "ingestor_slots_acquired" # REMOVED _total
assert set(metrics.SLOTS_ACQUIRED._labelnames) == {"pod_id"}
def test_slots_released():
"""Verify the definition of SLOTS_RELEASED."""
assert metrics.SLOTS_RELEASED is not None
assert isinstance(metrics.SLOTS_RELEASED, Counter)
assert metrics.SLOTS_RELEASED._name == "ingestor_slots_released" # REMOVED _total
assert set(metrics.SLOTS_RELEASED._labelnames) == {"pod_id"}
def test_opc_managers_active():
"""Verify the definition of OPC_MANAGERS_ACTIVE."""
assert metrics.OPC_MANAGERS_ACTIVE is not None
assert isinstance(metrics.OPC_MANAGERS_ACTIVE, Gauge)
assert metrics.OPC_MANAGERS_ACTIVE._name == "ingestor_opc_managers_active"
assert set(metrics.OPC_MANAGERS_ACTIVE._labelnames) == {"pod_id"}
def test_opc_subscription_errors():
"""Verify the definition of OPC_SUBSCRIPTION_ERRORS."""
assert metrics.OPC_SUBSCRIPTION_ERRORS is not None
assert isinstance(metrics.OPC_SUBSCRIPTION_ERRORS, Counter)
assert (
metrics.OPC_SUBSCRIPTION_ERRORS._name == "ingestor_opc_subscription_errors"
) # REMOVED _total
assert set(metrics.OPC_SUBSCRIPTION_ERRORS._labelnames) == {
"pod_id",
"server",
"slot",
}
def test_opc_connections_total():
"""Verify the definition of OPC_CONNECTIONS_TOTAL."""
assert metrics.OPC_CONNECTIONS_TOTAL is not None
assert isinstance(metrics.OPC_CONNECTIONS_TOTAL, Counter)
assert (
metrics.OPC_CONNECTIONS_TOTAL._name == "opc_connections_initiated"
) # REMOVED _total
assert set(metrics.OPC_CONNECTIONS_TOTAL._labelnames) == {"pod_id", "server_name"}
def test_opc_connections_failed():
"""Verify the definition of OPC_CONNECTIONS_FAILED."""
assert metrics.OPC_CONNECTIONS_FAILED is not None
assert isinstance(metrics.OPC_CONNECTIONS_FAILED, Counter)
assert (
metrics.OPC_CONNECTIONS_FAILED._name == "opc_connections_failed"
) # REMOVED _total
assert set(metrics.OPC_CONNECTIONS_FAILED._labelnames) == {"pod_id", "server_name"}
def test_opc_connection_status():
"""Verify the definition of OPC_CONNECTION_STATUS."""
assert metrics.OPC_CONNECTION_STATUS is not None
assert isinstance(metrics.OPC_CONNECTION_STATUS, Gauge)
assert metrics.OPC_CONNECTION_STATUS._name == "opc_connection_status"
assert set(metrics.OPC_CONNECTION_STATUS._labelnames) == {
"pod_id",
"server_name",
"server_url",
}
def test_opc_subscriptions_created():
"""Verify the definition of OPC_SUBSCRIPTIONS_CREATED."""
assert metrics.OPC_SUBSCRIPTIONS_CREATED is not None
assert isinstance(metrics.OPC_SUBSCRIPTIONS_CREATED, Counter)
assert (
metrics.OPC_SUBSCRIPTIONS_CREATED._name == "opc_subscriptions_created"
) # REMOVED _total
assert set(metrics.OPC_SUBSCRIPTIONS_CREATED._labelnames) == {
"pod_id",
"server_name",
"slot_name",
}
def test_opc_tags_subscribed():
"""Verify the definition of OPC_TAGS_SUBSCRIBED."""
assert metrics.OPC_TAGS_SUBSCRIBED is not None
assert isinstance(metrics.OPC_TAGS_SUBSCRIBED, Gauge)
assert metrics.OPC_TAGS_SUBSCRIBED._name == "opc_tags_subscribed_current"
assert set(metrics.OPC_TAGS_SUBSCRIBED._labelnames) == {"pod_id", "server_name"}
def test_opc_cycles_without_data():
"""Verify the definition of OPC_CYCLES_WITHOUT_DATA."""
assert metrics.OPC_CYCLES_WITHOUT_DATA is not None
assert isinstance(metrics.OPC_CYCLES_WITHOUT_DATA, Gauge)
assert metrics.OPC_CYCLES_WITHOUT_DATA._name == "opc_cycles_without_data"
assert set(metrics.OPC_CYCLES_WITHOUT_DATA._labelnames) == {"pod_id", "server_name"}
def test_opc_reconnections_total():
"""Verify the definition of OPC_RECONNECTIONS_TOTAL."""
assert metrics.OPC_RECONNECTIONS_TOTAL is not None
assert isinstance(metrics.OPC_RECONNECTIONS_TOTAL, Counter)
assert (
metrics.OPC_RECONNECTIONS_TOTAL._name == "opc_reconnections_tried"
) # REMOVED _total
assert set(metrics.OPC_RECONNECTIONS_TOTAL._labelnames) == {"pod_id", "server_name"}
def test_kafka_messages_sent():
"""Verify the definition of KAFKA_MESSAGES_SENT."""
assert metrics.KAFKA_MESSAGES_SENT is not None
assert isinstance(metrics.KAFKA_MESSAGES_SENT, Counter)
assert metrics.KAFKA_MESSAGES_SENT._name == "kafka_messages_sent" # REMOVED _total
assert set(metrics.KAFKA_MESSAGES_SENT._labelnames) == {"pod_id", "topic"}
def test_kafka_messages_errors():
"""Verify the definition of KAFKA_MESSAGES_ERRORS."""
assert metrics.KAFKA_MESSAGES_ERRORS is not None
assert isinstance(metrics.KAFKA_MESSAGES_ERRORS, Counter)
assert (
metrics.KAFKA_MESSAGES_ERRORS._name == "kafka_messages_errors"
) # REMOVED _total
assert set(metrics.KAFKA_MESSAGES_ERRORS._labelnames) == {"pod_id", "topic"}
def test_kafka_connection_status():
"""Verify the definition of KAFKA_CONNECTION_STATUS."""
assert metrics.KAFKA_CONNECTION_STATUS is not None
assert isinstance(metrics.KAFKA_CONNECTION_STATUS, Gauge)
assert metrics.KAFKA_CONNECTION_STATUS._name == "kafka_connection_status"
assert set(metrics.KAFKA_CONNECTION_STATUS._labelnames) == {"pod_id"}
def test_redis_operations_total():
"""Verify the definition of REDIS_OPERATIONS_TOTAL."""
assert metrics.REDIS_OPERATIONS_TOTAL is not None
assert isinstance(metrics.REDIS_OPERATIONS_TOTAL, Counter)
assert metrics.REDIS_OPERATIONS_TOTAL._name == "redis_operations" # REMOVED _total
assert set(metrics.REDIS_OPERATIONS_TOTAL._labelnames) == {"pod_id", "operation"}
def test_redis_operations_errors():
"""Verify the definition of REDIS_OPERATIONS_ERRORS."""
assert metrics.REDIS_OPERATIONS_ERRORS is not None
assert isinstance(metrics.REDIS_OPERATIONS_ERRORS, Counter)
assert (
metrics.REDIS_OPERATIONS_ERRORS._name == "redis_operations_errors"
) # REMOVED _total
assert set(metrics.REDIS_OPERATIONS_ERRORS._labelnames) == {"pod_id", "operation"}
def test_redis_operations_duration():
"""Verify the definition of REDIS_OPERATIONS_DURATION."""
assert metrics.REDIS_OPERATIONS_DURATION is not None
assert isinstance(metrics.REDIS_OPERATIONS_DURATION, Histogram)
assert (
metrics.REDIS_OPERATIONS_DURATION._name == "redis_operations_duration_seconds"
)
assert set(metrics.REDIS_OPERATIONS_DURATION._labelnames) == {"pod_id", "operation"}
def test_redis_connection_status():
"""Verify the definition of REDIS_CONNECTION_STATUS."""
assert metrics.REDIS_CONNECTION_STATUS is not None
assert isinstance(metrics.REDIS_CONNECTION_STATUS, Gauge)
assert metrics.REDIS_CONNECTION_STATUS._name == "redis_connection_status"
assert set(metrics.REDIS_CONNECTION_STATUS._labelnames) == {"pod_id"}
def test_notifications_sent():
"""Verify the definition of NOTIFICATIONS_SENT."""
assert metrics.NOTIFICATIONS_SENT is not None
assert isinstance(metrics.NOTIFICATIONS_SENT, Counter)
assert metrics.NOTIFICATIONS_SENT._name == "notifications_sent" # REMOVED _total
assert set(metrics.NOTIFICATIONS_SENT._labelnames) == {"pod_id", "level", "block"}

View File

@@ -123,7 +123,7 @@ env:
- name: GITHUB_REPO_URL - name: GITHUB_REPO_URL
value: "git@github.com:Aignosi/sientia-dataops-opc-ingestor.git" value: "git@github.com:Aignosi/sientia-dataops-opc-ingestor.git"
- name: GITHUB_BRANCH - name: GITHUB_BRANCH
value: "SIENTIAPDE-988-criar-ingestor-opc" value: "main"
- name: PYTHON_APP - name: PYTHON_APP
value: "ingestor.app" value: "ingestor.app"