Integrate MON and Grafana 56/13556/27
authorPatricia Reinoso <patricia.reinoso@canonical.com>
Tue, 20 Jun 2023 15:23:47 +0000 (15:23 +0000)
committerbeierlm <mark.beierl@canonical.com>
Fri, 28 Jul 2023 14:06:36 +0000 (16:06 +0200)
Add actions for datasources in mon charm

create, list and delete prometheus datasources

requests are done using grafana API calls

grafana-url, grafana-admin, grafana-password does
not have default value. If they are not present in
config the charm is blocked.

Change-Id: Ia0138b8d3088654f65ea9d23a664619a4475d3d8
Signed-off-by: Patricia Reinoso <patricia.reinoso@canonical.com>
installers/charm/osm-mon/actions.yaml
installers/charm/osm-mon/config.yaml
installers/charm/osm-mon/requirements.txt
installers/charm/osm-mon/src/charm.py
installers/charm/osm-mon/src/grafana_datasource_handler.py [new file with mode: 0644]
installers/charm/osm-mon/tests/integration/test_charm.py
installers/charm/osm-mon/tests/integration/test_datasource_actions.py [new file with mode: 0644]
installers/charm/osm-mon/tests/unit/test_charm.py

index 0d73468..4f1a157 100644 (file)
 
 get-debug-mode-information:
   description: Get information to debug the container
+
+create-datasource:
+  description: Create a new prometheus datasource.
+  params:
+    name:
+      type: string
+      description: The name of the datasource.
+    url:
+      type: string
+      description: URL to prometheus.
+  required: [name, url]
+  additionalProperties: false
+
+list-datasources:
+  description: List datasources.
+  additionalProperties: false
+
+delete-datasource:
+  description: Delete a given datasource.
+  params:
+    name:
+      type: string
+      description: The name of the datasource.
+  required: [name]
+  additionalProperties: false
index cb2eb99..8f1d91f 100644 (file)
@@ -59,15 +59,12 @@ options:
   grafana-url:
     description: Grafana URL
     type: string
-    default: http://grafana:3000
   grafana-user:
     description: Grafana user
     type: string
-    default: admin
   grafana-password:
     description: Grafana password
     type: string
-    default: admin
   keystone-enabled:
     description: MON will use Keystone backend
     type: boolean
index 398d4ad..44e5b92 100644 (file)
@@ -20,4 +20,5 @@
 ops < 2.2
 lightkube
 lightkube-models
+requests
 # git+https://github.com/charmed-osm/config-validator/
index db72dfe..4587462 100755 (executable)
@@ -29,6 +29,7 @@ See more: https://charmhub.io/osm
 
 import logging
 from typing import Any, Dict
+from urllib.parse import urlparse
 
 from charms.data_platform_libs.v0.data_interfaces import DatabaseRequires
 from charms.kafka_k8s.v0.kafka import KafkaRequires, _KafkaAvailableEvent
@@ -47,6 +48,11 @@ from ops.framework import EventSource, StoredState
 from ops.main import main
 from ops.model import ActiveStatus, Container
 
+from grafana_datasource_handler import (
+    DatasourceConfig,
+    GrafanaConfig,
+    GrafanaDataSourceHandler,
+)
 from legacy_interfaces import KeystoneClient, PrometheusClient
 
 HOSTPATHS = [
@@ -161,6 +167,47 @@ class OsmMonCharm(CharmBase):
         }
         event.set_results(debug_info)
 
+    def _on_create_datasource_action(self, event: ActionEvent) -> None:
+        """Handler for the create-datasource action event."""
+        url = event.params["url"]
+        if not self._is_valid_url(url):
+            event.fail(f"Invalid datasource url '{url}'")
+            return
+        grafana_config = self._get_grafana_config()
+        datasource_config = DatasourceConfig(event.params["name"], url)
+        response = GrafanaDataSourceHandler.create_datasource(grafana_config, datasource_config)
+        logger.debug(response)
+        if response.is_success:
+            event.set_results(response.results)
+            return
+        event.fail(response.message)
+
+    def _on_list_datasources_action(self, event: ActionEvent) -> None:
+        """Handler for the list-datasource action event."""
+        grafana_config = self._get_grafana_config()
+        response = GrafanaDataSourceHandler.list_datasources(grafana_config)
+        logger.debug(response)
+        if response.is_success:
+            event.set_results(response.results)
+            return
+        event.fail(response.message)
+
+    def _on_delete_datasource_action(self, event: ActionEvent) -> None:
+        """Handler for the delete-datasource action event."""
+        datasource_name = event.params["name"]
+        grafana_config = self._get_grafana_config()
+        response = GrafanaDataSourceHandler.delete_datasource(grafana_config, datasource_name)
+        logger.debug(response)
+        if not response.is_success:
+            event.fail(response.message)
+
+    def _get_grafana_config(self) -> GrafanaConfig:
+        return GrafanaConfig(
+            self.config.get("grafana-user", ""),
+            self.config.get("grafana-password", ""),
+            self.config.get("grafana-url", ""),
+        )
+
     # ---------------------------------------------------------------------------
     #   Validation and configuration and more
     # ---------------------------------------------------------------------------
@@ -179,6 +226,9 @@ class OsmMonCharm(CharmBase):
             self.on["mongodb"].relation_broken: self._on_required_relation_broken,
             # Action events
             self.on.get_debug_mode_information_action: self._on_get_debug_mode_information_action,
+            self.on.create_datasource_action: self._on_create_datasource_action,
+            self.on.list_datasources_action: self._on_list_datasources_action,
+            self.on.delete_datasource_action: self._on_delete_datasource_action,
         }
         for relation in [self.on[rel_name] for rel_name in ["prometheus", "keystone"]]:
             event_handler_mapping[relation.relation_changed] = self._on_config_changed
@@ -200,6 +250,24 @@ class OsmMonCharm(CharmBase):
             CharmError: if charm configuration is invalid.
         """
         logger.debug("validating charm config")
+        missing_configs = []
+        grafana_configs = ["grafana-url", "grafana-user", "grafana-password"]
+        for config in grafana_configs:
+            if not self.config.get(config):
+                missing_configs.append(config)
+
+        if missing_configs:
+            config_str = ", ".join(missing_configs)
+            error_msg = f"need {config_str} config"
+            logger.warning(error_msg)
+            raise CharmError(error_msg)
+
+        grafana_url = self.config["grafana-url"]
+        if not self._is_valid_url(grafana_url):
+            raise CharmError(f"Invalid value for grafana-url config: '{grafana_url}'")
+
+    def _is_valid_url(self, url) -> bool:
+        return urlparse(url).hostname is not None
 
     def _check_relations(self) -> None:
         """Validate charm relations.
@@ -254,9 +322,9 @@ class OsmMonCharm(CharmBase):
             "OSMMON_PROMETHEUS_URL": f"http://{self.prometheus_client.hostname}:{self.prometheus_client.port}",
             "OSMMON_PROMETHEUS_USER": self.prometheus_client.user,
             "OSMMON_PROMETHEUS_PASSWORD": self.prometheus_client.password,
-            "OSMMON_GRAFANA_URL": self.config["grafana-url"],
-            "OSMMON_GRAFANA_USER": self.config["grafana-user"],
-            "OSMMON_GRAFANA_PASSWORD": self.config["grafana-password"],
+            "OSMMON_GRAFANA_URL": self.config.get("grafana-url", ""),
+            "OSMMON_GRAFANA_USER": self.config.get("grafana-user", ""),
+            "OSMMON_GRAFANA_PASSWORD": self.config.get("grafana-password", ""),
             "OSMMON_KEYSTONE_ENABLED": self.config["keystone-enabled"],
             "OSMMON_KEYSTONE_URL": self.keystone_client.host,
             "OSMMON_KEYSTONE_DOMAIN_NAME": self.keystone_client.user_domain_name,
diff --git a/installers/charm/osm-mon/src/grafana_datasource_handler.py b/installers/charm/osm-mon/src/grafana_datasource_handler.py
new file mode 100644 (file)
index 0000000..9cd6acb
--- /dev/null
@@ -0,0 +1,120 @@
+#!/usr/bin/env python3
+# Copyright 2022 Canonical Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+# For those usages not covered by the Apache License, Version 2.0 please
+# contact: legal@canonical.com
+#
+# To get in touch with the maintainers, please contact:
+# osm-charmers@lists.launchpad.net
+#
+#
+# Learn more at: https://juju.is/docs/sdk
+
+"""Module to handle Grafana datasource operations."""
+
+import json
+import logging
+from dataclasses import dataclass
+
+import requests
+from requests.auth import HTTPBasicAuth
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class GrafanaConfig:
+    """Values needed to make grafana API calls."""
+
+    user: str
+    password: str
+    url: str
+
+
+@dataclass
+class DatasourceConfig:
+    """Information about the datasource to create."""
+
+    name: str
+    url: str
+
+
+@dataclass
+class DatasourceResponse:
+    """Return value used by GrafanaDataSourceHandler operations."""
+
+    is_success: bool
+    message: str
+    results: dict
+
+
+class GrafanaDataSourceHandler:
+    """Handle grafana datasource pperations."""
+
+    @staticmethod
+    def create_datasource(
+        grafana_config: GrafanaConfig, datasource_config: DatasourceConfig
+    ) -> DatasourceResponse:
+        """Calls the Grafana API to create a new prometheus datasource."""
+        try:
+            auth = HTTPBasicAuth(grafana_config.user, grafana_config.password)
+            url = grafana_config.url + "/api/datasources"
+            datasource_data = {
+                "name": datasource_config.name,
+                "type": "prometheus",
+                "url": datasource_config.url,
+                "access": "proxy",
+                "readOnly": False,
+                "basicAuth": False,
+            }
+            response = requests.post(url, json=datasource_data, auth=auth)
+            response_content = response.json()
+            results = {"datasource-name": response_content.get("name")}
+            return DatasourceResponse(
+                response.ok, response_content.get("message"), results=results
+            )
+        except Exception as e:
+            logger.debug(f"Exception processing request for creating datasource: {e}")
+            return DatasourceResponse(False, str(e), results={})
+
+    @staticmethod
+    def list_datasources(grafana_config: GrafanaConfig) -> DatasourceResponse:
+        """Calls the Grafana API to get a list of datasources."""
+        try:
+            auth = HTTPBasicAuth(grafana_config.user, grafana_config.password)
+            url = grafana_config.url + "/api/datasources"
+            response = requests.get(url, auth=auth)
+            response_content = response.json()
+            results = {"datasources": json.dumps(response_content)}
+            message = response_content.get("message") if not response.ok else ""
+            return DatasourceResponse(response.ok, message=message, results=results)
+        except Exception as e:
+            logger.debug(f"Exception processing request to list datasources: {e}")
+            return DatasourceResponse(False, str(e), results={})
+
+    @staticmethod
+    def delete_datasource(
+        grafana_config: GrafanaConfig, datasource_name: str
+    ) -> DatasourceResponse:
+        """Calls the Grafana API to delete a given datasource by name."""
+        try:
+            auth = HTTPBasicAuth(grafana_config.user, grafana_config.password)
+            url = grafana_config.url + f"/api/datasources/name/{datasource_name}"
+            response = requests.delete(url, auth=auth)
+            response_content = response.json()
+            return DatasourceResponse(response.ok, response_content.get("message"), results={})
+        except Exception as e:
+            logger.debug(f"Exception processing request for deleting datasource: {e}")
+            return DatasourceResponse(False, str(e), results={})
index caf8ded..e14e191 100644 (file)
@@ -52,7 +52,7 @@ APPS = [KAFKA_APP, ZOOKEEPER_APP, KEYSTONE_APP, MONGO_DB_APP, MARIADB_APP, PROME
 
 
 @pytest.mark.abort_on_fail
-async def test_mon_is_deployed(ops_test: OpsTest):
+async def test_mon_and_other_charms_are_idle(ops_test: OpsTest):
     charm = await ops_test.build_charm(".")
     resources = {"mon-image": METADATA["resources"]["mon-image"]["upstream-source"]}
 
@@ -71,11 +71,40 @@ async def test_mon_is_deployed(ops_test: OpsTest):
     await ops_test.run(*shlex.split(cmd), check=True)
 
     async with ops_test.fast_forward():
-        await ops_test.model.wait_for_idle(
-            apps=APPS,
-        )
+        await ops_test.model.wait_for_idle(apps=APPS)
+
+
+@pytest.mark.abort_on_fail
+async def test_mon_is_blocked_due_to_missing_grafana_config(ops_test: OpsTest):
     assert ops_test.model.applications[MON_APP].status == "blocked"
     unit = ops_test.model.applications[MON_APP].units[0]
+    assert (
+        unit.workload_status_message == "need grafana-url, grafana-user, grafana-password config"
+    )
+
+    await ops_test.model.applications[MON_APP].set_config({"grafana-url": "new_value"})
+    async with ops_test.fast_forward():
+        await ops_test.model.wait_for_idle(apps=[MON_APP], status="blocked")
+    assert unit.workload_status_message == "need grafana-user, grafana-password config"
+
+    await ops_test.model.applications[MON_APP].set_config({"grafana-password": "new_value"})
+    async with ops_test.fast_forward():
+        await ops_test.model.wait_for_idle(apps=[MON_APP], status="blocked")
+    assert unit.workload_status_message == "need grafana-user config"
+
+    await ops_test.model.applications[MON_APP].set_config({"grafana-user": "new_value"})
+    async with ops_test.fast_forward():
+        await ops_test.model.wait_for_idle(apps=[MON_APP], status="blocked")
+
+    assert unit.workload_status_message == "Invalid value for grafana-url config: 'new_value'"
+    await ops_test.model.applications[MON_APP].set_config({"grafana-url": "http://valid:92"})
+
+
+@pytest.mark.abort_on_fail
+async def test_mon_is_blocked_due_to_missing_relations(ops_test: OpsTest):
+    async with ops_test.fast_forward():
+        await ops_test.model.wait_for_idle(apps=[MON_APP], status="blocked")
+    unit = ops_test.model.applications[MON_APP].units[0]
     assert unit.workload_status_message == "need kafka, mongodb, prometheus, keystone relations"
 
     logger.info("Adding relations for other components")
@@ -91,10 +120,7 @@ async def test_mon_is_deployed(ops_test: OpsTest):
     await ops_test.model.add_relation(MON_APP, PROMETHEUS_APP)
 
     async with ops_test.fast_forward():
-        await ops_test.model.wait_for_idle(
-            apps=APPS,
-            status="active",
-        )
+        await ops_test.model.wait_for_idle(apps=APPS, status="active")
 
 
 @pytest.mark.abort_on_fail
@@ -129,19 +155,13 @@ async def test_mon_blocks_without_relation(ops_test: OpsTest, relation_to_remove
         assert unit.workload_status_message == f"need {relation_to_remove} relation"
     await ops_test.model.add_relation(MON_APP, relation_to_remove)
     async with ops_test.fast_forward():
-        await ops_test.model.wait_for_idle(
-            apps=APPS,
-            status="active",
-        )
+        await ops_test.model.wait_for_idle(apps=APPS, status="active")
 
 
 @pytest.mark.abort_on_fail
 async def test_mon_action_debug_mode_disabled(ops_test: OpsTest):
     async with ops_test.fast_forward():
-        await ops_test.model.wait_for_idle(
-            apps=APPS,
-            status="active",
-        )
+        await ops_test.model.wait_for_idle(apps=APPS, status="active")
     logger.info("Running action 'get-debug-mode-information'")
     action = (
         await ops_test.model.applications[MON_APP]
@@ -187,9 +207,7 @@ async def test_mon_integration_vca(ops_test: OpsTest):
         ),
     )
     async with ops_test.fast_forward():
-        await ops_test.model.wait_for_idle(
-            apps=[VCA_APP],
-        )
+        await ops_test.model.wait_for_idle(apps=[VCA_APP])
     controllers = (Path.home() / ".local/share/juju/controllers.yaml").read_text()
     accounts = (Path.home() / ".local/share/juju/accounts.yaml").read_text()
     public_key = (Path.home() / ".local/share/juju/ssh/juju_id_rsa.pub").read_text()
@@ -202,13 +220,7 @@ async def test_mon_integration_vca(ops_test: OpsTest):
         }
     )
     async with ops_test.fast_forward():
-        await ops_test.model.wait_for_idle(
-            apps=APPS + [VCA_APP],
-            status="active",
-        )
+        await ops_test.model.wait_for_idle(apps=APPS + [VCA_APP], status="active")
     await ops_test.model.add_relation(MON_APP, VCA_APP)
     async with ops_test.fast_forward():
-        await ops_test.model.wait_for_idle(
-            apps=APPS + [VCA_APP],
-            status="active",
-        )
+        await ops_test.model.wait_for_idle(apps=APPS + [VCA_APP], status="active")
diff --git a/installers/charm/osm-mon/tests/integration/test_datasource_actions.py b/installers/charm/osm-mon/tests/integration/test_datasource_actions.py
new file mode 100644 (file)
index 0000000..71d0706
--- /dev/null
@@ -0,0 +1,272 @@
+#!/usr/bin/env python3
+# Copyright 2022 Canonical Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+# For those usages not covered by the Apache License, Version 2.0 please
+# contact: legal@canonical.com
+#
+# To get in touch with the maintainers, please contact:
+# osm-charmers@lists.launchpad.net
+#
+# Learn more about testing at: https://juju.is/docs/sdk/testing
+
+import asyncio
+import json
+import logging
+from pathlib import Path
+
+import pytest
+import yaml
+from pytest_operator.plugin import OpsTest
+
+logger = logging.getLogger(__name__)
+
+METADATA = yaml.safe_load(Path("./metadata.yaml").read_text())
+MON_APP = METADATA["name"]
+GRAFANA_CHARM = "grafana-k8s"
+GRAFANA_APP = "grafana"
+DS_NAME = "osm_prometheus"
+DS_URL = "http://prometheus:9090"
+
+
+async def _run_action_create_datasource(ops_test: OpsTest, name: str, url: str):
+    action = (
+        await ops_test.model.applications[MON_APP]
+        .units[0]
+        .run_action(
+            action_name="create-datasource",
+            name=name,
+            url=url,
+        )
+    )
+    return await action.wait()
+
+
+async def _run_action_list_datasources(ops_test: OpsTest):
+    action = (
+        await ops_test.model.applications[MON_APP]
+        .units[0]
+        .run_action(action_name="list-datasources")
+    )
+    return await action.wait()
+
+
+async def _run_action_delete_datasource(ops_test: OpsTest, name: str):
+    action = (
+        await ops_test.model.applications[MON_APP]
+        .units[0]
+        .run_action(action_name="delete-datasource", name=name)
+    )
+    return await action.wait()
+
+
+@pytest.mark.abort_on_fail
+async def test_mon_and_grafana_are_idle(ops_test: OpsTest):
+    charm = await ops_test.build_charm(".")
+    resources = {"mon-image": METADATA["resources"]["mon-image"]["upstream-source"]}
+
+    await asyncio.gather(
+        ops_test.model.deploy(
+            charm, resources=resources, application_name=MON_APP, series="jammy"
+        ),
+        ops_test.model.deploy(GRAFANA_CHARM, application_name=GRAFANA_APP, channel="stable"),
+    )
+    async with ops_test.fast_forward():
+        await ops_test.model.wait_for_idle(apps=[GRAFANA_APP], status="active")
+        await ops_test.model.wait_for_idle(apps=[MON_APP], status="blocked")
+
+
+@pytest.mark.abort_on_fail
+async def test_mon_cannot_create_datasource_grafana_url_is_not_set(ops_test: OpsTest):
+    action = await _run_action_create_datasource(ops_test, DS_NAME, DS_URL)
+    assert action.status == "failed"
+    assert "Invalid URL" in action.message
+
+
+@pytest.mark.abort_on_fail
+async def test_mon_cannot_list_datasources_grafana_url_is_not_set(ops_test: OpsTest):
+    action = await _run_action_list_datasources(ops_test)
+    assert action.status == "failed"
+    assert "Invalid URL" in action.message
+
+
+@pytest.mark.abort_on_fail
+async def test_mon_cannot_delete_datasource_grafana_url_is_not_set(ops_test: OpsTest):
+    action = await _run_action_delete_datasource(ops_test, "prometheus")
+    assert action.status == "failed"
+    assert "Invalid URL" in action.message
+
+
+@pytest.mark.abort_on_fail
+async def test_mon_cannot_create_datasource_due_to_invalid_grafana_password(ops_test: OpsTest):
+    await ops_test.model.applications[MON_APP].set_config(
+        {"grafana-url": f"http://{GRAFANA_APP}:3000", "grafana-user": "admin"}
+    )
+    async with ops_test.fast_forward():
+        await ops_test.model.wait_for_idle(apps=[MON_APP], status="blocked")
+
+    action = await _run_action_create_datasource(ops_test, DS_NAME, DS_URL)
+    assert action.status == "failed"
+    assert action.message == "invalid username or password"
+
+
+@pytest.mark.abort_on_fail
+async def test_mon_cannot_create_datasource_invalid_url_fails(ops_test: OpsTest):
+    await ops_test.model.applications[MON_APP].set_config(
+        {"grafana-url": f"http://{GRAFANA_APP}:3000"}
+    )
+    async with ops_test.fast_forward():
+        await ops_test.model.wait_for_idle(apps=[MON_APP], status="blocked")
+
+    action = await _run_action_create_datasource(ops_test, DS_NAME, "prometheus:9090")
+    assert action.status == "failed"
+    assert action.message == "Invalid datasource url 'prometheus:9090'"
+
+
+async def _get_grafana_admin_password(ops_test: OpsTest):
+    action = (
+        await ops_test.model.applications[GRAFANA_APP].units[0].run_action("get-admin-password")
+    )
+    admin_password = (await action.wait()).results["admin-password"]
+    logger.info(f"Password obtained from {GRAFANA_APP} : {admin_password}")
+    assert admin_password != "Admin password has been changed by an administrator"
+    return admin_password
+
+
+@pytest.mark.abort_on_fail
+async def test_grafana_password_is_set_in_mon_config(ops_test: OpsTest):
+    admin_password = await _get_grafana_admin_password(ops_test)
+
+    default_password = None
+    grafana_password_in_mon = (
+        (await ops_test.model.applications[MON_APP].get_config())
+        .get("grafana-password")
+        .get("value")
+    )
+    assert grafana_password_in_mon == default_password
+
+    await ops_test.model.applications[MON_APP].set_config({"grafana-password": admin_password})
+
+    async with ops_test.fast_forward():
+        await ops_test.model.wait_for_idle(apps=[GRAFANA_APP], status="active")
+        await ops_test.model.wait_for_idle(apps=[MON_APP], status="blocked")
+
+    grafana_password_in_mon = (
+        (await ops_test.model.applications[MON_APP].get_config())
+        .get("grafana-password")
+        .get("value")
+    )
+    assert grafana_password_in_mon == admin_password
+
+
+@pytest.mark.abort_on_fail
+async def test_mon_create_datasource_successfully(ops_test: OpsTest):
+    action = await _run_action_create_datasource(ops_test, DS_NAME, DS_URL)
+    assert action.status == "completed"
+    assert action.results["datasource-name"] == DS_NAME
+
+
+@pytest.mark.abort_on_fail
+async def test_mon_list_datasources_returns_osm_prometheus(ops_test: OpsTest):
+    action = await _run_action_list_datasources(ops_test)
+    assert action.status == "completed"
+    datasources = json.loads(action.results.get("datasources"))
+    assert len(datasources) == 1
+    assert datasources[0].get("name") == DS_NAME
+    assert datasources[0].get("type") == "prometheus"
+
+
+@pytest.mark.abort_on_fail
+async def test_mon_create_datasource_that_already_exists_returns_error_message(ops_test: OpsTest):
+    action = await _run_action_create_datasource(ops_test, DS_NAME, DS_URL)
+    assert action.status == "failed"
+    assert action.message == "data source with the same name already exists"
+
+
+@pytest.mark.abort_on_fail
+async def test_mon_delete_existing_datasource_returns_success_message(ops_test: OpsTest):
+    action = await _run_action_delete_datasource(ops_test, DS_NAME)
+    assert action.status == "completed"
+
+
+@pytest.mark.abort_on_fail
+async def test_mon_list_datasources_is_empty(ops_test: OpsTest):
+    action = await _run_action_list_datasources(ops_test)
+    assert action.status == "completed"
+    datasources = json.loads(action.results.get("datasources"))
+    assert len(datasources) == 0
+
+
+@pytest.mark.abort_on_fail
+async def test_mon_delete_non_existing_datasource_returns_error_message(ops_test: OpsTest):
+    action = await _run_action_delete_datasource(ops_test, DS_NAME)
+    assert action.status == "failed"
+    assert action.message == "Data source not found"
+
+
+@pytest.mark.abort_on_fail
+async def test_mon_create_datasource_incorrect_grafana_host_fails(ops_test: OpsTest):
+    await ops_test.model.applications[MON_APP].set_config(
+        {"grafana-url": f"http://{GRAFANA_APP}-k8s:3000"}
+    )
+    async with ops_test.fast_forward():
+        await ops_test.model.wait_for_idle(apps=[MON_APP], status="blocked")
+    action = await _run_action_create_datasource(ops_test, DS_NAME, DS_URL)
+    assert action.status == "failed"
+    assert f"Failed to resolve '{GRAFANA_APP}-k8s'" in action.message
+
+
+@pytest.mark.abort_on_fail
+async def test_mon_list_datasources_incorrect_grafana_host_fails(ops_test: OpsTest):
+    action = await _run_action_list_datasources(ops_test)
+    assert action.status == "failed"
+    assert f"Failed to resolve '{GRAFANA_APP}-k8s'" in action.message
+
+
+@pytest.mark.abort_on_fail
+async def test_mon_delete_datasource_incorrect_grafana_host_fails(ops_test: OpsTest):
+    action = await _run_action_delete_datasource(ops_test, DS_NAME)
+    assert action.status == "failed"
+    assert f"Failed to resolve '{GRAFANA_APP}-k8s'" in action.message
+
+
+@pytest.mark.abort_on_fail
+async def test_mon_incorrect_grafana_port_returns_timeout_message(ops_test: OpsTest):
+    await ops_test.model.applications[MON_APP].set_config(
+        {"grafana-url": f"http://{GRAFANA_APP}:3001"}
+    )
+    async with ops_test.fast_forward():
+        await ops_test.model.wait_for_idle(apps=[MON_APP], status="blocked")
+    action = await _run_action_create_datasource(ops_test, DS_NAME, DS_URL)
+    assert action.status == "failed"
+    assert f"Connection to {GRAFANA_APP} timed out" in action.message
+
+
+@pytest.mark.abort_on_fail
+async def test_mon_list_datasources_incorrect_user_fails(ops_test: OpsTest):
+    await ops_test.model.applications[MON_APP].set_config(
+        {"grafana-url": f"http://{GRAFANA_APP}:3000", "grafana-user": "some_user"}
+    )
+    async with ops_test.fast_forward():
+        await ops_test.model.wait_for_idle(apps=[MON_APP], status="blocked")
+    action = await _run_action_list_datasources(ops_test)
+    assert action.status == "failed"
+    assert action.message == "invalid username or password"
+
+
+@pytest.mark.abort_on_fail
+async def test_mon_delete_datasource_incorrect_user_fails(ops_test: OpsTest):
+    action = await _run_action_delete_datasource(ops_test, DS_NAME)
+    assert action.status == "failed"
+    assert action.message == "invalid username or password"
index 33598fe..441eddb 100644 (file)
@@ -42,7 +42,27 @@ def harness(mocker: MockerFixture):
     harness.cleanup()
 
 
+def _set_grafana_config(harness: Harness):
+    harness.update_config(
+        {
+            "grafana-url": "http://prometheus:1234",
+            "grafana-user": "user",
+            "grafana-password": "password",
+        }
+    )
+
+
+def test_default_grafana_config_is_invalid_charm_is_blocked(harness: Harness):
+    harness.charm.on.config_changed.emit()
+    assert type(harness.charm.unit.status) == BlockedStatus
+    assert (
+        "need grafana-url, grafana-user, grafana-password config"
+        == harness.charm.unit.status.message
+    )
+
+
 def test_missing_relations(harness: Harness):
+    _set_grafana_config(harness)
     harness.charm.on.config_changed.emit()
     assert type(harness.charm.unit.status) == BlockedStatus
     assert all(
@@ -51,12 +71,48 @@ def test_missing_relations(harness: Harness):
     )
 
 
+def test_grafana_url_without_schema_block_status(harness: Harness):
+    harness.update_config(
+        {"grafana-url": "foo.com", "grafana-user": "user", "grafana-password": "password"}
+    )
+    assert type(harness.charm.unit.status) == BlockedStatus
+    assert "Invalid value for grafana-url config: 'foo.com'" == harness.charm.unit.status.message
+
+
+def test_grafana_url_with_port_without_schema_block_status(harness: Harness):
+    harness.update_config(
+        {"grafana-url": "foo.com:9090", "grafana-user": "user", "grafana-password": "password"}
+    )
+    assert type(harness.charm.unit.status) == BlockedStatus
+    assert (
+        "Invalid value for grafana-url config: 'foo.com:9090'" == harness.charm.unit.status.message
+    )
+
+
+def test_grafana_url_without_port_is_valid(harness: Harness):
+    _add_relations(harness)
+    harness.update_config(
+        {"grafana-url": "http://foo", "grafana-user": "user", "grafana-password": "password"}
+    )
+    assert harness.charm.unit.status == ActiveStatus()
+
+
+def test_grafana_url_with_port_is_valid(harness: Harness):
+    _add_relations(harness)
+    harness.update_config(
+        {"grafana-url": "http://foo:90", "grafana-user": "user", "grafana-password": "password"}
+    )
+    assert harness.charm.unit.status == ActiveStatus()
+
+
 def test_ready(harness: Harness):
+    _set_grafana_config(harness)
     _add_relations(harness)
     assert harness.charm.unit.status == ActiveStatus()
 
 
 def test_container_stops_after_relation_broken(harness: Harness):
+    _set_grafana_config(harness)
     harness.charm.on[container_name].pebble_ready.emit(container_name)
     container = harness.charm.unit.get_container(container_name)
     relation_ids = _add_relations(harness)