Add VCA Integrator Charm
[osm/devops.git] / installers / charm / vca-integrator-operator / src / charm.py
1 #!/usr/bin/env python3
2 #######################################################################################
3 # Copyright ETSI Contributors and Others.
4 #
5 # Licensed under the Apache License, Version 2.0 (the "License");
6 # you may not use this file except in compliance with the License.
7 # You may obtain a copy of the License at
8 #
9 # http://www.apache.org/licenses/LICENSE-2.0
10 #
11 # Unless required by applicable law or agreed to in writing, software
12 # distributed under the License is distributed on an "AS IS" BASIS,
13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14 # implied.
15 # See the License for the specific language governing permissions and
16 # limitations under the License.
17 #######################################################################################
18
19 """VcaIntegrator K8s charm module."""
20
21 import asyncio
22 import base64
23 import logging
24 import os
25 from pathlib import Path
26 from typing import Dict, Set
27
28 import yaml
29 from charms.osm_vca_integrator.v0.vca import VcaData, VcaProvides
30 from juju.controller import Controller
31 from ops.charm import CharmBase
32 from ops.main import main
33 from ops.model import ActiveStatus, BlockedStatus, StatusBase
34
35 logger = logging.getLogger(__name__)
36
37 GO_COOKIES = "/root/.go-cookies"
38 JUJU_DATA = os.environ["JUJU_DATA"] = "/root/.local/share/juju"
39 JUJU_CONFIGS = {
40 "public-key": "ssh/juju_id_rsa.pub",
41 "controllers": "controllers.yaml",
42 "accounts": "accounts.yaml",
43 }
44
45
46 class CharmError(Exception):
47 """Charm Error Exception."""
48
49 def __init__(self, message: str, status_class: StatusBase = BlockedStatus) -> None:
50 self.message = message
51 self.status_class = status_class
52 self.status = status_class(message)
53
54
55 class VcaIntegratorCharm(CharmBase):
56 """VcaIntegrator K8s Charm operator."""
57
58 def __init__(self, *args):
59 super().__init__(*args)
60 self.vca_provider = VcaProvides(self)
61 # Observe charm events
62 event_observe_mapping = {
63 self.on.config_changed: self._on_config_changed,
64 self.on.vca_relation_joined: self._on_config_changed,
65 }
66 for event, observer in event_observe_mapping.items():
67 self.framework.observe(event, observer)
68
69 # ---------------------------------------------------------------------------
70 # Properties
71 # ---------------------------------------------------------------------------
72
73 @property
74 def clouds_set(self) -> Set:
75 """Clouds set in the configuration."""
76 clouds_set = set()
77 for cloud_config in ["k8s-cloud", "lxd-cloud"]:
78 if cloud_name := self.config.get(cloud_config):
79 clouds_set.add(cloud_name.split(":")[0])
80 return clouds_set
81
82 @property
83 def vca_data(self) -> VcaData:
84 """Get VCA data."""
85 return VcaData(self._get_vca_data())
86
87 # ---------------------------------------------------------------------------
88 # Handlers for Charm Events
89 # ---------------------------------------------------------------------------
90
91 def _on_config_changed(self, _) -> None:
92 """Handler for the config-changed event."""
93 # Validate charm configuration
94 try:
95 self._validate_config()
96 self._write_controller_config_files()
97 self._check_controller()
98 self.vca_provider.update_vca_data(self.vca_data)
99 self.unit.status = ActiveStatus()
100 except CharmError as e:
101 self.unit.status = e.status
102
103 # ---------------------------------------------------------------------------
104 # Validation and configuration
105 # ---------------------------------------------------------------------------
106
107 def _validate_config(self) -> None:
108 """Validate charm configuration.
109
110 Raises:
111 Exception: if charm configuration is invalid.
112 """
113 # Check mandatory fields
114 for mandatory_field in [
115 "controllers",
116 "accounts",
117 "public-key",
118 ]:
119 if not self.config.get(mandatory_field):
120 raise CharmError(f'missing config: "{mandatory_field}"')
121 # Check if any clouds are set
122 if not self.clouds_set:
123 raise CharmError("no clouds set")
124
125 if self.config.get("model-configs"):
126 try:
127 yaml.safe_load(self.config["model-configs"])
128 except Exception:
129 raise CharmError("invalid yaml format for model-configs")
130
131 def _write_controller_config_files(self) -> None:
132 Path(f"{JUJU_DATA}/ssh").mkdir(parents=True, exist_ok=True)
133 go_cookies = Path(GO_COOKIES)
134 if not go_cookies.is_file():
135 go_cookies.write_text(data="[]")
136 for config, path in JUJU_CONFIGS.items():
137 Path(f"{JUJU_DATA}/{path}").expanduser().write_text(self.config[config])
138
139 def _check_controller(self):
140 loop = asyncio.get_event_loop()
141 # Check controller connectivity
142 loop.run_until_complete(self._check_controller_connectivity())
143 # Check clouds exist in controller
144 loop.run_until_complete(self._check_clouds_in_controller())
145
146 async def _check_controller_connectivity(self):
147 controller = Controller()
148 await controller.connect()
149 await controller.disconnect()
150
151 async def _check_clouds_in_controller(self):
152 controller = Controller()
153 await controller.connect()
154 try:
155 controller_clouds = await controller.clouds()
156 for cloud in self.clouds_set:
157 if f"cloud-{cloud}" not in controller_clouds.clouds:
158 raise CharmError(f"Cloud {cloud} does not exist in the controller")
159 finally:
160 await controller.disconnect()
161
162 def _get_vca_data(self) -> Dict[str, str]:
163 loop = asyncio.get_event_loop()
164 data_from_config = self._get_vca_data_from_config()
165 coro_data_from_controller = loop.run_until_complete(self._get_vca_data_from_controller())
166 vca_data = {**data_from_config, **coro_data_from_controller}
167 logger.debug(f"vca data={vca_data}")
168 return vca_data
169
170 def _get_vca_data_from_config(self) -> Dict[str, str]:
171 data = {"public-key": self.config["public-key"]}
172 if self.config.get("lxd-cloud"):
173 lxd_cloud_parts = self.config["lxd-cloud"].split(":")
174 data.update(
175 {
176 "lxd-cloud": lxd_cloud_parts[0],
177 "lxd-credentials": lxd_cloud_parts[1]
178 if len(lxd_cloud_parts) > 1
179 else lxd_cloud_parts[0],
180 }
181 )
182 if self.config.get("k8s-cloud"):
183 k8s_cloud_parts = self.config["k8s-cloud"].split(":")
184 data.update(
185 {
186 "k8s-cloud": k8s_cloud_parts[0],
187 "k8s-credentials": k8s_cloud_parts[1]
188 if len(k8s_cloud_parts) > 1
189 else k8s_cloud_parts[0],
190 }
191 )
192 if self.config.get("model-configs"):
193 data["model-configs"] = yaml.safe_load(self.config["model-configs"])
194
195 return data
196
197 async def _get_vca_data_from_controller(self) -> Dict[str, str]:
198 controller = Controller()
199 await controller.connect()
200 try:
201 connection = controller._connector._connection
202 return {
203 "endpoints": ",".join(await controller.api_endpoints),
204 "user": connection.username,
205 "secret": connection.password,
206 "cacert": base64.b64encode(connection.cacert.encode("utf-8")).decode("utf-8"),
207 }
208 finally:
209 await controller.disconnect()
210
211
212 if __name__ == "__main__": # pragma: no cover
213 main(VcaIntegratorCharm)