2 # Copyright 2021 Canonical Ltd.
4 # Licensed under the Apache License, Version 2.0 (the "License"); you may
5 # not use this file except in compliance with the License. You may obtain
6 # a copy of the License at
8 # http://www.apache.org/licenses/LICENSE-2.0
10 # Unless required by applicable law or agreed to in writing, software
11 # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 # License for the specific language governing permissions and limitations
16 # For those usages not covered by the Apache License, Version 2.0 please
17 # contact: legal@canonical.com
19 # To get in touch with the maintainers, please contact:
20 # osm-charmers@lists.launchpad.net
23 # pylint: disable=E0213
25 from ipaddress
import ip_network
27 from pathlib
import Path
29 from string
import Template
30 from typing
import NoReturn
, Optional
31 from urllib
.parse
import urlparse
33 from ops
.main
import main
34 from opslib
.osm
.charm
import CharmedOsmBase
, RelationsMissing
35 from opslib
.osm
.interfaces
.grafana
import GrafanaCluster
36 from opslib
.osm
.interfaces
.mysql
import MysqlClient
37 from opslib
.osm
.interfaces
.prometheus
import PrometheusClient
38 from opslib
.osm
.pod
import (
41 IngressResourceV3Builder
,
44 from opslib
.osm
.validator
import ModelValidator
, validator
47 logger
= logging
.getLogger(__name__
)
50 class ConfigModel(ModelValidator
):
56 site_url
: Optional
[str]
57 cluster_issuer
: Optional
[str]
58 ingress_class
: Optional
[str]
59 ingress_whitelist_source_range
: Optional
[str]
60 tls_secret_name
: Optional
[str]
61 image_pull_policy
: str
63 @validator("log_level")
64 def validate_log_level(cls
, v
):
65 allowed_values
= ("debug", "info", "warn", "error", "critical")
66 if v
not in allowed_values
:
69 f
'incorrect value. Allowed values are "{separator.join(allowed_values)}"'
73 @validator("max_file_size")
74 def validate_max_file_size(cls
, v
):
76 raise ValueError("value must be equal or greater than 0")
79 @validator("site_url")
80 def validate_site_url(cls
, v
):
83 if not parsed
.scheme
.startswith("http"):
84 raise ValueError("value must start with http")
87 @validator("ingress_whitelist_source_range")
88 def validate_ingress_whitelist_source_range(cls
, v
):
93 @validator("image_pull_policy")
94 def validate_image_pull_policy(cls
, v
):
97 "ifnotpresent": "IfNotPresent",
101 if v
not in values
.keys():
102 raise ValueError("value must be always, ifnotpresent or never")
106 class GrafanaCharm(CharmedOsmBase
):
107 """GrafanaCharm Charm."""
109 def __init__(self
, *args
) -> NoReturn
:
110 """Prometheus Charm constructor."""
111 super().__init
__(*args
, oci_image
="image", mysql_uri
=True)
112 # Initialize relation objects
113 self
.prometheus_client
= PrometheusClient(self
, "prometheus")
114 self
.grafana_cluster
= GrafanaCluster(self
, "cluster")
115 self
.mysql_client
= MysqlClient(self
, "db")
117 event_observer_mapping
= {
118 self
.on
["prometheus"].relation_changed
: self
.configure_pod
,
119 self
.on
["prometheus"].relation_broken
: self
.configure_pod
,
120 self
.on
["db"].relation_changed
: self
.configure_pod
,
121 self
.on
["db"].relation_broken
: self
.configure_pod
,
123 for event
, observer
in event_observer_mapping
.items():
124 self
.framework
.observe(event
, observer
)
126 def _build_dashboard_files(self
, config
: ConfigModel
):
127 files_builder
= FilesV3Builder()
128 files_builder
.add_file(
129 "dashboard_osm.yaml",
130 Path("templates/default_dashboards.yaml").read_text(),
132 if config
.osm_dashboards
:
133 osm_dashboards_mapping
= {
134 "kafka_exporter_dashboard.json": "templates/kafka_exporter_dashboard.json",
135 "mongodb_exporter_dashboard.json": "templates/mongodb_exporter_dashboard.json",
136 "mysql_exporter_dashboard.json": "templates/mysql_exporter_dashboard.json",
137 "nodes_exporter_dashboard.json": "templates/nodes_exporter_dashboard.json",
138 "summary_dashboard.json": "templates/summary_dashboard.json",
140 for file_name
, path
in osm_dashboards_mapping
.items():
141 files_builder
.add_file(file_name
, Path(path
).read_text())
142 return files_builder
.build()
144 def _build_datasources_files(self
):
145 files_builder
= FilesV3Builder()
146 files_builder
.add_file(
147 "datasource_prometheus.yaml",
148 Template(Path("templates/default_datasources.yaml").read_text()).substitute(
149 prometheus_host
=self
.prometheus_client
.hostname
,
150 prometheus_port
=self
.prometheus_client
.port
,
153 return files_builder
.build()
155 def _check_missing_dependencies(self
, config
: ConfigModel
, external_db
: bool):
156 missing_relations
= []
158 if self
.prometheus_client
.is_missing_data_in_app():
159 missing_relations
.append("prometheus")
161 if not external_db
and self
.mysql_client
.is_missing_data_in_unit():
162 missing_relations
.append("db")
164 if missing_relations
:
165 raise RelationsMissing(missing_relations
)
167 def build_pod_spec(self
, image_info
, **kwargs
):
169 config
= ConfigModel(**dict(self
.config
))
170 mysql_config
= kwargs
["mysql_config"]
171 if mysql_config
.mysql_uri
and not self
.mysql_client
.is_missing_data_in_unit():
172 raise Exception("Mysql data cannot be provided via config and relation")
175 external_db
= True if mysql_config
.mysql_uri
else False
176 self
._check
_missing
_dependencies
(config
, external_db
)
178 # Get initial password
179 admin_initial_password
= self
.grafana_cluster
.admin_initial_password
180 if not admin_initial_password
:
181 admin_initial_password
= _generate_random_password()
182 self
.grafana_cluster
.set_initial_password(admin_initial_password
)
184 # Create Builder for the PodSpec
185 pod_spec_builder
= PodSpecV3Builder()
188 container_builder
= ContainerV3Builder(
189 self
.app
.name
, image_info
, config
.image_pull_policy
191 container_builder
.add_port(name
=self
.app
.name
, port
=config
.port
)
192 container_builder
.add_http_readiness_probe(
195 initial_delay_seconds
=10,
200 container_builder
.add_http_liveness_probe(
203 initial_delay_seconds
=60,
205 failure_threshold
=10,
207 container_builder
.add_volume_config(
209 "/etc/grafana/provisioning/dashboards/",
210 self
._build
_dashboard
_files
(config
),
212 container_builder
.add_volume_config(
214 "/etc/grafana/provisioning/datasources/",
215 self
._build
_datasources
_files
(),
218 container_builder
.add_envs(
220 "GF_SERVER_HTTP_PORT": config
.port
,
221 "GF_LOG_LEVEL": config
.log_level
,
222 "GF_SECURITY_ADMIN_USER": config
.admin_user
,
223 "GF_SECURITY_ADMIN_PASSWORD": {
224 "secret": {"name": "grafana-admin-secret", "key": "admin-password"}
227 "secret": {"name": "grafana-admin-secret", "key": "mysql-url"}
231 container
= container_builder
.build()
232 # Add container to pod spec
233 pod_spec_builder
.add_container(container
)
234 pod_spec_builder
.add_secret(
235 "grafana-admin-secret",
237 "admin-password": admin_initial_password
,
238 "mysql-url": mysql_config
.mysql_uri
or self
.mysql_client
.get_uri(),
241 # Add ingress resources to pod spec if site url exists
243 parsed
= urlparse(config
.site_url
)
245 "nginx.ingress.kubernetes.io/proxy-body-size": "{}".format(
246 str(config
.max_file_size
) + "m"
247 if config
.max_file_size
> 0
248 else config
.max_file_size
251 if config
.ingress_class
:
252 annotations
["kubernetes.io/ingress.class"] = config
.ingress_class
253 ingress_resource_builder
= IngressResourceV3Builder(
254 f
"{self.app.name}-ingress", annotations
257 if config
.ingress_whitelist_source_range
:
259 "nginx.ingress.kubernetes.io/whitelist-source-range"
260 ] = config
.ingress_whitelist_source_range
262 if config
.cluster_issuer
:
263 annotations
["cert-manager.io/cluster-issuer"] = config
.cluster_issuer
265 if parsed
.scheme
== "https":
266 ingress_resource_builder
.add_tls(
267 [parsed
.hostname
], config
.tls_secret_name
270 annotations
["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
272 ingress_resource_builder
.add_rule(
273 parsed
.hostname
, self
.app
.name
, config
.port
275 ingress_resource
= ingress_resource_builder
.build()
276 pod_spec_builder
.add_ingress_resource(ingress_resource
)
277 return pod_spec_builder
.build()
280 def _generate_random_password():
281 return secrets
.token_hex(16)
284 if __name__
== "__main__":