Skip to content

Commit

Permalink
Merge pull request #58 from canonical/IAM-998
Browse files Browse the repository at this point in the history
Iam 998
  • Loading branch information
nsklikas authored Aug 30, 2024
2 parents 48c3b68 + 5170fb3 commit 8ab3dac
Show file tree
Hide file tree
Showing 15 changed files with 341 additions and 37 deletions.
1 change: 0 additions & 1 deletion .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ jobs:
provider: microk8s
channel: 1.28-strict/stable
juju-channel: 3.4
bootstrap-options: '--agent-version=3.4.0'

- name: Run integration tests
run: tox -e build-prerequisites,integration -- --model testing
Expand Down
4 changes: 4 additions & 0 deletions charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ requires:
interface: tls-certificates
limit: 1
optional: true
ldap-client:
description: |
Use another ldap server as a backend
interface: ldap

provides:
metrics-endpoint:
Expand Down
85 changes: 75 additions & 10 deletions lib/charms/glauth_k8s/v0/ldap.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def __init__(self, *args):
def _on_ldap_ready(self, event: LdapReadyEvent) -> None:
# Consume the LDAP related information
ldap_data = self.ldap_requirer.consume_ldap_relation_data(
event.relation.id,
relation=event.relation,
)
# Configure the LDAP requirer charm
Expand Down Expand Up @@ -122,9 +122,10 @@ def _on_ldap_requested(self, event: LdapRequestedEvent) -> None:
LDAP related information in order to connect and authenticate to the LDAP server
"""

import json
from functools import wraps
from string import Template
from typing import Any, Callable, Literal, Optional, Union
from typing import Any, Callable, List, Literal, Optional, Union

import ops
from ops.charm import (
Expand Down Expand Up @@ -154,7 +155,7 @@ def _on_ldap_requested(self, event: LdapRequestedEvent) -> None:

# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 4
LIBPATCH = 5

PYDEPS = ["pydantic~=2.5.3"]

Expand Down Expand Up @@ -227,17 +228,27 @@ def remove(self) -> None:


class LdapProviderBaseData(BaseModel):
url: str = Field(frozen=True)
urls: List[str] = Field(frozen=True)
base_dn: str = Field(frozen=True)
starttls: StrictBool = Field(frozen=True)

@field_validator("url")
@field_validator("urls", mode="before")
@classmethod
def validate_ldap_url(cls, v: str) -> str:
if not v.startswith("ldap://"):
raise ValidationError("Invalid LDAP URL scheme.")
def validate_ldap_urls(cls, vs: List[str] | str) -> List[str]:
if isinstance(vs, str):
vs = json.loads(vs)
if isinstance(vs, str):
vs = [vs]

return v
for v in vs:
if not v.startswith("ldap://"):
raise ValidationError("Invalid LDAP URL scheme.")

return vs

@field_serializer("urls")
def serialize_list(self, urls: List[str]) -> str:
return str(json.dumps(urls))

@field_validator("starttls", mode="before")
@classmethod
Expand Down Expand Up @@ -419,10 +430,12 @@ def _on_ldap_relation_broken(self, event: RelationBrokenEvent) -> None:
def consume_ldap_relation_data(
self,
/,
relation: Optional[Relation] = None,
relation_id: Optional[int] = None,
) -> Optional[LdapProviderData]:
"""An API for the requirer charm to consume the LDAP related information in the application databag."""
relation = self.charm.model.get_relation(self._relation_name, relation_id)
if not relation:
relation = self.charm.model.get_relation(self._relation_name, relation_id)

if not relation:
return None
Expand All @@ -432,3 +445,55 @@ def consume_ldap_relation_data(
secret = self.charm.model.get_secret(id=secret_id)
provider_data["bind_password"] = secret.get_content().get("password")
return LdapProviderData(**provider_data) if provider_data else None

def _is_relation_active(self, relation: Relation) -> bool:
"""Whether the relation is active based on contained data."""
try:
_ = repr(relation.data)
return True
except (RuntimeError, ops.ModelError):
return False

@property
def relations(self) -> List[Relation]:
"""The list of Relation instances associated with this relation_name."""
return [
relation
for relation in self.charm.model.relations[self._relation_name]
if self._is_relation_active(relation)
]

def _ready_for_relation(self, relation: Relation) -> bool:
if not relation.app:
return False

return "urls" in relation.data[relation.app] and "bind_dn" in relation.data[relation.app]

def ready(self, relation_id: Optional[int] = None) -> bool:
"""Check if the resource has been created.
This function can be used to check if the Provider answered with data in the charm code
when outside an event callback.
Args:
relation_id (int, optional): When provided the check is done only for the relation id
provided, otherwise the check is done for all relations
Returns:
True or False
Raises:
IndexError: If relation_id is provided but that relation does not exist
"""
if relation_id is None:
return (
all(self._ready_for_relation(relation) for relation in self.relations)
if self.relations
else False
)

try:
relation = [relation for relation in self.relations if relation.id == relation_id][0]
return self._ready_for_relation(relation)
except IndexError:
raise IndexError(f"relation id {relation_id} cannot be accessed")
37 changes: 32 additions & 5 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@
DatabaseEndpointsChangedEvent,
DatabaseRequires,
)
from charms.glauth_k8s.v0.ldap import LdapProvider, LdapRequestedEvent
from charms.glauth_k8s.v0.ldap import (
LdapProvider,
LdapReadyEvent,
LdapRequestedEvent,
LdapRequirer,
)
from charms.glauth_utils.v0.glauth_auxiliary import AuxiliaryProvider, AuxiliaryRequestedEvent
from charms.grafana_k8s.v0.grafana_dashboard import GrafanaDashboardProvider
from charms.loki_k8s.v0.loki_push_api import LogProxyConsumer, PromtailDigestError
Expand All @@ -35,14 +40,15 @@
from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus
from ops.pebble import ChangeError

from configs import ConfigFile, DatabaseConfig, StartTLSConfig, pebble_layer
from configs import ConfigFile, DatabaseConfig, LdapServerConfig, StartTLSConfig, pebble_layer
from constants import (
CERTIFICATES_INTEGRATION_NAME,
CERTIFICATES_TRANSFER_INTEGRATION_NAME,
DATABASE_INTEGRATION_NAME,
GLAUTH_CONFIG_DIR,
GLAUTH_LDAP_PORT,
GRAFANA_DASHBOARD_INTEGRATION_NAME,
LDAP_CLIENT_INTEGRATION_NAME,
LOG_DIR,
LOG_FILE,
LOKI_API_PUSH_INTEGRATION_NAME,
Expand All @@ -59,11 +65,14 @@
from kubernetes_resource import ConfigMapResource, StatefulSetResource
from utils import (
after_config_updated,
backend_integration_not_exists,
backend_not_ready,
block_when,
container_not_connected,
database_not_ready,
integration_not_exists,
leader_unit,
service_not_ready,
tls_certificates_not_ready,
wait_when,
)
Expand Down Expand Up @@ -96,6 +105,12 @@ def __init__(self, *args: Any):
self._on_ldap_requested,
)

self.ldap_requirer = LdapRequirer(self, LDAP_CLIENT_INTEGRATION_NAME)
self.framework.observe(
self.ldap_requirer.on.ldap_ready,
self._on_ldap_ready,
)

self.auxiliary_provider = AuxiliaryProvider(self)
self.framework.observe(
self.auxiliary_provider.on.auxiliary_requested,
Expand Down Expand Up @@ -162,18 +177,19 @@ def _restart_glauth_service(self) -> None:
)

@block_when(
integration_not_exists(DATABASE_INTEGRATION_NAME),
backend_integration_not_exists,
integration_not_exists(CERTIFICATES_INTEGRATION_NAME),
)
@wait_when(
container_not_connected,
database_not_ready,
backend_not_ready,
tls_certificates_not_ready,
)
def _handle_event_update(self, event: HookEvent) -> None:
self.unit.status = MaintenanceStatus("Configuring GLAuth container")

self.config_file.database_config = DatabaseConfig.load(self.database_requirer)
self.config_file.ldap_servers_config = LdapServerConfig.load(self.ldap_requirer)

self._update_glauth_config()
self._container.add_layer(WORKLOAD_CONTAINER, pebble_layer, combine=True)
Expand Down Expand Up @@ -245,10 +261,18 @@ def __on_pebble_ready(self, event: PebbleReadyEvent) -> None:
self._container.make_dir(path=LOG_DIR, make_parents=True)
logger.debug(f"Created logging directory {LOG_DIR}")

try:
self._certs_integration.update_certificates()
except CertificatesError:
self.unit.status = BlockedStatus(
"Failed to update the TLS certificates, please check the logs"
)
return

self._handle_event_update(event)

@leader_unit
@wait_when(database_not_ready)
@wait_when(database_not_ready, service_not_ready)
def _on_ldap_requested(self, event: LdapRequestedEvent) -> None:
if not (requirer_data := event.data):
logger.error(f"The LDAP requirer {event.app.name} does not provide necessary data.")
Expand All @@ -265,6 +289,9 @@ def _on_ldap_requested(self, event: LdapRequestedEvent) -> None:
relation_id=event.relation.id,
)

def _on_ldap_ready(self, event: LdapReadyEvent) -> None:
self._handle_event_update(event)

@wait_when(database_not_ready)
def _on_auxiliary_requested(self, event: AuxiliaryRequestedEvent) -> None:
self.auxiliary_provider.update_relation_app_data(
Expand Down
28 changes: 22 additions & 6 deletions src/configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from pathlib import Path
from typing import Any, Mapping, Optional

from charms.glauth_k8s.v0.ldap import LdapProviderData, LdapRequirer
from jinja2 import Template
from ops.pebble import Layer

Expand Down Expand Up @@ -35,9 +36,9 @@ def dsn(self) -> str:
)

@classmethod
def load(cls, requirer: Any) -> "DatabaseConfig":
def load(cls, requirer: Any) -> Optional["DatabaseConfig"]:
if not (database_integrations := requirer.relations):
return DatabaseConfig()
return None

integration_id = database_integrations[0].id
integration_data = requirer.fetch_relation_data()[integration_id]
Expand All @@ -50,6 +51,18 @@ def load(cls, requirer: Any) -> "DatabaseConfig":
)


@dataclass
class LdapServerConfig:
ldap_server: Optional[LdapProviderData] = None

@classmethod
def load(cls, requirer: LdapRequirer) -> Optional["LdapServerConfig"]:
if not (ldap_servers := requirer.consume_ldap_relation_data()):
return None

return LdapServerConfig(ldap_servers)


@dataclass
class StartTLSConfig:
enabled: bool = True
Expand All @@ -68,6 +81,7 @@ class ConfigFile:
base_dn: Optional[str] = None
database_config: Optional[DatabaseConfig] = None
starttls_config: Optional[StartTLSConfig] = None
ldap_servers_config: Optional[LdapServerConfig] = None

@property
def content(self) -> str:
Expand All @@ -77,12 +91,14 @@ def render(self) -> str:
with open("templates/glauth.cfg.j2", mode="r") as file:
template = Template(file.read())

database_config = self.database_config or DatabaseConfig()
starttls_config = self.starttls_config or StartTLSConfig()
database_config = asdict(self.database_config) if self.database_config else None
ldap_servers_config = self.ldap_servers_config
starttls_config = asdict(self.starttls_config) if self.starttls_config else None
rendered = template.render(
base_dn=self.base_dn,
database=asdict(database_config),
starttls=asdict(starttls_config),
database=database_config,
ldap_servers=ldap_servers_config,
starttls=starttls_config,
)
return rendered

Expand Down
1 change: 1 addition & 0 deletions src/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from pathlib import Path, PurePath
from string import Template

LDAP_CLIENT_INTEGRATION_NAME = "ldap-client"
DATABASE_INTEGRATION_NAME = "pg-database"
LOKI_API_PUSH_INTEGRATION_NAME = "logging"
PROMETHEUS_SCRAPE_INTEGRATION_NAME = "metrics-endpoint"
Expand Down
Loading

0 comments on commit 8ab3dac

Please sign in to comment.