Skip to content
Snippets Groups Projects
Commit 1072160c authored by sousaedu's avatar sousaedu
Browse files

Moving exporter charms to use opslib


This commit also includes external DB configuration option.

Change-Id: Iddb4adfae582ecfc6af2d797716e386420ad1df8
Signed-off-by: default avatarsousaedu <eduardo.sousa@canonical.com>
parent f5e7f427
No related branches found
No related tags found
No related merge requests found
Showing
with 1609 additions and 1249 deletions
......@@ -22,7 +22,9 @@
venv
.vscode
build
kafka-exporter.charm
*.charm
.coverage
coverage.xml
.stestr
cover
release
\ No newline at end of file
# 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
*.charm
.coverage
coverage.xml
.gitignore
.stestr
cover
release
tests/
requirements*
tox.ini
......@@ -28,4 +28,7 @@ yaml-files:
- ".yamllint"
ignore: |
.tox
cover/
build/
venv
release/
# 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
mock==4.0.3
......@@ -19,5 +19,4 @@
# osm-charmers@lists.launchpad.net
##
ops
git+https://github.com/juju-solutions/resource-oci-image/@c5778285d332edf3d9a538f9d0c06154b7ec1b0b#egg=oci-image
git+https://github.com/charmed-osm/ops-lib-charmed-osm/@master
......@@ -20,203 +20,207 @@
# osm-charmers@lists.launchpad.net
##
# pylint: disable=E0213
from ipaddress import ip_network
import logging
from pathlib import Path
from typing import Dict, List, NoReturn
from typing import NoReturn, Optional
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 opslib.osm.charm import CharmedOsmBase, RelationsMissing
from opslib.osm.interfaces.grafana import GrafanaDashboardTarget
from opslib.osm.interfaces.kafka import KafkaClient
from opslib.osm.interfaces.prometheus import PrometheusScrapeTarget
from opslib.osm.pod import (
ContainerV3Builder,
IngressResourceV3Builder,
PodSpecV3Builder,
)
from opslib.osm.validator import ModelValidator, validator
from pod_spec import make_pod_spec
logger = logging.getLogger(__name__)
KAFKA_EXPORTER_PORT = 9308
PORT = 9308
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 ConfigModel(ModelValidator):
site_url: Optional[str]
cluster_issuer: Optional[str]
ingress_whitelist_source_range: Optional[str]
tls_secret_name: Optional[str]
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
@validator("site_url")
def validate_site_url(cls, v):
if v:
parsed = urlparse(v)
if not parsed.scheme.startswith("http"):
raise ValueError("value must start with http")
return v
class KafkaExporterCharm(CharmBase):
"""Kafka Exporter Charm."""
@validator("ingress_whitelist_source_range")
def validate_ingress_whitelist_source_range(cls, v):
if v:
ip_network(v)
return v
state = StoredState()
class KafkaExporterCharm(CharmedOsmBase):
def __init__(self, *args) -> NoReturn:
"""Kafka Exporter Charm constructor."""
super().__init__(*args)
# Internal state initialization
self.state.set_default(pod_spec=None)
self.port = KAFKA_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)
super().__init__(*args, oci_image="image")
# Registering required relation events
self.framework.observe(self.on.kafka_relation_changed, self.configure_pod)
# Provision Kafka relation to exchange information
self.kafka_client = KafkaClient(self, "kafka")
self.framework.observe(self.on["kafka"].relation_changed, self.configure_pod)
self.framework.observe(self.on["kafka"].relation_broken, self.configure_pod)
# Registering required relation departed events
self.framework.observe(self.on.kafka_relation_departed, self.configure_pod)
# Registering provided relation events
# Register relation to provide a Scraping Target
self.scrape_target = PrometheusScrapeTarget(self, "prometheus-scrape")
self.framework.observe(
self.on.prometheus_scrape_relation_joined, self._publish_scrape_info
self.on["prometheus-scrape"].relation_joined, self._publish_scrape_info
)
# Register relation to provide a Dasboard Target
self.dashboard_target = GrafanaDashboardTarget(self, "grafana-dashboard")
self.framework.observe(
self.on.grafana_dashboard_relation_joined, self._publish_dashboard_info
self.on["grafana-dashboard"].relation_joined, self._publish_dashboard_info
)
def _publish_scrape_info(self, event: EventBase) -> NoReturn:
"""Publishes scrape information.
def _publish_scrape_info(self, event) -> NoReturn:
"""Publishes scraping information for Prometheus.
Args:
event (EventBase): Prometheus relation event.
"""
if self.unit.is_leader():
hostname = (
urlparse(self.model.config["site_url"]).hostname
if self.model.config["site_url"]
else self.model.app.name
)
port = str(PORT)
if self.model.config.get("site_url", "").startswith("https://"):
port = "443"
elif self.model.config.get("site_url", "").startswith("http://"):
port = "80"
self.scrape_target.publish_info(
hostname=hostname,
port=port,
metrics_path="/metrics",
scrape_interval="30s",
scrape_timeout="15s",
)
def _publish_dashboard_info(self, event) -> NoReturn:
"""Publish dashboards for Grafana.
Args:
event (EventBase): Exporter relation event.
event (EventBase): Grafana 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(KAFKA_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.
if self.unit.is_leader():
self.dashboard_target.publish_info(
name="osm-kafka",
dashboard=Path("files/kafka_exporter_dashboard.json").read_text(),
)
def _check_missing_dependencies(self, config: ConfigModel):
"""Check if there is any relation missing.
Args:
event (EventBase): Exporter relation event.
config (ConfigModel): object with configuration information.
Raises:
RelationsMissing: if kafka is missing.
"""
rel_data = {
"name": "osm-kafka",
"dashboard": Path("files/kafka_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("kafka", ["host", "port"], 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.
missing_relations = []
if self.kafka_client.is_missing_data_in_unit():
missing_relations.append("kafka")
if missing_relations:
raise RelationsMissing(missing_relations)
def build_pod_spec(self, image_info):
"""Build the PodSpec to be used.
Args:
event (EventBase): Hook or Relation event that started the
function.
image_info (str): container image information.
Returns:
Dict: PodSpec information.
"""
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,
# Validate config
config = ConfigModel(**dict(self.config))
# Check relations
self._check_missing_dependencies(config)
# Create Builder for the PodSpec
pod_spec_builder = PodSpecV3Builder()
# Build container
container_builder = ContainerV3Builder(self.app.name, image_info)
container_builder.add_port(name=self.app.name, port=PORT)
container_builder.add_http_readiness_probe(
path="/api/health",
port=PORT,
initial_delay_seconds=10,
period_seconds=10,
timeout_seconds=5,
success_threshold=1,
failure_threshold=3,
)
container_builder.add_http_liveness_probe(
path="/api/health",
port=PORT,
initial_delay_seconds=60,
timeout_seconds=30,
failure_threshold=10,
)
container_builder.add_command(
[
"kafka_exporter",
f"--kafka.server={self.kafka_client.host}:{self.kafka_client.port}",
]
)
container = container_builder.build()
# Add container to PodSpec
pod_spec_builder.add_container(container)
# Add ingress resources to PodSpec if site url exists
if config.site_url:
parsed = urlparse(config.site_url)
annotations = {}
ingress_resource_builder = IngressResourceV3Builder(
f"{self.app.name}-ingress", annotations
)
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
if config.ingress_whitelist_source_range:
annotations[
"nginx.ingress.kubernetes.io/whitelist-source-range"
] = config.ingress_whitelist_source_range
if config.cluster_issuer:
annotations["cert-manager.io/cluster-issuer"] = config.cluster_issuer
if parsed.scheme == "https":
ingress_resource_builder.add_tls(
[parsed.hostname], config.tls_secret_name
)
else:
annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
ingress_resource_builder.add_rule(parsed.hostname, self.app.name, PORT)
ingress_resource = ingress_resource_builder.build()
pod_spec_builder.add_ingress_resource(ingress_resource)
logger.debug(pod_spec_builder.build())
self.unit.status = ActiveStatus("ready")
return pod_spec_builder.build()
if __name__ == "__main__":
......
......@@ -20,8 +20,8 @@
# osm-charmers@lists.launchpad.net
##
import logging
from ipaddress import ip_network
import logging
from typing import Any, Dict, List
from urllib.parse import urlparse
......
......@@ -23,9 +23,17 @@
"""Init mocking for unit tests."""
import sys
import mock
class OCIImageResourceErrorMock(Exception):
pass
sys.path.append("src")
oci_image = mock.MagicMock()
oci_image.OCIImageResourceError = OCIImageResourceErrorMock
sys.modules["oci_image"] = oci_image
sys.modules["oci_image"].OCIImageResource().fetch.return_value = {}
This diff is collapsed.
......@@ -18,64 +18,107 @@
# 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
envlist = black, cover, flake8, pylint, yamllint, safety
skipsdist = true
[tox:jenkins]
toxworkdir = /tmp/.tox
[testenv]
basepython = python3
basepython = python3.8
setenv = VIRTUAL_ENV={envdir}
PYTHONDONTWRITEBYTECODE = 1
deps = -r{toxinidir}/requirements.txt
#######################################################################################
[testenv:black]
deps = black
commands =
black --check --diff src/ tests/
#######################################################################################
[testenv:cover]
deps = {[testenv]deps}
-r{toxinidir}/requirements-test.txt
coverage
nose2
commands =
sh -c 'rm -f nosetests.xml'
coverage erase
nose2 -C --coverage src
coverage report --omit='*tests*'
coverage html -d ./cover --omit='*tests*'
coverage xml -o coverage.xml --omit=*tests*
whitelist_externals = sh
#######################################################################################
[testenv:flake8]
deps = flake8
flake8-import-order
commands =
flake8 src/ tests/
#######################################################################################
[testenv:pylint]
deps = {[testenv]deps}
-r{toxinidir}/requirements-test.txt
pylint
commands =
pylint -E src/ tests/
#######################################################################################
[testenv:safety]
setenv =
PYTHONHASHSEED=0
PYTHONPATH = {toxinidir}/src
CHARM_NAME = kafka-exporter
LC_ALL=C.UTF-8
LANG=C.UTF-8
deps = {[testenv]deps}
safety
commands =
- safety check --full-report
#######################################################################################
[testenv:yamllint]
deps = {[testenv]deps}
-r{toxinidir}/requirements-test.txt
yamllint
commands = yamllint .
#######################################################################################
[testenv:build]
passenv=HTTP_PROXY HTTPS_PROXY NO_PROXY
deps = charmcraft
whitelist_externals =
charmcraft
rm
unzip
cp
commands =
rm -rf release kafka-exporter.charm
charmcraft build
unzip kafka-exporter.charm -d release
cp -r build 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/*
#######################################################################################
[flake8]
ignore =
W291,
W293,
W503,
E123,
E125,
E226,
E241,
exclude =
.git,
__pycache__,
.tox,
max-line-length = 120
show-source = True
builtins = _
max-complexity = 10
import-order-style = google
......@@ -22,7 +22,9 @@
venv
.vscode
build
mongodb-exporter.charm
*.charm
.coverage
coverage.xml
.stestr
cover
release
\ No newline at end of file
# 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
*.charm
.coverage
coverage.xml
.gitignore
.stestr
cover
release
tests/
requirements*
tox.ini
......@@ -28,4 +28,7 @@ yaml-files:
- ".yamllint"
ignore: |
.tox
cover/
build/
venv
release/
......@@ -41,3 +41,6 @@ options:
type: string
description: Name of the cluster issuer for TLS certificates
default: ""
mongodb_uri:
type: string
description: MongoDB URI (external database)
# 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
mock==4.0.3
......@@ -19,5 +19,4 @@
# osm-charmers@lists.launchpad.net
##
ops
git+https://github.com/juju-solutions/resource-oci-image/@c5778285d332edf3d9a538f9d0c06154b7ec1b0b#egg=oci-image
git+https://github.com/charmed-osm/ops-lib-charmed-osm/@master
......@@ -20,205 +20,227 @@
# osm-charmers@lists.launchpad.net
##
# pylint: disable=E0213
from ipaddress import ip_network
import logging
from pathlib import Path
from typing import Dict, List, NoReturn
from typing import NoReturn, Optional
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 opslib.osm.charm import CharmedOsmBase, RelationsMissing
from opslib.osm.interfaces.grafana import GrafanaDashboardTarget
from opslib.osm.interfaces.mongo import MongoClient
from opslib.osm.interfaces.prometheus import PrometheusScrapeTarget
from opslib.osm.pod import (
ContainerV3Builder,
IngressResourceV3Builder,
PodSpecV3Builder,
)
from opslib.osm.validator import ModelValidator, validator
from pod_spec import make_pod_spec
logger = logging.getLogger(__name__)
MONGODB_EXPORTER_PORT = 9216
PORT = 9216
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 ConfigModel(ModelValidator):
site_url: Optional[str]
cluster_issuer: Optional[str]
ingress_whitelist_source_range: Optional[str]
tls_secret_name: Optional[str]
mongodb_uri: Optional[str]
@validator("site_url")
def validate_site_url(cls, v):
if v:
parsed = urlparse(v)
if not parsed.scheme.startswith("http"):
raise ValueError("value must start with http")
return v
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
@validator("ingress_whitelist_source_range")
def validate_ingress_whitelist_source_range(cls, v):
if v:
ip_network(v)
return v
@validator("mongodb_uri")
def validate_mongodb_uri(cls, v):
if v and not v.startswith("mongodb://"):
raise ValueError("mongodb_uri is not properly formed")
return v
class MongodbExporterCharm(CharmBase):
"""Mongodb Exporter Charm."""
state = StoredState()
class MongodbExporterCharm(CharmedOsmBase):
def __init__(self, *args) -> NoReturn:
"""Mongodb Exporter Charm constructor."""
super().__init__(*args)
# Internal state initialization
self.state.set_default(pod_spec=None)
super().__init__(*args, oci_image="image")
self.port = MONGODB_EXPORTER_PORT
self.image = OCIImageResource(self, "image")
# Provision Kafka relation to exchange information
self.mongodb_client = MongoClient(self, "mongodb")
self.framework.observe(self.on["mongodb"].relation_changed, self.configure_pod)
self.framework.observe(self.on["mongodb"].relation_broken, self.configure_pod)
# 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.mongodb_relation_changed, self.configure_pod)
# Registering required relation departed events
self.framework.observe(self.on.mongodb_relation_departed, self.configure_pod)
# Registering provided relation events
# Register relation to provide a Scraping Target
self.scrape_target = PrometheusScrapeTarget(self, "prometheus-scrape")
self.framework.observe(
self.on.prometheus_scrape_relation_joined, self._publish_scrape_info
self.on["prometheus-scrape"].relation_joined, self._publish_scrape_info
)
# Register relation to provide a Dasboard Target
self.dashboard_target = GrafanaDashboardTarget(self, "grafana-dashboard")
self.framework.observe(
self.on.grafana_dashboard_relation_joined, self._publish_dashboard_info
self.on["grafana-dashboard"].relation_joined, self._publish_dashboard_info
)
def _publish_scrape_info(self, event: EventBase) -> NoReturn:
"""Publishes scrape information.
def _publish_scrape_info(self, event) -> NoReturn:
"""Publishes scraping information for Prometheus.
Args:
event (EventBase): Exporter relation event.
event (EventBase): Prometheus 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(MONGODB_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.
if self.unit.is_leader():
hostname = (
urlparse(self.model.config["site_url"]).hostname
if self.model.config["site_url"]
else self.model.app.name
)
port = str(PORT)
if self.model.config.get("site_url", "").startswith("https://"):
port = "443"
elif self.model.config.get("site_url", "").startswith("http://"):
port = "80"
self.scrape_target.publish_info(
hostname=hostname,
port=port,
metrics_path="/metrics",
scrape_interval="30s",
scrape_timeout="15s",
)
def _publish_dashboard_info(self, event) -> NoReturn:
"""Publish dashboards for Grafana.
Args:
event (EventBase): Exporter relation event.
event (EventBase): Grafana relation event.
"""
rel_data = {
"name": "osm-mongodb",
"dashboard": Path("files/mongodb_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("mongodb", ["connection_string"], 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.
if self.unit.is_leader():
self.dashboard_target.publish_info(
name="osm-mongodb",
dashboard=Path("files/mongodb_exporter_dashboard.json").read_text(),
)
def _check_missing_dependencies(self, config: ConfigModel):
"""Check if there is any relation missing.
Args:
event (EventBase): Hook or Relation event that started the
function.
config (ConfigModel): object with configuration information.
Raises:
RelationsMissing: if kafka is missing.
"""
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,
missing_relations = []
if not config.mongodb_uri and self.mongodb_client.is_missing_data_in_unit():
missing_relations.append("mongodb")
if missing_relations:
raise RelationsMissing(missing_relations)
def build_pod_spec(self, image_info):
"""Build the PodSpec to be used.
Args:
image_info (str): container image information.
Returns:
Dict: PodSpec information.
"""
# Validate config
config = ConfigModel(**dict(self.config))
if config.mongodb_uri and not self.mongodb_client.is_missing_data_in_unit():
raise Exception("Mongodb data cannot be provided via config and relation")
# Check relations
self._check_missing_dependencies(config)
# Create Builder for the PodSpec
pod_spec_builder = PodSpecV3Builder()
# Build container
container_builder = ContainerV3Builder(self.app.name, image_info)
container_builder.add_port(name=self.app.name, port=PORT)
container_builder.add_http_readiness_probe(
path="/api/health",
port=PORT,
initial_delay_seconds=10,
period_seconds=10,
timeout_seconds=5,
success_threshold=1,
failure_threshold=3,
)
container_builder.add_http_liveness_probe(
path="/api/health",
port=PORT,
initial_delay_seconds=60,
timeout_seconds=30,
failure_threshold=10,
)
unparsed = (
config.mongodb_uri
if config.mongodb_uri
else self.mongodb_client.connection_string
)
parsed = urlparse(unparsed)
mongodb_uri = f"mongodb://{parsed.netloc.split(',')[0]}{parsed.path}"
if parsed.query:
mongodb_uri += f"?{parsed.query}"
container_builder.add_envs(
{
"MONGODB_URI": mongodb_uri,
}
)
container = container_builder.build()
# Add container to PodSpec
pod_spec_builder.add_container(container)
# Add ingress resources to PodSpec if site url exists
if config.site_url:
parsed = urlparse(config.site_url)
annotations = {}
ingress_resource_builder = IngressResourceV3Builder(
f"{self.app.name}-ingress", annotations
)
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
if config.ingress_whitelist_source_range:
annotations[
"nginx.ingress.kubernetes.io/whitelist-source-range"
] = config.ingress_whitelist_source_range
if config.cluster_issuer:
annotations["cert-manager.io/cluster-issuer"] = config.cluster_issuer
if parsed.scheme == "https":
ingress_resource_builder.add_tls(
[parsed.hostname], config.tls_secret_name
)
else:
annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
ingress_resource_builder.add_rule(parsed.hostname, self.app.name, PORT)
ingress_resource = ingress_resource_builder.build()
pod_spec_builder.add_ingress_resource(ingress_resource)
logger.debug(pod_spec_builder.build())
self.unit.status = ActiveStatus("ready")
return pod_spec_builder.build()
if __name__ == "__main__":
......
......@@ -20,8 +20,8 @@
# osm-charmers@lists.launchpad.net
##
import logging
from ipaddress import ip_network
import logging
from typing import Any, Dict, List
from urllib.parse import urlparse
......
......@@ -23,9 +23,17 @@
"""Init mocking for unit tests."""
import sys
import mock
class OCIImageResourceErrorMock(Exception):
pass
sys.path.append("src")
oci_image = mock.MagicMock()
oci_image.OCIImageResourceError = OCIImageResourceErrorMock
sys.modules["oci_image"] = oci_image
sys.modules["oci_image"].OCIImageResource().fetch.return_value = {}
This diff is collapsed.
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