Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion charm_version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0+69db8cf+69db8cf-dirty+69db8cf-dirty+69db8cf-dirty+69db8cf-dirty+d0f9b21
1.0+91f8440-dirty+91f8440-dirty+91f8440-dirty+91f8440-dirty+91f8440-dirty+91f8440-dirty+91f8440-dirty
1 change: 1 addition & 0 deletions charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ parts:
- libssl-dev
- rustc
- cargo
- cmake
bases:
- build-on:
- name: "ubuntu"
Expand Down
2 changes: 1 addition & 1 deletion lib/charms/data_platform_libs/v0/data_interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ def leader_only(f):
def wrapper(self, *args, **kwargs):
if self.component == self.local_app and not self.local_unit.is_leader():
logger.error(
"This operation (%s()) can only be performed by the leader unit", f.__name__
"This operation (%s.%s()) with arguments (%s, %s)can only be performed by the leader unit", f.__class__, f.__name__, str(args), str(kwargs)
)
return
return f(self, *args, **kwargs)
Expand Down
745 changes: 744 additions & 1 deletion poetry.lock

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ python = "^3.10"
tenacity = "^8.4.2"
pure-sasl = "^0.6.2"
cosl = "^0.0.12"
ops = "^2.13.0"
pydantic = "^1.10.17"
pyyaml = "^6.0.1"
poetry-plugin-export = "^1.8.0"

# TODO: clean any of the notes below and their deps.
[tool.poetry.group.charm-libs.dependencies]
Expand Down Expand Up @@ -78,12 +80,18 @@ pep8-naming = "^0.14.1"
codespell = "^2.2.6"
pyright = "^1.1.318"
typing-extensions = "^4.9.0"
requests = "^2.32.3"
ops = "^2.13.0"

[tool.poetry.group.unit]
optional = true

[tool.poetry.group.unit.dependencies]
pytest = "^8.2.2"
coverage = {extras = ["toml"], version = "^7.5.1"}
pytest-mock = "^3.11.1"
pyyaml = "^6.0.1"
responses = "^0.25.3"

[tool.poetry.group.integration.dependencies]
pytest = "^8.2.2"
Expand Down
51 changes: 45 additions & 6 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@
import logging
import time

# from events.provider import ProviderEvents
from charms.grafana_agent.v0.cos_agent import COSAgentProvider
from charms.rolling_ops.v0.rollingops import RollingOpsManager
from ops.charm import CharmBase, InstallEvent, SecretChangedEvent
from ops.framework import EventBase
from ops.main import main
from ops.model import BlockedStatus, MaintenanceStatus, WaitingStatus
from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, WaitingStatus

from core.cluster import ClusterState
from events.requirer import RequirerEvents
Expand All @@ -29,14 +28,17 @@
MSG_INSTALLING,
MSG_STARTING,
MSG_STARTING_SERVER,
MSG_STATUS,
MSG_TLS_CONFIG,
MSG_WAITING_FOR_PEER,
PEER,
RESTART_TIMEOUT,
SERVER_PORT,
SUBSTRATE,
)
from managers.api import APIManager
from managers.config import ConfigManager
from managers.health import HealthManager
from managers.tls import TLSManager
from workload import ODWorkload

Expand Down Expand Up @@ -71,6 +73,12 @@ def __init__(self, *args):
self.config_manager = ConfigManager(
state=self.state, workload=self.workload, substrate=SUBSTRATE, config=self.config
)
self.api_manager = APIManager(
state=self.state, workload=self.workload, substrate=SUBSTRATE
)
self.health_manager = HealthManager(
state=self.state, workload=self.workload, substrate=SUBSTRATE
)

# --- LIB EVENT HANDLERS ---

Expand Down Expand Up @@ -123,9 +131,14 @@ def _on_install(self, event: InstallEvent) -> None:
def reconcile(self, event: EventBase) -> None:
"""Generic handler for all 'something changed, update' events across all relations."""

outdated_status = []

# not all methods called
if not self.state.peer_relation:
self.unit.status = WaitingStatus(MSG_WAITING_FOR_PEER)
return
else:
outdated_status.append(MSG_WAITING_FOR_PEER)

# attempt startup of server
if not self.state.unit_server.started:
Expand All @@ -135,11 +148,11 @@ def reconcile(self, event: EventBase) -> None:
if getattr(event, "departing_unit", None) == self.unit:
return

outdated_status = []
# Maintain the correct app status
if self.unit.is_leader():
if self.state.opensearch_server:
outdated_status.append(MSG_DB_MISSING)
if self.state.opensearch_server:
outdated_status.append(MSG_DB_MISSING)
else:
self.unit.status = BlockedStatus(MSG_DB_MISSING)

# Maintain the correct unit status

Expand All @@ -149,6 +162,8 @@ def reconcile(self, event: EventBase) -> None:
outdated_status.append(MSG_TLS_CONFIG)
else:
self.unit.status = MaintenanceStatus(MSG_TLS_CONFIG)
else:
outdated_status.append(MSG_TLS_CONFIG)

# Restart on config change
if (
Expand All @@ -158,9 +173,31 @@ def reconcile(self, event: EventBase) -> None:
):
self.on[f"{self.restart.name}"].acquire_lock.emit()

# Regular health-check
if isinstance(self.unit.status, ActiveStatus) or self.unit.status.message in MSG_STATUS:
healthy, msg = self.health_manager.healthy()

if healthy:
if msg:
self.unit.status = ActiveStatus(msg)
else:
outdated_status += MSG_STATUS
else:
self.unit.status = BlockedStatus(msg)

# Clear all possible irrelevant statuses
for status in outdated_status:
clear_status(self.unit, status)
if self.unit.is_leader():
clear_status(self.app, status)

# # In case all units have the same status, we set app status accordingly
# if self.unit.is_leader():
# status = self.unit.status
# for unit in self.state.peer_relation.units:
# if unit.status != status:
# return
# self.app.status = self.unit.status

def _on_secret_changed(self, event: SecretChangedEvent):
"""Reconfigure services on a secret changed event."""
Expand Down Expand Up @@ -217,6 +254,7 @@ def _restart(self, event: EventBase) -> None:
time.sleep(5)

clear_status(self.unit, [MSG_STARTING, MSG_STARTING_SERVER])
self.on.update_status.emit()

# --- CONVENIENCE METHODS ---

Expand All @@ -229,6 +267,7 @@ def init_server(self):
self.config_manager.set_dashboard_properties()

logger.debug("starting Opensearch Dashboards service")

self.workload.start()

# open port
Expand Down
7 changes: 5 additions & 2 deletions src/core/cluster.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class ClusterState(Object):
"""Collection of global cluster state for Framework/Object."""

def __init__(self, charm: Framework | Object, substrate: SUBSTRATES):
super().__init__(parent=charm, key="charm_state")
super().__init__(parent=charm, key="osd_charm_state")
self.substrate: SUBSTRATES = substrate
self._servers_data = {}

Expand All @@ -59,7 +59,10 @@ def peer_relation(self) -> Relation | None:
@property
def opensearch_relation(self) -> Relation | None:
"""The Opensearch Server relation."""
return self.model.get_relation(OPENSEARCH_REL_NAME)
try:
return self.model.get_relation(OPENSEARCH_REL_NAME)
except KeyError:
return None

@property
def tls_relation(self) -> Relation | None:
Expand Down
20 changes: 9 additions & 11 deletions src/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from ops.model import Application, Relation, Unit
from typing_extensions import override

from literals import COS_USER
from literals import SERVER_PORT

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -40,6 +40,7 @@ def __init__(
@property
def relation_data(self) -> MutableMapping[str, str]:
"""The raw relation data."""
logger.debug("Fetching relation data for %s", str(self.relation))
return self._relation_data.data if isinstance(self._relation_data, DataDict) else {}

def update(self, items: dict[str, str]) -> None:
Expand Down Expand Up @@ -165,16 +166,6 @@ def started(self) -> bool:
"""Flag to check if the unit has started the service."""
return self.relation_data.get("state", None) == "started"

@property
def cos_user(self) -> str | None:
"""The generated password for the client application."""
return COS_USER

@property
def cos_password(self) -> str | None:
"""The generated password for the client application."""
return self.relation_data.get("monitor-password")

@property
def password_rotated(self) -> bool:
"""Flag to check if the unit has rotated their internal passwords."""
Expand Down Expand Up @@ -258,3 +249,10 @@ def sans(self) -> dict[str, list[str]]:
"sans_ip": [self.private_ip],
"sans_dns": [self.hostname, self.fqdn],
}

@property
def url(self) -> str:
"""Service URL."""
if self.tls:
return f"https://{self.private_ip}:{SERVER_PORT}"
return f"http://{self.private_ip}:{SERVER_PORT}"
13 changes: 13 additions & 0 deletions src/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env python3
# Copyright 2024 Canonical Ltd.
# See LICENSE file for licensing details.

"""Charm-specific exceptions."""


class OSDError(Exception):
"""Charm-specific parent exception."""


class OSDAPIError(OSDError):
"""Exception relating to OSD API access."""
14 changes: 13 additions & 1 deletion src/literals.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,20 @@
MSG_TLS_CONFIG = "Waiting for TLS to be fully configured..."
MSG_INCOMPATIBLE_UPGRADE = "Incompatible upgrade, rollback required"

MSG_STATUS_UNAVAIL = "Service unavailable"
MSG_STATUS_UNHEALTHY = "Service is not in a green health state"
MSG_STATUS_ERROR = "Service is an error state"
MSG_STATUS_WORKLOAD_DOWN = "Workload is not alive"
MSG_STATUS_UNKNOWN = "Workload status is not known"

MSG_STATUS = [
MSG_STATUS_UNAVAIL,
MSG_STATUS_UNHEALTHY,
MSG_STATUS_WORKLOAD_DOWN,
MSG_STATUS_UNKNOWN,
]

# COS

COS_RELATION_NAME = "cos-agent"
COS_USER = "monitor"
COS_PORT = 9684
97 changes: 97 additions & 0 deletions src/managers/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
# Copyright 2024 Canonical Ltd.
# See LICENSE file for licensing details.

"""Manager for for handling API access."""
import json
import logging
from typing import TYPE_CHECKING, Any

import requests
from requests.exceptions import RequestException

from exceptions import OSDAPIError

if TYPE_CHECKING:
pass

from core.cluster import SUBSTRATES, ClusterState
from core.workload import WorkloadBase

logger = logging.getLogger(__name__)

HEADERS = {
"Accept": "application/json",
"Content-Type": "application/json",
"osd-xsrf": "osd-true",
}


class APIManager:
"""Manager for for handling configuration building + writing."""

def __init__(
self,
state: ClusterState,
workload: WorkloadBase,
substrate: SUBSTRATES,
):
self.state = state
self.workload = workload
self.substrate = substrate

# =================================
# Opensearch connection functions
# =================================

def request(
self,
endpoint: str,
method: str = "GET",
headers: dict = HEADERS,
payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Make an HTTP(S) request to the OSD Rest API.

Args:
method: matching the known http methods.
headers: request headers as a dict
endpoint: relative to the base uri.
payload: JSON / map body payload.
"""

if None in [endpoint, method]:
raise ValueError("endpoint or method missing")

full_url = f"{self.state.unit_server.url}/api/{endpoint}"

request_kwargs = {
"verify": self.workload.paths.ca,
"method": method.upper(),
"url": full_url,
"headers": headers,
}

request_kwargs["data"] = json.dumps(payload)
request_kwargs["headers"] = headers

if not self.state.opensearch_server:
raise OSDAPIError("No Opensearch connection, can't query API (missing credentials).")

try:
with requests.Session() as s:
s.auth = ( # type: ignore [reportAttributeAccessIssue]
self.state.opensearch_server.username,
self.state.opensearch_server.password,
)
resp = s.request(**request_kwargs)
resp.raise_for_status()
except RequestException as e:
logger.error(f"Request {method} to {full_url} with payload: {payload} failed. \n{e}")
raise

return resp.json()

def service_status(self) -> dict[str, Any]:
"""Query service status from the OSD API."""
return self.request(endpoint="status")
Loading