Skip to content
Snippets Groups Projects
Commit 90d10f5d authored by sousaedu's avatar sousaedu Committed by Mark Beierl
Browse files

Adding Prometheus Mysqld Exporter Charm


Change-Id: I4f4d99613b5cdb04c450359bc8beff52ffe8cec4
Signed-off-by: default avatarsousaedu <eduardo.sousa@canonical.com>
parent b025f308
No related branches found
No related tags found
No related merge requests found
Showing
with 2973 additions and 0 deletions
# Copyright 2021 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
##
venv
.vscode
build
grafana.charm
.coverage
.stestr
cover
# Copyright 2021 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
##
---
extends: default
yaml-files:
- "*.yaml"
- "*.yml"
- ".yamllint"
ignore: |
.tox
build/
<!-- Copyright 2021 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 -->
# Prometheus Mysql Exporter operator Charm for Kubernetes
## Requirements
# Copyright 2021 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
##
options:
ingress_whitelist_source_range:
type: string
description: |
A comma-separated list of CIDRs to store in the
ingress.kubernetes.io/whitelist-source-range annotation.
This can be used to lock down access to
Keystone based on source IP address.
default: ""
tls_secret_name:
type: string
description: TLS Secret name
default: ""
site_url:
type: string
description: Ingress URL
default: ""
This diff is collapsed.
# Copyright 2021 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
##
name: mysqld-exporter
summary: OSM Prometheus Mysql Exporter
description: |
A CAAS charm to deploy OSM's Prometheus Mysql Exporter.
series:
- kubernetes
tags:
- kubernetes
- osm
- prometheus
- mysql-exporter
min-juju-version: 2.8.0
deployment:
type: stateless
service: cluster
resources:
image:
type: oci-image
description: Image of mysqld-exporter
upstream-source: "bitnami/mysqld-exporter:latest"
provides:
prometheus-scrape:
interface: prometheus
grafana-dashboard:
interface: grafana-dashboard
requires:
mysql:
interface: mysql
# Copyright 2021 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
##
ops
git+https://github.com/juju-solutions/resource-oci-image/@c5778285d332edf3d9a538f9d0c06154b7ec1b0b#egg=oci-image
#!/usr/bin/env python3
# Copyright 2021 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
##
import logging
from pathlib import Path
from typing import Dict, List, NoReturn
from urllib.parse import urlparse
from ops.charm import CharmBase
from ops.framework import EventBase, StoredState
from ops.main import main
from ops.model import ActiveStatus, Application, BlockedStatus, MaintenanceStatus, Unit
from oci_image import OCIImageResource, OCIImageResourceError
from pod_spec import make_pod_spec
logger = logging.getLogger(__name__)
MYSQLD_EXPORTER_PORT = 9104
class RelationsMissing(Exception):
def __init__(self, missing_relations: List):
self.message = ""
if missing_relations and isinstance(missing_relations, list):
self.message += f'Waiting for {", ".join(missing_relations)} relation'
if "," in self.message:
self.message += "s"
class RelationDefinition:
def __init__(self, relation_name: str, keys: List, source_type):
if source_type != Application and source_type != Unit:
raise TypeError(
"source_type should be ops.model.Application or ops.model.Unit"
)
self.relation_name = relation_name
self.keys = keys
self.source_type = source_type
def check_missing_relation_data(
data: Dict,
expected_relations_data: List[RelationDefinition],
):
missing_relations = []
for relation_data in expected_relations_data:
if not all(
f"{relation_data.relation_name}_{k}" in data for k in relation_data.keys
):
missing_relations.append(relation_data.relation_name)
if missing_relations:
raise RelationsMissing(missing_relations)
def get_relation_data(
charm: CharmBase,
relation_data: RelationDefinition,
) -> Dict:
data = {}
relation = charm.model.get_relation(relation_data.relation_name)
if relation:
self_app_unit = (
charm.app if relation_data.source_type == Application else charm.unit
)
expected_type = relation_data.source_type
for app_unit in relation.data:
if app_unit != self_app_unit and isinstance(app_unit, expected_type):
if all(k in relation.data[app_unit] for k in relation_data.keys):
for k in relation_data.keys:
data[f"{relation_data.relation_name}_{k}"] = relation.data[
app_unit
].get(k)
break
return data
class MysqldExporterCharm(CharmBase):
"""Mysqld Exporter Charm."""
state = StoredState()
def __init__(self, *args) -> NoReturn:
"""Mysqld Exporter Charm constructor."""
super().__init__(*args)
# Internal state initialization
self.state.set_default(pod_spec=None)
self.port = MYSQLD_EXPORTER_PORT
self.image = OCIImageResource(self, "image")
# Registering regular events
self.framework.observe(self.on.start, self.configure_pod)
self.framework.observe(self.on.config_changed, self.configure_pod)
# Registering required relation events
self.framework.observe(self.on.mysql_relation_changed, self.configure_pod)
# Registering required relation departed events
self.framework.observe(self.on.mysql_relation_departed, self.configure_pod)
# Registering provided relation events
self.framework.observe(
self.on.prometheus_scrape_relation_joined, self._publish_scrape_info
)
self.framework.observe(
self.on.grafana_dashboard_relation_joined, self._publish_dashboard_info
)
def _publish_scrape_info(self, event: EventBase) -> NoReturn:
"""Publishes scrape information.
Args:
event (EventBase): Exporter relation event.
"""
rel_data = {
"hostname": urlparse(self.model.config["site_url"]).hostname
if self.model.config["site_url"]
else self.model.app.name,
"port": "80"
if self.model.config["site_url"]
else str(MYSQLD_EXPORTER_PORT),
"metrics_path": "/metrics",
"scrape_interval": "30s",
"scrape_timeout": "15s",
}
for k, v in rel_data.items():
event.relation.data[self.unit][k] = v
def _publish_dashboard_info(self, event: EventBase) -> NoReturn:
"""Publishes dashboard information.
Args:
event (EventBase): Exporter relation event.
"""
rel_data = {
"name": "osm-mysql",
"dashboard": Path("files/mysql_exporter_dashboard.json").read_text(),
}
for k, v in rel_data.items():
event.relation.data[self.unit][k] = v
@property
def relations_requirements(self):
return [
RelationDefinition(
"mysql", ["host", "port", "user", "password", "root_password"], Unit
)
]
def get_relation_state(self):
relation_state = {}
for relation_requirements in self.relations_requirements:
data = get_relation_data(self, relation_requirements)
relation_state = {**relation_state, **data}
check_missing_relation_data(relation_state, self.relations_requirements)
return relation_state
def configure_pod(self, _=None) -> NoReturn:
"""Assemble the pod spec and apply it, if possible.
Args:
event (EventBase): Hook or Relation event that started the
function.
"""
if not self.unit.is_leader():
self.unit.status = ActiveStatus("ready")
return
relation_state = None
try:
relation_state = self.get_relation_state()
except RelationsMissing as exc:
logger.exception("Relation missing error")
self.unit.status = BlockedStatus(exc.message)
return
self.unit.status = MaintenanceStatus("Assembling pod spec")
# Fetch image information
try:
self.unit.status = MaintenanceStatus("Fetching image information")
image_info = self.image.fetch()
except OCIImageResourceError:
self.unit.status = BlockedStatus("Error fetching image information")
return
try:
pod_spec = make_pod_spec(
image_info,
self.model.config,
relation_state,
self.model.app.name,
self.port,
)
except ValueError as exc:
logger.exception("Config/Relation data validation error")
self.unit.status = BlockedStatus(str(exc))
return
if self.state.pod_spec != pod_spec:
self.model.pod.set_spec(pod_spec)
self.state.pod_spec = pod_spec
self.unit.status = ActiveStatus("ready")
if __name__ == "__main__":
main(MysqldExporterCharm)
#!/usr/bin/env python3
# Copyright 2021 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
##
import logging
from ipaddress import ip_network
from typing import Any, Dict, List
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
def _validate_ip_network(network: str) -> bool:
"""Validate IP network.
Args:
network (str): IP network range.
Returns:
bool: True if valid, false otherwise.
"""
if not network:
return True
try:
ip_network(network)
except ValueError:
return False
return True
def _validate_data(config_data: Dict[str, Any], relation_data: Dict[str, Any]) -> bool:
"""Validates passed information.
Args:
config_data (Dict[str, Any]): configuration information.
relation_data (Dict[str, Any]): relation information
Raises:
ValueError: when config and/or relation data is not valid.
"""
config_validators = {
"site_url": lambda value, _: isinstance(value, str)
if value is not None
else True,
"ingress_whitelist_source_range": lambda value, _: _validate_ip_network(value),
"tls_secret_name": lambda value, _: isinstance(value, str)
if value is not None
else True,
}
relation_validators = {
"mysql_host": lambda value, _: isinstance(value, str) and len(value) > 0,
"mysql_port": lambda value, _: isinstance(value, str) and int(value) > 0,
"mysql_user": lambda value, _: isinstance(value, str) and len(value) > 0,
"mysql_password": lambda value, _: isinstance(value, str) and len(value) > 0,
"mysql_root_password": lambda value, _: isinstance(value, str)
and len(value) > 0,
}
problems = []
for key, validator in config_validators.items():
valid = validator(config_data.get(key), config_data)
if not valid:
problems.append(key)
for key, validator in relation_validators.items():
valid = validator(relation_data.get(key), relation_data)
if not valid:
problems.append(key)
if len(problems) > 0:
raise ValueError("Errors found in: {}".format(", ".join(problems)))
return True
def _make_pod_ports(port: int) -> List[Dict[str, Any]]:
"""Generate pod ports details.
Args:
port (int): port to expose.
Returns:
List[Dict[str, Any]]: pod port details.
"""
return [{"name": "mysqld-exporter", "containerPort": port, "protocol": "TCP"}]
def _make_pod_envconfig(
config: Dict[str, Any], relation_state: Dict[str, Any]
) -> Dict[str, Any]:
"""Generate pod environment configuration.
Args:
config (Dict[str, Any]): configuration information.
relation_state (Dict[str, Any]): relation state information.
Returns:
Dict[str, Any]: pod environment configuration.
"""
envconfig = {
"DATA_SOURCE_NAME": "root:{mysql_root_password}@({mysql_host}:{mysql_port})/".format(
**relation_state
)
}
return envconfig
def _make_pod_ingress_resources(
config: Dict[str, Any], app_name: str, port: int
) -> List[Dict[str, Any]]:
"""Generate pod ingress resources.
Args:
config (Dict[str, Any]): configuration information.
app_name (str): application name.
port (int): port to expose.
Returns:
List[Dict[str, Any]]: pod ingress resources.
"""
site_url = config.get("site_url")
if not site_url:
return
parsed = urlparse(site_url)
if not parsed.scheme.startswith("http"):
return
ingress_whitelist_source_range = config["ingress_whitelist_source_range"]
annotations = {}
if ingress_whitelist_source_range:
annotations[
"nginx.ingress.kubernetes.io/whitelist-source-range"
] = ingress_whitelist_source_range
ingress_spec_tls = None
if parsed.scheme == "https":
ingress_spec_tls = [{"hosts": [parsed.hostname]}]
tls_secret_name = config["tls_secret_name"]
if tls_secret_name:
ingress_spec_tls[0]["secretName"] = tls_secret_name
else:
annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
ingress = {
"name": "{}-ingress".format(app_name),
"annotations": annotations,
"spec": {
"rules": [
{
"host": parsed.hostname,
"http": {
"paths": [
{
"path": "/",
"backend": {
"serviceName": app_name,
"servicePort": port,
},
}
]
},
}
]
},
}
if ingress_spec_tls:
ingress["spec"]["tls"] = ingress_spec_tls
return [ingress]
def _make_readiness_probe(port: int) -> Dict[str, Any]:
"""Generate readiness probe.
Args:
port (int): service port.
Returns:
Dict[str, Any]: readiness probe.
"""
return {
"httpGet": {
"path": "/api/health",
"port": port,
},
"initialDelaySeconds": 10,
"periodSeconds": 10,
"timeoutSeconds": 5,
"successThreshold": 1,
"failureThreshold": 3,
}
def _make_liveness_probe(port: int) -> Dict[str, Any]:
"""Generate liveness probe.
Args:
port (int): service port.
Returns:
Dict[str, Any]: liveness probe.
"""
return {
"httpGet": {
"path": "/api/health",
"port": port,
},
"initialDelaySeconds": 60,
"timeoutSeconds": 30,
"failureThreshold": 10,
}
def make_pod_spec(
image_info: Dict[str, str],
config: Dict[str, Any],
relation_state: Dict[str, Any],
app_name: str = "mysqld-exporter",
port: int = 9104,
) -> Dict[str, Any]:
"""Generate the pod spec information.
Args:
image_info (Dict[str, str]): Object provided by
OCIImageResource("image").fetch().
config (Dict[str, Any]): Configuration information.
relation_state (Dict[str, Any]): Relation state information.
app_name (str, optional): Application name. Defaults to "ro".
port (int, optional): Port for the container. Defaults to 9090.
Returns:
Dict[str, Any]: Pod spec dictionary for the charm.
"""
if not image_info:
return None
_validate_data(config, relation_state)
ports = _make_pod_ports(port)
env_config = _make_pod_envconfig(config, relation_state)
readiness_probe = _make_readiness_probe(port)
liveness_probe = _make_liveness_probe(port)
ingress_resources = _make_pod_ingress_resources(config, app_name, port)
return {
"version": 3,
"containers": [
{
"name": app_name,
"imageDetails": image_info,
"imagePullPolicy": "Always",
"ports": ports,
"envConfig": env_config,
"kubernetes": {
"readinessProbe": readiness_probe,
"livenessProbe": liveness_probe,
},
}
],
"kubernetesResources": {
"ingressResources": ingress_resources or [],
},
}
#!/usr/bin/env python3
# Copyright 2021 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
##
"""Init mocking for unit tests."""
import sys
import mock
sys.path.append("src")
oci_image = mock.MagicMock()
sys.modules["oci_image"] = oci_image
#!/usr/bin/env python3
# Copyright 2021 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
##
from typing import NoReturn
import unittest
from ops.model import BlockedStatus
from ops.testing import Harness
from charm import MysqldExporterCharm
class TestCharm(unittest.TestCase):
"""Mysql Exporter Charm unit tests."""
def setUp(self) -> NoReturn:
"""Test setup"""
self.harness = Harness(MysqldExporterCharm)
self.harness.set_leader(is_leader=True)
self.harness.begin()
def test_on_start_without_relations(self) -> NoReturn:
"""Test installation without any relation."""
self.harness.charm.on.start.emit()
# Verifying status
self.assertIsInstance(self.harness.charm.unit.status, BlockedStatus)
# Verifying status message
self.assertGreater(len(self.harness.charm.unit.status.message), 0)
self.assertTrue(
self.harness.charm.unit.status.message.startswith("Waiting for ")
)
self.assertIn("mysql", self.harness.charm.unit.status.message)
self.assertTrue(self.harness.charm.unit.status.message.endswith(" relation"))
def test_on_start_with_relations_without_http(self) -> NoReturn:
"""Test deployment."""
expected_result = {
"version": 3,
"containers": [
{
"name": "mysqld-exporter",
"imageDetails": self.harness.charm.image.fetch(),
"imagePullPolicy": "Always",
"ports": [
{
"name": "mysqld-exporter",
"containerPort": 9104,
"protocol": "TCP",
}
],
"envConfig": {"DATA_SOURCE_NAME": "root:rootpw@(mysql:3306)/"},
"kubernetes": {
"readinessProbe": {
"httpGet": {
"path": "/api/health",
"port": 9104,
},
"initialDelaySeconds": 10,
"periodSeconds": 10,
"timeoutSeconds": 5,
"successThreshold": 1,
"failureThreshold": 3,
},
"livenessProbe": {
"httpGet": {
"path": "/api/health",
"port": 9104,
},
"initialDelaySeconds": 60,
"timeoutSeconds": 30,
"failureThreshold": 10,
},
},
},
],
"kubernetesResources": {"ingressResources": []},
}
self.harness.charm.on.start.emit()
# Initializing the mysql relation
relation_id = self.harness.add_relation("mysql", "mysql")
self.harness.add_relation_unit(relation_id, "mysql/0")
self.harness.update_relation_data(
relation_id,
"mysql/0",
{
"host": "mysql",
"port": "3306",
"user": "mano",
"password": "manopw",
"root_password": "rootpw",
},
)
# Verifying status
self.assertNotIsInstance(self.harness.charm.unit.status, BlockedStatus)
pod_spec, _ = self.harness.get_pod_spec()
self.assertDictEqual(expected_result, pod_spec)
def test_ingress_resources_with_http(self) -> NoReturn:
"""Test ingress resources with HTTP."""
expected_result = {
"version": 3,
"containers": [
{
"name": "mysqld-exporter",
"imageDetails": self.harness.charm.image.fetch(),
"imagePullPolicy": "Always",
"ports": [
{
"name": "mysqld-exporter",
"containerPort": 9104,
"protocol": "TCP",
}
],
"envConfig": {"DATA_SOURCE_NAME": "root:rootpw@(mysql:3306)/"},
"kubernetes": {
"readinessProbe": {
"httpGet": {
"path": "/api/health",
"port": 9104,
},
"initialDelaySeconds": 10,
"periodSeconds": 10,
"timeoutSeconds": 5,
"successThreshold": 1,
"failureThreshold": 3,
},
"livenessProbe": {
"httpGet": {
"path": "/api/health",
"port": 9104,
},
"initialDelaySeconds": 60,
"timeoutSeconds": 30,
"failureThreshold": 10,
},
},
},
],
"kubernetesResources": {
"ingressResources": [
{
"name": "mysqld-exporter-ingress",
"annotations": {
"nginx.ingress.kubernetes.io/ssl-redirect": "false",
},
"spec": {
"rules": [
{
"host": "mysqld-exporter",
"http": {
"paths": [
{
"path": "/",
"backend": {
"serviceName": "mysqld-exporter",
"servicePort": 9104,
},
}
]
},
}
]
},
}
],
},
}
self.harness.charm.on.start.emit()
# Initializing the mysql relation
relation_id = self.harness.add_relation("mysql", "mysql")
self.harness.add_relation_unit(relation_id, "mysql/0")
self.harness.update_relation_data(
relation_id,
"mysql/0",
{
"host": "mysql",
"port": "3306",
"user": "mano",
"password": "manopw",
"root_password": "rootpw",
},
)
self.harness.update_config({"site_url": "http://mysqld-exporter"})
pod_spec, _ = self.harness.get_pod_spec()
self.assertDictEqual(expected_result, pod_spec)
def test_ingress_resources_with_https(self) -> NoReturn:
"""Test ingress resources with HTTPS."""
expected_result = {
"version": 3,
"containers": [
{
"name": "mysqld-exporter",
"imageDetails": self.harness.charm.image.fetch(),
"imagePullPolicy": "Always",
"ports": [
{
"name": "mysqld-exporter",
"containerPort": 9104,
"protocol": "TCP",
}
],
"envConfig": {"DATA_SOURCE_NAME": "root:rootpw@(mysql:3306)/"},
"kubernetes": {
"readinessProbe": {
"httpGet": {
"path": "/api/health",
"port": 9104,
},
"initialDelaySeconds": 10,
"periodSeconds": 10,
"timeoutSeconds": 5,
"successThreshold": 1,
"failureThreshold": 3,
},
"livenessProbe": {
"httpGet": {
"path": "/api/health",
"port": 9104,
},
"initialDelaySeconds": 60,
"timeoutSeconds": 30,
"failureThreshold": 10,
},
},
},
],
"kubernetesResources": {
"ingressResources": [
{
"name": "mysqld-exporter-ingress",
"annotations": {},
"spec": {
"rules": [
{
"host": "mysqld-exporter",
"http": {
"paths": [
{
"path": "/",
"backend": {
"serviceName": "mysqld-exporter",
"servicePort": 9104,
},
}
]
},
}
],
"tls": [
{
"hosts": ["mysqld-exporter"],
"secretName": "mysqld-exporter",
}
],
},
}
],
},
}
self.harness.charm.on.start.emit()
# Initializing the mysql relation
relation_id = self.harness.add_relation("mysql", "mysql")
self.harness.add_relation_unit(relation_id, "mysql/0")
self.harness.update_relation_data(
relation_id,
"mysql/0",
{
"host": "mysql",
"port": "3306",
"user": "mano",
"password": "manopw",
"root_password": "rootpw",
},
)
self.harness.update_config(
{
"site_url": "https://mysqld-exporter",
"tls_secret_name": "mysqld-exporter",
}
)
pod_spec, _ = self.harness.get_pod_spec()
self.assertDictEqual(expected_result, pod_spec)
def test_ingress_resources_with_https_and_ingress_whitelist(self) -> NoReturn:
"""Test ingress resources with HTTPS and ingress whitelist."""
expected_result = {
"version": 3,
"containers": [
{
"name": "mysqld-exporter",
"imageDetails": self.harness.charm.image.fetch(),
"imagePullPolicy": "Always",
"ports": [
{
"name": "mysqld-exporter",
"containerPort": 9104,
"protocol": "TCP",
}
],
"envConfig": {"DATA_SOURCE_NAME": "root:rootpw@(mysql:3306)/"},
"kubernetes": {
"readinessProbe": {
"httpGet": {
"path": "/api/health",
"port": 9104,
},
"initialDelaySeconds": 10,
"periodSeconds": 10,
"timeoutSeconds": 5,
"successThreshold": 1,
"failureThreshold": 3,
},
"livenessProbe": {
"httpGet": {
"path": "/api/health",
"port": 9104,
},
"initialDelaySeconds": 60,
"timeoutSeconds": 30,
"failureThreshold": 10,
},
},
},
],
"kubernetesResources": {
"ingressResources": [
{
"name": "mysqld-exporter-ingress",
"annotations": {
"nginx.ingress.kubernetes.io/whitelist-source-range": "0.0.0.0/0",
},
"spec": {
"rules": [
{
"host": "mysqld-exporter",
"http": {
"paths": [
{
"path": "/",
"backend": {
"serviceName": "mysqld-exporter",
"servicePort": 9104,
},
}
]
},
}
],
"tls": [
{
"hosts": ["mysqld-exporter"],
"secretName": "mysqld-exporter",
}
],
},
}
],
},
}
self.harness.charm.on.start.emit()
# Initializing the mysql relation
relation_id = self.harness.add_relation("mysql", "mysql")
self.harness.add_relation_unit(relation_id, "mysql/0")
self.harness.update_relation_data(
relation_id,
"mysql/0",
{
"host": "mysql",
"port": "3306",
"user": "mano",
"password": "manopw",
"root_password": "rootpw",
},
)
self.harness.update_config(
{
"site_url": "https://mysqld-exporter",
"tls_secret_name": "mysqld-exporter",
"ingress_whitelist_source_range": "0.0.0.0/0",
}
)
pod_spec, _ = self.harness.get_pod_spec()
self.assertDictEqual(expected_result, pod_spec)
def test_on_mysql_unit_relation_changed(self) -> NoReturn:
"""Test to see if mysql relation is updated."""
self.harness.charm.on.start.emit()
relation_id = self.harness.add_relation("mysql", "mysql")
self.harness.add_relation_unit(relation_id, "mysql/0")
self.harness.update_relation_data(
relation_id,
"mysql/0",
{
"host": "mysql",
"port": "3306",
"user": "mano",
"password": "manopw",
"root_password": "rootpw",
},
)
# Verifying status
self.assertNotIsInstance(self.harness.charm.unit.status, BlockedStatus)
def test_publish_target_info(self) -> NoReturn:
"""Test to see if target relation is updated."""
expected_result = {
"hostname": "mysqld-exporter",
"port": "9104",
"metrics_path": "/metrics",
"scrape_interval": "30s",
"scrape_timeout": "15s",
}
self.harness.charm.on.start.emit()
relation_id = self.harness.add_relation("prometheus-scrape", "prometheus")
self.harness.add_relation_unit(relation_id, "prometheus/0")
relation_data = self.harness.get_relation_data(relation_id, "mysqld-exporter/0")
self.assertDictEqual(expected_result, relation_data)
def test_publish_scrape_info_with_site_url(self) -> NoReturn:
"""Test to see if target relation is updated."""
expected_result = {
"hostname": "mysqld-exporter-osm",
"port": "80",
"metrics_path": "/metrics",
"scrape_interval": "30s",
"scrape_timeout": "15s",
}
self.harness.charm.on.start.emit()
self.harness.update_config({"site_url": "http://mysqld-exporter-osm"})
relation_id = self.harness.add_relation("prometheus-scrape", "prometheus")
self.harness.add_relation_unit(relation_id, "prometheus/0")
relation_data = self.harness.get_relation_data(relation_id, "mysqld-exporter/0")
self.assertDictEqual(expected_result, relation_data)
def test_publish_dashboard_info(self) -> NoReturn:
"""Test to see if dashboard relation is updated."""
self.harness.charm.on.start.emit()
relation_id = self.harness.add_relation("grafana-dashboard", "grafana")
self.harness.add_relation_unit(relation_id, "grafana/0")
relation_data = self.harness.get_relation_data(relation_id, "mysqld-exporter/0")
self.assertTrue("dashboard" in relation_data)
self.assertTrue(len(relation_data["dashboard"]) > 0)
self.assertEqual(relation_data["name"], "osm-mysql")
if __name__ == "__main__":
unittest.main()
#!/usr/bin/env python3
# Copyright 2021 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
##
from typing import NoReturn
import unittest
import pod_spec
class TestPodSpec(unittest.TestCase):
"""Pod spec unit tests."""
def test_make_pod_ports(self) -> NoReturn:
"""Testing make pod ports."""
port = 9104
expected_result = [
{
"name": "mysqld-exporter",
"containerPort": port,
"protocol": "TCP",
}
]
pod_ports = pod_spec._make_pod_ports(port)
self.assertListEqual(expected_result, pod_ports)
def test_make_pod_envconfig(self) -> NoReturn:
"""Teting make pod envconfig."""
config = {}
relation_state = {
"mysql_host": "mysql",
"mysql_port": "3306",
"mysql_user": "mano",
"mysql_password": "manopw",
"mysql_root_password": "rootpw",
}
expected_result = {
"DATA_SOURCE_NAME": "root:{mysql_root_password}@({mysql_host}:{mysql_port})/".format(
**relation_state
)
}
pod_envconfig = pod_spec._make_pod_envconfig(config, relation_state)
self.assertDictEqual(expected_result, pod_envconfig)
def test_make_pod_ingress_resources_without_site_url(self) -> NoReturn:
"""Testing make pod ingress resources without site_url."""
config = {"site_url": ""}
app_name = "mysqld-exporter"
port = 9104
pod_ingress_resources = pod_spec._make_pod_ingress_resources(
config, app_name, port
)
self.assertIsNone(pod_ingress_resources)
def test_make_pod_ingress_resources(self) -> NoReturn:
"""Testing make pod ingress resources."""
config = {
"site_url": "http://mysqld-exporter",
"ingress_whitelist_source_range": "",
}
app_name = "mysqld-exporter"
port = 9104
expected_result = [
{
"name": f"{app_name}-ingress",
"annotations": {
"nginx.ingress.kubernetes.io/ssl-redirect": "false",
},
"spec": {
"rules": [
{
"host": app_name,
"http": {
"paths": [
{
"path": "/",
"backend": {
"serviceName": app_name,
"servicePort": port,
},
}
]
},
}
]
},
}
]
pod_ingress_resources = pod_spec._make_pod_ingress_resources(
config, app_name, port
)
self.assertListEqual(expected_result, pod_ingress_resources)
def test_make_pod_ingress_resources_with_whitelist_source_range(self) -> NoReturn:
"""Testing make pod ingress resources with whitelist_source_range."""
config = {
"site_url": "http://mysqld-exporter",
"ingress_whitelist_source_range": "0.0.0.0/0",
}
app_name = "mysqld-exporter"
port = 9104
expected_result = [
{
"name": f"{app_name}-ingress",
"annotations": {
"nginx.ingress.kubernetes.io/ssl-redirect": "false",
"nginx.ingress.kubernetes.io/whitelist-source-range": config[
"ingress_whitelist_source_range"
],
},
"spec": {
"rules": [
{
"host": app_name,
"http": {
"paths": [
{
"path": "/",
"backend": {
"serviceName": app_name,
"servicePort": port,
},
}
]
},
}
]
},
}
]
pod_ingress_resources = pod_spec._make_pod_ingress_resources(
config, app_name, port
)
self.assertListEqual(expected_result, pod_ingress_resources)
def test_make_pod_ingress_resources_with_https(self) -> NoReturn:
"""Testing make pod ingress resources with HTTPs."""
config = {
"site_url": "https://mysqld-exporter",
"ingress_whitelist_source_range": "",
"tls_secret_name": "",
}
app_name = "mysqld-exporter"
port = 9104
expected_result = [
{
"name": f"{app_name}-ingress",
"annotations": {},
"spec": {
"rules": [
{
"host": app_name,
"http": {
"paths": [
{
"path": "/",
"backend": {
"serviceName": app_name,
"servicePort": port,
},
}
]
},
}
],
"tls": [{"hosts": [app_name]}],
},
}
]
pod_ingress_resources = pod_spec._make_pod_ingress_resources(
config, app_name, port
)
self.assertListEqual(expected_result, pod_ingress_resources)
def test_make_pod_ingress_resources_with_https_tls_secret_name(self) -> NoReturn:
"""Testing make pod ingress resources with HTTPs and TLS secret name."""
config = {
"site_url": "https://mysqld-exporter",
"ingress_whitelist_source_range": "",
"tls_secret_name": "secret_name",
}
app_name = "mysqld-exporter"
port = 9104
expected_result = [
{
"name": f"{app_name}-ingress",
"annotations": {},
"spec": {
"rules": [
{
"host": app_name,
"http": {
"paths": [
{
"path": "/",
"backend": {
"serviceName": app_name,
"servicePort": port,
},
}
]
},
}
],
"tls": [
{"hosts": [app_name], "secretName": config["tls_secret_name"]}
],
},
}
]
pod_ingress_resources = pod_spec._make_pod_ingress_resources(
config, app_name, port
)
self.assertListEqual(expected_result, pod_ingress_resources)
def test_make_readiness_probe(self) -> NoReturn:
"""Testing make readiness probe."""
port = 9104
expected_result = {
"httpGet": {
"path": "/api/health",
"port": port,
},
"initialDelaySeconds": 10,
"periodSeconds": 10,
"timeoutSeconds": 5,
"successThreshold": 1,
"failureThreshold": 3,
}
readiness_probe = pod_spec._make_readiness_probe(port)
self.assertDictEqual(expected_result, readiness_probe)
def test_make_liveness_probe(self) -> NoReturn:
"""Testing make liveness probe."""
port = 9104
expected_result = {
"httpGet": {
"path": "/api/health",
"port": port,
},
"initialDelaySeconds": 60,
"timeoutSeconds": 30,
"failureThreshold": 10,
}
liveness_probe = pod_spec._make_liveness_probe(port)
self.assertDictEqual(expected_result, liveness_probe)
def test_make_pod_spec(self) -> NoReturn:
"""Testing make pod spec."""
image_info = {"upstream-source": "bitnami/mysqld-exporter:latest"}
config = {
"site_url": "",
}
relation_state = {
"mysql_host": "mysql",
"mysql_port": "3306",
"mysql_user": "mano",
"mysql_password": "manopw",
"mysql_root_password": "rootpw",
}
app_name = "mysqld-exporter"
port = 9104
expected_result = {
"version": 3,
"containers": [
{
"name": app_name,
"imageDetails": image_info,
"imagePullPolicy": "Always",
"ports": [
{
"name": app_name,
"containerPort": port,
"protocol": "TCP",
}
],
"envConfig": {
"DATA_SOURCE_NAME": "root:{mysql_root_password}@({mysql_host}:{mysql_port})/".format(
**relation_state
)
},
"kubernetes": {
"readinessProbe": {
"httpGet": {
"path": "/api/health",
"port": port,
},
"initialDelaySeconds": 10,
"periodSeconds": 10,
"timeoutSeconds": 5,
"successThreshold": 1,
"failureThreshold": 3,
},
"livenessProbe": {
"httpGet": {
"path": "/api/health",
"port": port,
},
"initialDelaySeconds": 60,
"timeoutSeconds": 30,
"failureThreshold": 10,
},
},
}
],
"kubernetesResources": {"ingressResources": []},
}
spec = pod_spec.make_pod_spec(
image_info, config, relation_state, app_name, port
)
self.assertDictEqual(expected_result, spec)
def test_make_pod_spec_with_ingress(self) -> NoReturn:
"""Testing make pod spec."""
image_info = {"upstream-source": "bitnami/mysqld-exporter:latest"}
config = {
"site_url": "https://mysqld-exporter",
"tls_secret_name": "mysqld-exporter",
"ingress_whitelist_source_range": "0.0.0.0/0",
}
relation_state = {
"mysql_host": "mysql",
"mysql_port": "3306",
"mysql_user": "mano",
"mysql_password": "manopw",
"mysql_root_password": "rootpw",
}
app_name = "mysqld-exporter"
port = 9104
expected_result = {
"version": 3,
"containers": [
{
"name": app_name,
"imageDetails": image_info,
"imagePullPolicy": "Always",
"ports": [
{
"name": app_name,
"containerPort": port,
"protocol": "TCP",
}
],
"envConfig": {
"DATA_SOURCE_NAME": "root:{mysql_root_password}@({mysql_host}:{mysql_port})/".format(
**relation_state
)
},
"kubernetes": {
"readinessProbe": {
"httpGet": {
"path": "/api/health",
"port": port,
},
"initialDelaySeconds": 10,
"periodSeconds": 10,
"timeoutSeconds": 5,
"successThreshold": 1,
"failureThreshold": 3,
},
"livenessProbe": {
"httpGet": {
"path": "/api/health",
"port": port,
},
"initialDelaySeconds": 60,
"timeoutSeconds": 30,
"failureThreshold": 10,
},
},
}
],
"kubernetesResources": {
"ingressResources": [
{
"name": "{}-ingress".format(app_name),
"annotations": {
"nginx.ingress.kubernetes.io/whitelist-source-range": config.get(
"ingress_whitelist_source_range"
),
},
"spec": {
"rules": [
{
"host": app_name,
"http": {
"paths": [
{
"path": "/",
"backend": {
"serviceName": app_name,
"servicePort": port,
},
}
]
},
}
],
"tls": [
{
"hosts": [app_name],
"secretName": config.get("tls_secret_name"),
}
],
},
}
],
},
}
spec = pod_spec.make_pod_spec(
image_info, config, relation_state, app_name, port
)
self.assertDictEqual(expected_result, spec)
def test_make_pod_spec_without_image_info(self) -> NoReturn:
"""Testing make pod spec without image_info."""
image_info = None
config = {
"site_url": "",
}
relation_state = {
"mysql_host": "mysql",
"mysql_port": 3306,
"mysql_user": "mano",
"mysql_password": "manopw",
"mysql_root_password": "rootpw",
}
app_name = "mysqld-exporter"
port = 9104
spec = pod_spec.make_pod_spec(
image_info, config, relation_state, app_name, port
)
self.assertIsNone(spec)
def test_make_pod_spec_without_relation_state(self) -> NoReturn:
"""Testing make pod spec without relation_state."""
image_info = {"upstream-source": "bitnami/mysqld-exporter:latest"}
config = {
"site_url": "",
}
relation_state = {}
app_name = "mysqld-exporter"
port = 9104
with self.assertRaises(ValueError):
pod_spec.make_pod_spec(image_info, config, relation_state, app_name, port)
if __name__ == "__main__":
unittest.main()
# Copyright 2021 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
##
[tox]
skipsdist = True
envlist = unit, lint
sitepackages = False
skip_missing_interpreters = False
[testenv]
basepython = python3
setenv =
PYTHONHASHSEED=0
PYTHONPATH = {toxinidir}/src
CHARM_NAME = mysqld-exporter
[testenv:build]
passenv=HTTP_PROXY HTTPS_PROXY NO_PROXY
whitelist_externals =
charmcraft
rm
unzip
commands =
rm -rf release mysqld-exporter.charm
charmcraft build
unzip mysqld-exporter.charm -d release
[testenv:unit]
commands =
coverage erase
stestr run --slowest --test-path=./tests --top-dir=./
coverage combine
coverage html -d cover
coverage xml -o cover/coverage.xml
coverage report
deps =
coverage
stestr
mock
ops
setenv =
{[testenv]setenv}
PYTHON=coverage run
[testenv:lint]
deps =
black
yamllint
flake8
commands =
black --check --diff . --exclude "build/|.tox/|mod/|lib/"
yamllint .
flake8 . --max-line-length=100 --ignore="E501,W503,W504,F722" --exclude "build/ .tox/ mod/ lib/"
[coverage:run]
branch = True
concurrency = multiprocessing
parallel = True
source =
.
omit =
.tox/*
tests/*
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment