Skip to content
Merged
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: 2 additions & 0 deletions metadata.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ provides:
- service
versions: [v1]
__schema_source: https://raw.githubusercontent.com/canonical/operator-schemas/master/object-storage.yaml
s3-credentials:
interface: s3
metrics-endpoint:
interface: prometheus_scrape
grafana-dashboard:
Expand Down
1,950 changes: 983 additions & 967 deletions poetry.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ optional = true
[tool.poetry.group.charm.dependencies]
charmed-kubeflow-chisme = "^0.4.11"
cosl = "^0.0.50"
object-storage-charmlib = "^1.0.0"
ops = "^2.17.1"
owasp-logger = "^0.1.2"
charmed-service-mesh-helpers = "^0.5.0"
Expand Down
31 changes: 31 additions & 0 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

from components.owasp_logging import OWASPLoggerComponent
from components.pebble_component import MinIOInputs, MinIOPebbleService
from components.s3_provider_component import S3ProviderComponent, S3ProviderInputs
from components.service_component import KubernetesServicePatchComponent
from components.service_mesh_component import ServiceMeshComponent

Expand Down Expand Up @@ -114,6 +115,21 @@ def __init__(self, *args):
depends_on=[self.leadership_gate, self.service_patcher, self.minio_container],
)

self.s3_provider = self.charm_reconciler.add(
component=S3ProviderComponent(
charm=self,
name="relation:s3_credentials",
relation_name="s3-credentials",
is_optional=True,
inputs_getter=lambda: S3ProviderInputs(
ENDPOINT=self._get_minio_endpoint(),
ACCESS_KEY=str(self.model.config["access-key"]),
SECRET_KEY=secret_key,
),
),
depends_on=[self.leadership_gate, self.service_patcher, self.minio_container],
)

self.prometheus_provider = MetricsEndpointProvider(
charm=self,
jobs=[
Expand All @@ -140,6 +156,21 @@ def __init__(self, *args):

self.charm_reconciler.install_default_event_handlers()

def _get_minio_endpoint(self) -> str:
"""Build the in-cluster HTTP(S) URL for the MinIO service.

Returns:
str: Full endpoint URL, e.g. ``http://minio.my-model.svc.cluster.local:9000``
"""
protocol = (
"https"
if self.model.config.get("ssl-cert") and self.model.config.get("ssl-key")
else "http"
)
host = f"{self.model.app.name}.{self.model.name}.svc.cluster.local"
port = self.model.config["port"]
return f"{protocol}://{host}:{port}"

def _get_minio_args(self) -> List[str]:
"""
Build command line arguments for MinIO based on configuration mode.
Expand Down
105 changes: 105 additions & 0 deletions src/components/s3_provider_component.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Copyright 2026 Canonical Ltd.
# See LICENSE file for licensing details.
"""Component for interacting with S3-compatible object storage via the s3 interface.

This component uses the S3Provider interface, provided by the object-storage-charmlib library.
See: https://github.com/canonical/object-storage-integrator/tree/main/s3
"""

import dataclasses
import logging

from charmed_kubeflow_chisme.components import Component
from object_storage import (
PrematureDataAccessError,
S3Provider,
StorageConnectionInfoRequestedEvent,
)
from ops import ActiveStatus, BlockedStatus, StatusBase, WaitingStatus

logger = logging.getLogger(__name__)


@dataclasses.dataclass
class S3ProviderInputs:
"""Defines the required inputs for S3ProviderComponent."""

ENDPOINT: str
ACCESS_KEY: str
SECRET_KEY: str


class S3ProviderComponent(Component):
"""Component that manages an S3-compatible object storage relation.

Publishes endpoint and credentials to related requirers using the `s3` interface.
"""
Comment thread
Copilot marked this conversation as resolved.

def __init__(
self,
*args,
relation_name: str,
is_optional: bool = False,
**kwargs,
):
"""Initialise the component.

Args:
relation_name: Name of the S3 relation endpoint.
is_optional: When True, the component is Active even if no relation is present.
"""
super().__init__(*args, **kwargs)
self.relation_name = relation_name
self.is_optional = is_optional
self.s3_provider = S3Provider(
charm=self._charm,
relation_name=relation_name,
)
self._events_to_observe = [
self._charm.on[self.relation_name].relation_changed,
self._charm.on[self.relation_name].relation_broken,
self.s3_provider.on.storage_connection_info_requested,
]

def _configure_unit(self, event):
"""Execute everything this Component should do for every Unit."""
if not self._charm.unit.is_leader():
return

inputs: S3ProviderInputs = self._inputs_getter()
data = {
"endpoint": inputs.ENDPOINT,
"access-key": inputs.ACCESS_KEY,
"secret-key": inputs.SECRET_KEY,
Comment thread
mvlassis marked this conversation as resolved.
}

if isinstance(event, StorageConnectionInfoRequestedEvent):
relation_ids = [event.relation.id]
else:
relation_ids = list(self.s3_provider.fetch_relation_data().keys())

for relation_id in relation_ids:
try:
self.s3_provider.set_storage_connection_info(relation_id=relation_id, data=data)
except PrematureDataAccessError:
logger.warning("Relation %s not yet initialised, skipping.", relation_id)
Comment thread
NohaIhab marked this conversation as resolved.

def get_status(self) -> StatusBase:
"""Return the status of this component.

- Blocked: no relation present and component is not optional.
- Active: no relation present and component is optional.
- Waiting: relation is present but no requirer has initialised the protocol yet.
- Active: at least one relation is fully initialised.
"""
relations = self._charm.model.relations[self.relation_name]

if not relations:
if self.is_optional:
return ActiveStatus()
return BlockedStatus(f"Please add the missing relation: {self.relation_name}")

if not any(self.s3_provider.is_protocol_ready(relation) for relation in relations):
return WaitingStatus(f"Waiting for {self.relation_name} relation to be initialised")

return ActiveStatus()
Comment thread
NohaIhab marked this conversation as resolved.
2 changes: 1 addition & 1 deletion tests/integration/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ async def test_build_and_deploy(ops_test: OpsTest, request):
)


async def test_metrics_enpoint(ops_test: OpsTest):
async def test_metrics_endpoint(ops_test: OpsTest):
"""Test metrics_endpoints are defined in relation data bag and their accessibility.
This function gets all the metrics_endpoints from the relation data bag, checks if
they are available from the grafana-agent-k8s charm and finally compares them with the
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/test_charm_ambient.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ async def test_build_and_deploy(ops_test: OpsTest, request):
)


async def test_metrics_enpoint(ops_test: OpsTest):
async def test_metrics_endpoint(ops_test: OpsTest):
"""Test metrics_endpoints are defined in relation data bag and their accessibility.
This function gets all the metrics_endpoints from the relation data bag, checks if
they are available from the grafana-agent-k8s charm and finally compares them with the
Expand Down
76 changes: 76 additions & 0 deletions tests/unit/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -576,3 +576,79 @@ def test_service_mesh_get_status_error_handling(
harness.charm.service_mesh.component.get_status()

assert "Error validating raw policies" in str(exc_info.value)


@pytest.mark.parametrize(
"ssl_config,expected_protocol",
[
({}, "http"),
({"ssl-cert": "test-cert"}, "http"),
({"ssl-cert": "test-cert", "ssl-key": "test-key"}, "https"),
],
)
def test_get_minio_endpoint(
ssl_config, expected_protocol, harness, mock_kubernetes_service_patched
):
"""Test that _get_minio_endpoint returns the correct URL based on SSL config."""
# Arrange
harness.set_leader(True)
harness.update_config(ssl_config)

# Act
harness.begin()

# Assert
endpoint = harness.charm._get_minio_endpoint()
assert endpoint == f"{expected_protocol}://minio.{MODEL_NAME}.svc.cluster.local:9000"


def test_s3_credentials_relation(harness, mock_kubernetes_service_patched):
"""Test that the s3-credentials relation is populated with the correct connection info."""
# Arrange
harness.set_leader(True)
harness.update_config({"secret-key": "test-secret-key"})

rel_id = harness.add_relation("s3-credentials", "requirer-app")
harness.add_relation_unit(rel_id, "requirer-app/0")
# Simulate the requirer writing its schema version to initiate the protocol, see:
# https://github.com/canonical/object-storage-integrator/blob/54e63ec0d524b9f52644e2beeb3db0494c3749fd/lib/README.md#versioning-and-compatibility
harness.update_relation_data(rel_id, "requirer-app", {"version": "1"})

# Act
harness.begin_with_initial_hooks()

# Assert
assert harness.charm.model.unit.status == ActiveStatus("")
data = harness.get_relation_data(rel_id, "minio")
assert data["endpoint"] == f"http://minio.{MODEL_NAME}.svc.cluster.local:9000"
assert data["access-key"] == "minio"
assert data["secret-key"] == "test-secret-key"


def test_s3_credentials_relation_not_initialised(harness, mock_kubernetes_service_patched):
"""Test that when the s3-credentials relation is present but the requirer has not yet
initialised the protocol, the component returns WaitingStatus and no data is written.

This also covers the PrematureDataAccessError path: _configure_unit should catch the
error, log a warning, and leave the relation databag empty.
"""
# Arrange
harness.set_leader(True)

rel_id = harness.add_relation("s3-credentials", "requirer-app")
harness.add_relation_unit(rel_id, "requirer-app/0")
# Intentionally do NOT write {"version": "1"}, so is_protocol_ready() returns False
# and set_storage_connection_info raises PrematureDataAccessError.

# Act
harness.begin_with_initial_hooks()

# Assert: component status is Waiting, not Active
assert harness.charm.s3_provider.component.get_status() == WaitingStatus(
"Waiting for s3-credentials relation to be initialised"
)
# No connection info should have been written to the relation bag
data = harness.get_relation_data(rel_id, "minio")
assert "endpoint" not in data
assert "access-key" not in data
assert "secret-key" not in data
Loading