Merge ~ballot/charm-k8s-mm-pd-bot/+git/charm-k8s-mm-pd-bot:reboot into charm-k8s-mm-pd-bot:master
- Git
- lp:~ballot/charm-k8s-mm-pd-bot/+git/charm-k8s-mm-pd-bot
- reboot
- Merge into master
Proposed by
Benjamin Allot
Status: | Work in progress |
---|---|
Proposed branch: | ~ballot/charm-k8s-mm-pd-bot/+git/charm-k8s-mm-pd-bot:reboot |
Merge into: | charm-k8s-mm-pd-bot:master |
Diff against target: |
1484 lines (+841/-283) 11 files modified
Makefile (+6/-2) README.md (+23/-14) actions.yaml (+18/-0) charmcraft.yaml (+10/-0) config.yaml (+4/-23) lib/charms/nginx_ingress_integrator/v0/ingress.py (+398/-0) metadata.yaml (+24/-5) requirements.txt (+1/-1) src/charm.py (+262/-149) tests/unit/scenario.py (+21/-23) tests/unit/test_charm.py (+74/-66) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
MatterMost Pagerduty Bot Charmers | Pending | ||
Review via email: mp+436576@code.launchpad.net |
Commit message
Description of the change
To post a comment you must log in.
Unmerged commits
- c8449a3... by Benjamin Allot
-
Huge revamp using old code and new ones
* Convert to pebble
* Use https://github. com/canonical/ nginx-ingress- integrator- operator
* Revamp of the docs (but not quite finished)
* Tests need to be fixed
* Using charmcraft and resources ibstead of an image path
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/Makefile b/Makefile |
2 | index 0e38a25..6e00e0b 100644 |
3 | --- a/Makefile |
4 | +++ b/Makefile |
5 | @@ -8,7 +8,7 @@ TESTS := $(shell find $(PROJECTPATH)/tests -name "*.py" -o -name "requirements.t |
6 | |
7 | # Rebuild the charm each time we have an update in the sources or tests |
8 | $(CHARM_FILE): $(SOURCES) $(TESTS) $(PROJECTPATH)/requirements.txt |
9 | - charmcraft build -f $(PROJECTPATH) |
10 | + charmcraft pack -v -p $(PROJECTPATH) |
11 | |
12 | all: test |
13 | |
14 | @@ -25,6 +25,10 @@ unittest: $(CHARM_FILE) |
15 | |
16 | test: lint unittest |
17 | |
18 | +build: |
19 | + @rm -f $(CHARM_FILE) |
20 | + charmcraft pack -v -p $(PROJECTPATH) |
21 | + |
22 | clean: |
23 | @echo "Cleaning files" |
24 | @find $(PROJECTPATH) -name "*.pyc" -exec rm -f {} + |
25 | @@ -32,4 +36,4 @@ clean: |
26 | @rm -f $(CHARM_FILE) |
27 | @rm -rf $(PROJECTPATH)/{build,charm} # Delete the build directory |
28 | |
29 | -.PHONY: all lint test unittest clean |
30 | +.PHONY: all lint test unittest clean build |
31 | diff --git a/README.md b/README.md |
32 | index a62e135..d8e5ac0 100644 |
33 | --- a/README.md |
34 | +++ b/README.md |
35 | @@ -2,30 +2,39 @@ |
36 | |
37 | A juju charm deploying Mattermost PagerDuty Bot. |
38 | |
39 | +## Prerequisites |
40 | + |
41 | +To deploy and use this charm you will need: |
42 | + |
43 | + * [Juju and a kubernetes cluster](https://juju.is/docs/olm/installing-juju) |
44 | + * [A PagerDuty account and a General access PagerDuty token](https://support.pagerduty.com/docs/generating-api-keys) |
45 | + * [A Bot account on a Mattermost server](https://docs.mattermost.com/developer/bot-accounts.html) |
46 | + * A kubernetes deployment with an ingress activated. [Microk8s is recommended for a test environment](https://microk8s.io/docs/addon-ingress) |
47 | + |
48 | ## Overview |
49 | |
50 | -This is a k8s workload charm and can only be deployed to to a Juju k8s |
51 | -cloud, attached to a controller using `juju add-k8s`. |
52 | +This charm will deploy a daemon interacting with PagerDuty and Mattermost. |
53 | + |
54 | +Alerts in PagerDuty will be displayed on a Mattermost channel and you will be able to interact with those alerts through the chat. |
55 | + |
56 | +You can quickly acknowledge, resolve or reassign incidents directly from Mattermost. |
57 | |
58 | -A valid configuration file will be needed. See [the example](examples/bot.cfg) |
59 | +The Mattermost PagerDuty Bot uses a configuration file. See [the example](https://git.launchpad.net/charm-k8s-mm-pd-bot/tree/examples/bot.cfg). |
60 | |
61 | ## Getting Started |
62 | |
63 | -For details on the configuration file, [see here](https://charmhub.io/mm-pd-bot-k8s/docs/configuration). |
64 | -To deploy into a juju k8s model, run: |
65 | +For details on the configuration file, [see here](https://charmhub.io/mm-pd-bot/docs/configuration). |
66 | +To deploy into a juju kubernetes model, run: |
67 | |
68 | ``` |
69 | -juju deploy mm-pd-bot-k8s \ |
70 | - --config image_path=rocks.example.com/mm-pd-bot:devel \ |
71 | - --config mm_pd_bot_cfg="$(cat bot.cfg)" \ # Don't forget the double quotes here |
72 | - --config juju-external-hostname=mm-pd-bot.local \ |
73 | - --config kubernetes-ingress-allow-http=true \ |
74 | +juju deploy ch:mm-pd-bot \ |
75 | + --config mm_pd_bot_cfg=@bot.cfg \ |
76 | + --config external_hostname=mm-pd-bot.mydomain.com \ |
77 | mm-pd-bot |
78 | -juju wait |
79 | -juju status |
80 | -juju expose mm-pd-bot |
81 | +juju deploy nginx-ingress-integrator |
82 | +juju relate mm-pd-bot nginx-ingress-integrator |
83 | ``` |
84 | |
85 | --- |
86 | |
87 | -For more details, [see here](https://charmhub.io/mm-pd-bot-k8s/docs) |
88 | +For more details, [see here](https://charmhub.io/mm-pd-bot/docs) |
89 | diff --git a/actions.yaml b/actions.yaml |
90 | new file mode 100644 |
91 | index 0000000..2d869d9 |
92 | --- /dev/null |
93 | +++ b/actions.yaml |
94 | @@ -0,0 +1,18 @@ |
95 | +--- |
96 | +set-pagerduty-credentials: |
97 | + description: Set the general access PagerDuty token |
98 | + params: |
99 | + account: |
100 | + type: string |
101 | + description: The PagerDuty account |
102 | + token: |
103 | + type: string |
104 | + description: The PagerDuty token |
105 | + required: [account, token] |
106 | +set-mattermost-bot-token: |
107 | + description: Set the Mattermost Bot account token |
108 | + params: |
109 | + token: |
110 | + type: string |
111 | + description: The Mattermost bot token |
112 | + required: [token] |
113 | diff --git a/charmcraft.yaml b/charmcraft.yaml |
114 | new file mode 100644 |
115 | index 0000000..e72c4e8 |
116 | --- /dev/null |
117 | +++ b/charmcraft.yaml |
118 | @@ -0,0 +1,10 @@ |
119 | +--- |
120 | +type: "charm" |
121 | + |
122 | +bases: |
123 | + - build-on: |
124 | + - name: ubuntu |
125 | + channel: "20.04" |
126 | + run-on: |
127 | + - name: ubuntu |
128 | + channel: "20.04" |
129 | diff --git a/config.yaml b/config.yaml |
130 | index 1e1e3e8..95e6a90 100644 |
131 | --- a/config.yaml |
132 | +++ b/config.yaml |
133 | @@ -1,27 +1,15 @@ |
134 | options: |
135 | - image_path: |
136 | + mm_pd_bot_cfg: |
137 | type: string |
138 | description: | |
139 | - The location of the image to use, e.g. "registry.example.com/mm-pd-bot:v1". |
140 | + The whole configuration file of the bot is passed as content to this setting. |
141 | |
142 | This setting is required. |
143 | default: '' |
144 | - image_username: |
145 | - type: string |
146 | - description: | |
147 | - The username for accessing the registry specified in image_path. |
148 | - default: '' |
149 | - image_password: |
150 | - type: string |
151 | - description: | |
152 | - The password associated with image_username for accessing the registry specified in image_path. |
153 | - default: '' |
154 | - mm_pd_bot_cfg: |
155 | + external_hostname: |
156 | type: string |
157 | description: | |
158 | - The whole configuration file of the bot is passed as content to this setting. |
159 | - |
160 | - This setting is required. |
161 | + The FQDN used to reach the bot via the webhooks. |
162 | default: '' |
163 | tls_secret_name: |
164 | type: string |
165 | @@ -30,10 +18,3 @@ options: |
166 | |
167 | This setting is ignored unless juju-external-hostname begins with "https://". |
168 | default: '' |
169 | - ingress_whitelist_source_range: |
170 | - type: string |
171 | - description: | |
172 | - A comma-separated list of CIDRs to store in the ingress.kubernetes.io/whitelist-source-range annotation. |
173 | - |
174 | - This can be used to lock down access to Mattermost PagerDuty Bot based on source IP address. |
175 | - default: '' |
176 | diff --git a/lib/charms/nginx_ingress_integrator/v0/ingress.py b/lib/charms/nginx_ingress_integrator/v0/ingress.py |
177 | new file mode 100644 |
178 | index 0000000..4ed6345 |
179 | --- /dev/null |
180 | +++ b/lib/charms/nginx_ingress_integrator/v0/ingress.py |
181 | @@ -0,0 +1,398 @@ |
182 | +# Copyright 2023 Canonical Ltd. |
183 | +# Licensed under the Apache2.0, see LICENCE file in charm source for details. |
184 | +"""Library for the ingress relation. |
185 | + |
186 | +This library contains the Requires and Provides classes for handling |
187 | +the ingress interface. |
188 | + |
189 | +Import `IngressRequires` in your charm, with two required options: |
190 | +- "self" (the charm itself) |
191 | +- config_dict |
192 | + |
193 | +`config_dict` accepts the following keys: |
194 | +- additional-hostnames |
195 | +- limit-rps |
196 | +- limit-whitelist |
197 | +- max-body-size |
198 | +- owasp-modsecurity-crs |
199 | +- owasp-modsecurity-custom-rules |
200 | +- path-routes |
201 | +- retry-errors |
202 | +- rewrite-enabled |
203 | +- rewrite-target |
204 | +- service-hostname (required) |
205 | +- service-name (required) |
206 | +- service-namespace |
207 | +- service-port (required) |
208 | +- session-cookie-max-age |
209 | +- tls-secret-name |
210 | + |
211 | +See [the config section](https://charmhub.io/nginx-ingress-integrator/configure) for descriptions |
212 | +of each, along with the required type. |
213 | + |
214 | +As an example, add the following to `src/charm.py`: |
215 | +``` |
216 | +from charms.nginx_ingress_integrator.v0.ingress import IngressRequires |
217 | + |
218 | +# In your charm's `__init__` method. |
219 | +self.ingress = IngressRequires(self, { |
220 | + "service-hostname": self.config["external_hostname"], |
221 | + "service-name": self.app.name, |
222 | + "service-port": 80, |
223 | + } |
224 | +) |
225 | + |
226 | +# In your charm's `config-changed` handler. |
227 | +self.ingress.update_config({"service-hostname": self.config["external_hostname"]}) |
228 | +``` |
229 | +And then add the following to `metadata.yaml`: |
230 | +``` |
231 | +requires: |
232 | + ingress: |
233 | + interface: ingress |
234 | +``` |
235 | +You _must_ register the IngressRequires class as part of the `__init__` method |
236 | +rather than, for instance, a config-changed event handler, for the relation |
237 | +changed event to be properly handled. |
238 | +""" |
239 | + |
240 | +import copy |
241 | +import logging |
242 | +from typing import Dict |
243 | + |
244 | +from ops.charm import CharmEvents, RelationBrokenEvent, RelationChangedEvent |
245 | +from ops.framework import EventBase, EventSource, Object |
246 | +from ops.model import BlockedStatus |
247 | + |
248 | +INGRESS_RELATION_NAME = "ingress" |
249 | +INGRESS_PROXY_RELATION_NAME = "ingress-proxy" |
250 | + |
251 | +# The unique Charmhub library identifier, never change it |
252 | +LIBID = "db0af4367506491c91663468fb5caa4c" |
253 | + |
254 | +# Increment this major API version when introducing breaking changes |
255 | +LIBAPI = 0 |
256 | + |
257 | +# Increment this PATCH version before using `charmcraft publish-lib` or reset |
258 | +# to 0 if you are raising the major API version |
259 | +LIBPATCH = 14 |
260 | + |
261 | +LOGGER = logging.getLogger(__name__) |
262 | + |
263 | +REQUIRED_INGRESS_RELATION_FIELDS = {"service-hostname", "service-name", "service-port"} |
264 | + |
265 | +OPTIONAL_INGRESS_RELATION_FIELDS = { |
266 | + "additional-hostnames", |
267 | + "limit-rps", |
268 | + "limit-whitelist", |
269 | + "max-body-size", |
270 | + "owasp-modsecurity-crs", |
271 | + "owasp-modsecurity-custom-rules", |
272 | + "path-routes", |
273 | + "retry-errors", |
274 | + "rewrite-target", |
275 | + "rewrite-enabled", |
276 | + "service-namespace", |
277 | + "session-cookie-max-age", |
278 | + "tls-secret-name", |
279 | +} |
280 | + |
281 | +RELATION_INTERFACES_MAPPINGS = { |
282 | + "service-hostname": "host", |
283 | + "service-name": "name", |
284 | + "service-namespace": "model", |
285 | + "service-port": "port", |
286 | +} |
287 | +RELATION_INTERFACES_MAPPINGS_VALUES = {v for v in RELATION_INTERFACES_MAPPINGS.values()} |
288 | + |
289 | + |
290 | +class IngressAvailableEvent(EventBase): |
291 | + """IngressAvailableEvent custom event. |
292 | + |
293 | + This event indicates the Ingress provider is available. |
294 | + """ |
295 | + |
296 | + |
297 | +class IngressProxyAvailableEvent(EventBase): |
298 | + """IngressProxyAvailableEvent custom event. |
299 | + |
300 | + This event indicates the IngressProxy provider is available. |
301 | + """ |
302 | + |
303 | + |
304 | +class IngressBrokenEvent(RelationBrokenEvent): |
305 | + """IngressBrokenEvent custom event. |
306 | + |
307 | + This event indicates the Ingress provider is broken. |
308 | + """ |
309 | + |
310 | + |
311 | +class IngressCharmEvents(CharmEvents): |
312 | + """Custom charm events. |
313 | + |
314 | + Attrs: |
315 | + ingress_available: Event to indicate that Ingress is available. |
316 | + ingress_proxy_available: Event to indicate that IngressProxy is available. |
317 | + ingress_broken: Event to indicate that Ingress is broken. |
318 | + """ |
319 | + |
320 | + ingress_available = EventSource(IngressAvailableEvent) |
321 | + ingress_proxy_available = EventSource(IngressProxyAvailableEvent) |
322 | + ingress_broken = EventSource(IngressBrokenEvent) |
323 | + |
324 | + |
325 | +class IngressRequires(Object): |
326 | + """This class defines the functionality for the 'requires' side of the 'ingress' relation. |
327 | + |
328 | + Hook events observed: |
329 | + - relation-changed |
330 | + |
331 | + Attrs: |
332 | + model: Juju model where the charm is deployed. |
333 | + config_dict: Contains all the configuration options for Ingress. |
334 | + """ |
335 | + |
336 | + def __init__(self, charm, config_dict): |
337 | + """Init function for the IngressRequires class. |
338 | + |
339 | + Args: |
340 | + charm: The charm that requires the ingress relation. |
341 | + config_dict: Contains all the configuration options for Ingress. |
342 | + """ |
343 | + super().__init__(charm, INGRESS_RELATION_NAME) |
344 | + |
345 | + self.framework.observe( |
346 | + charm.on[INGRESS_RELATION_NAME].relation_changed, self._on_relation_changed |
347 | + ) |
348 | + |
349 | + # Set default values. |
350 | + default_relation_fields = { |
351 | + "service-namespace": self.model.name, |
352 | + } |
353 | + config_dict.update( |
354 | + (key, value) |
355 | + for key, value in default_relation_fields.items() |
356 | + if key not in config_dict or not config_dict[key] |
357 | + ) |
358 | + |
359 | + self.config_dict = self._convert_to_relation_interface(config_dict) |
360 | + |
361 | + @staticmethod |
362 | + def _convert_to_relation_interface(config_dict: Dict) -> Dict: |
363 | + """Create a new relation dict that conforms with charm-relation-interfaces. |
364 | + |
365 | + Args: |
366 | + config_dict: Ingress configuration that doesn't conform with charm-relation-interfaces. |
367 | + |
368 | + Returns: |
369 | + The Ingress configuration conforming with charm-relation-interfaces. |
370 | + """ |
371 | + config_dict = copy.copy(config_dict) |
372 | + config_dict.update( |
373 | + (key, config_dict[old_key]) |
374 | + for old_key, key in RELATION_INTERFACES_MAPPINGS.items() |
375 | + if old_key in config_dict and config_dict[old_key] |
376 | + ) |
377 | + return config_dict |
378 | + |
379 | + def _config_dict_errors(self, config_dict: Dict, update_only: bool = False) -> bool: |
380 | + """Check our config dict for errors. |
381 | + |
382 | + Args: |
383 | + config_dict: Contains all the configuration options for Ingress. |
384 | + update_only: If the charm needs to update only existing keys. |
385 | + |
386 | + Returns: |
387 | + If we need to update the config dict ot not. |
388 | + """ |
389 | + blocked_message = "Error in ingress relation, check `juju debug-log`" |
390 | + unknown = [ |
391 | + config_key |
392 | + for config_key in config_dict |
393 | + if config_key |
394 | + not in REQUIRED_INGRESS_RELATION_FIELDS |
395 | + | OPTIONAL_INGRESS_RELATION_FIELDS |
396 | + | RELATION_INTERFACES_MAPPINGS_VALUES |
397 | + ] |
398 | + if unknown: |
399 | + LOGGER.error( |
400 | + "Ingress relation error, unknown key(s) in config dictionary found: %s", |
401 | + ", ".join(unknown), |
402 | + ) |
403 | + self.model.unit.status = BlockedStatus(blocked_message) |
404 | + return True |
405 | + if not update_only: |
406 | + missing = tuple( |
407 | + config_key |
408 | + for config_key in REQUIRED_INGRESS_RELATION_FIELDS |
409 | + if config_key not in self.config_dict |
410 | + ) |
411 | + if missing: |
412 | + LOGGER.error( |
413 | + "Ingress relation error, missing required key(s) in config dictionary: %s", |
414 | + ", ".join(sorted(missing)), |
415 | + ) |
416 | + self.model.unit.status = BlockedStatus(blocked_message) |
417 | + return True |
418 | + return False |
419 | + |
420 | + def _on_relation_changed(self, event: RelationChangedEvent) -> None: |
421 | + """Handle the relation-changed event. |
422 | + |
423 | + Args: |
424 | + event: Event triggering the relation-changed hook for the relation. |
425 | + """ |
426 | + # `self.unit` isn't available here, so use `self.model.unit`. |
427 | + if self.model.unit.is_leader(): |
428 | + if self._config_dict_errors(config_dict=self.config_dict): |
429 | + return |
430 | + event.relation.data[self.model.app].update( |
431 | + (key, str(self.config_dict[key])) for key in self.config_dict |
432 | + ) |
433 | + |
434 | + def update_config(self, config_dict: Dict) -> None: |
435 | + """Allow for updates to relation. |
436 | + |
437 | + Args: |
438 | + config_dict: Contains all the configuration options for Ingress. |
439 | + |
440 | + Attrs: |
441 | + config_dict: Contains all the configuration options for Ingress. |
442 | + """ |
443 | + if self.model.unit.is_leader(): |
444 | + self.config_dict = self._convert_to_relation_interface(config_dict) |
445 | + if self._config_dict_errors(self.config_dict, update_only=True): |
446 | + return |
447 | + relation = self.model.get_relation(INGRESS_RELATION_NAME) |
448 | + if relation: |
449 | + for key in self.config_dict: |
450 | + relation.data[self.model.app][key] = str(self.config_dict[key]) |
451 | + |
452 | + |
453 | +class IngressBaseProvides(Object): |
454 | + """Parent class for IngressProvides and IngressProxyProvides. |
455 | + |
456 | + Attrs: |
457 | + model: Juju model where the charm is deployed. |
458 | + """ |
459 | + |
460 | + def _on_relation_changed(self, event: RelationChangedEvent) -> None: |
461 | + """Handle a change to the ingress/ingress-proxy relation. |
462 | + |
463 | + Confirm we have the fields we expect to receive. |
464 | + |
465 | + Args: |
466 | + event: Event triggering the relation-changed hook for the relation. |
467 | + """ |
468 | + # `self.unit` isn't available here, so use `self.model.unit`. |
469 | + if not self.model.unit.is_leader(): |
470 | + return |
471 | + |
472 | + relation_name = event.relation.name |
473 | + |
474 | + if not event.relation.data[event.app]: |
475 | + LOGGER.info( |
476 | + "%s hasn't finished configuring, waiting until relation is changed again.", |
477 | + relation_name, |
478 | + ) |
479 | + return |
480 | + |
481 | + ingress_data = { |
482 | + field: event.relation.data[event.app].get(field) |
483 | + for field in REQUIRED_INGRESS_RELATION_FIELDS | OPTIONAL_INGRESS_RELATION_FIELDS |
484 | + } |
485 | + |
486 | + missing_fields = sorted( |
487 | + field for field in REQUIRED_INGRESS_RELATION_FIELDS if ingress_data.get(field) is None |
488 | + ) |
489 | + |
490 | + if missing_fields: |
491 | + LOGGER.warning( |
492 | + "Missing required data fields for %s relation: %s", |
493 | + relation_name, |
494 | + ", ".join(missing_fields), |
495 | + ) |
496 | + self.model.unit.status = BlockedStatus( |
497 | + f"Missing fields for {relation_name}: {', '.join(missing_fields)}" |
498 | + ) |
499 | + |
500 | + if relation_name == INGRESS_RELATION_NAME: |
501 | + # Conform to charm-relation-interfaces. |
502 | + if "name" in ingress_data and "port" in ingress_data: |
503 | + name = ingress_data["name"] |
504 | + port = ingress_data["port"] |
505 | + else: |
506 | + name = ingress_data["service-name"] |
507 | + port = ingress_data["service-port"] |
508 | + event.relation.data[self.model.app]["url"] = f"http://{name}:{port}/" |
509 | + |
510 | + # Create an event that our charm can use to decide it's okay to |
511 | + # configure the ingress. |
512 | + self.charm.on.ingress_available.emit() |
513 | + elif relation_name == INGRESS_PROXY_RELATION_NAME: |
514 | + self.charm.on.ingress_proxy_available.emit() |
515 | + |
516 | + |
517 | +class IngressProvides(IngressBaseProvides): |
518 | + """Class containing the functionality for the 'provides' side of the 'ingress' relation. |
519 | + |
520 | + Attrs: |
521 | + charm: The charm that provides the ingress relation. |
522 | + |
523 | + Hook events observed: |
524 | + - relation-changed |
525 | + """ |
526 | + |
527 | + def __init__(self, charm): |
528 | + """Init function for the IngressProvides class. |
529 | + |
530 | + Args: |
531 | + charm: The charm that provides the ingress relation. |
532 | + """ |
533 | + super().__init__(charm, INGRESS_RELATION_NAME) |
534 | + # Observe the relation-changed hook event and bind |
535 | + # self.on_relation_changed() to handle the event. |
536 | + self.framework.observe( |
537 | + charm.on[INGRESS_RELATION_NAME].relation_changed, self._on_relation_changed |
538 | + ) |
539 | + self.framework.observe( |
540 | + charm.on[INGRESS_RELATION_NAME].relation_broken, self._on_relation_broken |
541 | + ) |
542 | + self.charm = charm |
543 | + |
544 | + def _on_relation_broken(self, event: RelationBrokenEvent) -> None: |
545 | + """Handle a relation-broken event in the ingress relation. |
546 | + |
547 | + Args: |
548 | + event: Event triggering the relation-broken hook for the relation. |
549 | + """ |
550 | + if not self.model.unit.is_leader(): |
551 | + return |
552 | + |
553 | + # Create an event that our charm can use to remove the ingress resource. |
554 | + self.charm.on.ingress_broken.emit(event.relation) |
555 | + |
556 | + |
557 | +class IngressProxyProvides(IngressBaseProvides): |
558 | + """Class containing the functionality for the 'provides' side of the 'ingress-proxy' relation. |
559 | + |
560 | + Attrs: |
561 | + charm: The charm that provides the ingress-proxy relation. |
562 | + |
563 | + Hook events observed: |
564 | + - relation-changed |
565 | + """ |
566 | + |
567 | + def __init__(self, charm): |
568 | + """Init function for the IngressProxyProvides class. |
569 | + |
570 | + Args: |
571 | + charm: The charm that provides the ingress-proxy relation. |
572 | + """ |
573 | + super().__init__(charm, INGRESS_PROXY_RELATION_NAME) |
574 | + # Observe the relation-changed hook event and bind |
575 | + # self.on_relation_changed() to handle the event. |
576 | + self.framework.observe( |
577 | + charm.on[INGRESS_PROXY_RELATION_NAME].relation_changed, self._on_relation_changed |
578 | + ) |
579 | + self.charm = charm |
580 | diff --git a/metadata.yaml b/metadata.yaml |
581 | index 427f0c7..603c650 100644 |
582 | --- a/metadata.yaml |
583 | +++ b/metadata.yaml |
584 | @@ -1,14 +1,33 @@ |
585 | -name: mm-pd-bot-k8s |
586 | +name: mm-pd-bot |
587 | + |
588 | display-name: Mattermost PagerDuty Bot |
589 | -docs: https://discourse.charmhub.io/t/mattermost-pagerduty-bot-docs-index/4596 |
590 | + |
591 | +docs: https://charmhub.io/mm-pd-bot |
592 | + |
593 | summary: MatterMost Pagerduty Bot charm |
594 | + |
595 | maintainers: |
596 | - launchpad.net/~mm-pd-bot-charmers |
597 | + |
598 | description: | |
599 | A charm which deploys Mattermost Pagerduty Bot on kubernetes. |
600 | Mattermost is a flexible, open source messaging platform that enables |
601 | secure team collaboration. |
602 | This bot manages incidents on PagerDuty through commands sent to a MatterMost channel |
603 | -min-juju-version: 2.8.0 # charm storage in state |
604 | -series: |
605 | - - kubernetes |
606 | + |
607 | +requires: |
608 | + ingress: |
609 | + interface: ingress |
610 | + |
611 | +assumes: |
612 | + - juju < 3.0 |
613 | + - k8s-api |
614 | + |
615 | +containers: |
616 | + mm-pd-bot: |
617 | + resource: mm-pd-bot-image |
618 | + |
619 | +resources: |
620 | + mm-pd-bot-image: |
621 | + type: oci-image |
622 | + description: Docker image for Mattermost PagerDuty Bot to run |
623 | diff --git a/requirements.txt b/requirements.txt |
624 | index eaae2b5..b8a4bc5 100644 |
625 | --- a/requirements.txt |
626 | +++ b/requirements.txt |
627 | @@ -1 +1 @@ |
628 | -ops>=0.8 |
629 | +ops>=1.5.0 |
630 | diff --git a/src/charm.py b/src/charm.py |
631 | index 7b2807f..9d45a93 100755 |
632 | --- a/src/charm.py |
633 | +++ b/src/charm.py |
634 | @@ -3,10 +3,12 @@ |
635 | # Copyright 2020 Canonical Ltd. |
636 | # Licensed under the GPLv3, see LICENCE file for details. |
637 | |
638 | +import collections |
639 | import configparser |
640 | import logging |
641 | -from urllib.parse import urlparse |
642 | +import io |
643 | |
644 | +from charms.nginx_ingress_integrator.v0.ingress import IngressRequires |
645 | import ops |
646 | from ops.charm import CharmBase |
647 | from ops.main import main |
648 | @@ -14,15 +16,16 @@ from ops.model import ( |
649 | ActiveStatus, |
650 | BlockedStatus, |
651 | ) |
652 | +from ops.pebble import ConnectionError, PathError, ServiceStatus |
653 | |
654 | |
655 | -logger = logging.getLogger() |
656 | +logger = logging.getLogger(__name__) |
657 | |
658 | -REQUIRED_JUJU_SETTINGS = ["image_path", "mm_pd_bot_cfg"] |
659 | +REQUIRED_JUJU_SETTINGS = ["external_hostname", "mm_pd_bot_cfg"] |
660 | REQUIRED_CFG_SETTINGS = { |
661 | - "PagerDuty": ["account", "api-token", "private-channel", "public-channel"], |
662 | + "PagerDuty": ["private-channel", "public-channel"], |
663 | "Prometheus": ["max_cache_size"], |
664 | - "httpd": ["hostname", "listen-ip", "listen-port", "magic-uuid"], |
665 | + "httpd": ["listen-ip", "listen-port", "magic-uuid"], |
666 | "MattermostBot": ["bot_url", "bot_team"], |
667 | # Those sections can be empty |
668 | "nickname to email": [], |
669 | @@ -30,20 +33,219 @@ REQUIRED_CFG_SETTINGS = { |
670 | "PagerDuty service to Mattermost Channel": [], |
671 | } |
672 | BOT_CFG_SECTION = "MattermostBot" |
673 | +# TODO: get it from the metadata.yaml dynamically |
674 | +CONTAINER_NAME = "mm-pd-bot" |
675 | |
676 | |
677 | class MmPdBotK8sCharmConfigError(Exception): |
678 | """Exception when configuration is bad.""" |
679 | |
680 | |
681 | +class MmPdBotPebbleLayerError(Exception): |
682 | + """Exception when configuration is bad.""" |
683 | + |
684 | + |
685 | class MmPdBotK8sCharm(CharmBase): |
686 | def __init__(self, *args) -> None: |
687 | super().__init__(*args) |
688 | |
689 | - self.framework.observe(self.on.start, self.configure_pod) |
690 | - self.framework.observe(self.on.config_changed, self.configure_pod) |
691 | - self.framework.observe(self.on.leader_elected, self.configure_pod) |
692 | - self.framework.observe(self.on.upgrade_charm, self.configure_pod) |
693 | + # PEBBLE: on.start probably useless |
694 | + # Need the following actions |
695 | + # 1. Validate configuration (everything except secrets comapred to before) |
696 | + # 2. Check ingress availability. block on lack of relation |
697 | + # 3. Check secrets availability. Block on lack of tokens for PD and MM |
698 | + # 4. 3. must be set via an action in a second time, for now still in the conf file. |
699 | + # 5. Application leader is relevant for any action towards MM/PD. No need to do every actions several times. |
700 | + # 6. Upgrade-charm does not call config-changed so it needs to be handled |
701 | + |
702 | + # self.framework.observe(self.on.start, self.configure_pod) |
703 | + |
704 | + # No need for install as config-changed is called right after |
705 | + # https://juju.is/docs/sdk/events |
706 | + self.framework.observe(self.on.config_changed, self.configure_workload) |
707 | + # The name of the event is the key in metadata.yaml for "containers" + _pebble_ready |
708 | + # See https://ops.readthedocs.io/en/latest/#ops.charm.PebbleReadyEvent |
709 | + self.framework.observe(self.on.mm_pd_bot_pebble_ready, self._on_pebble_ready) |
710 | + self.framework.observe(self.on.ingress_relation_changed, self._configure_ingress) |
711 | + self.framework.observe(self.on.set_mattermost_bot_token_action, self._set_mm_bot_token) |
712 | + self.framework.observe(self.on.set_pagerduty_credentials_action, self._set_pagerduty_credentials) |
713 | + |
714 | + self.ingress = IngressRequires(self, {}) |
715 | + self.bot_config = self._load_mm_pd_bot_configuration(self.config["mm_pd_bot_cfg"]) |
716 | + |
717 | + def _configure_ingress(self, event: ops.charm.EventBase) -> None: |
718 | + """Configure the ingress relation.""" |
719 | + |
720 | + if not self._check_charm_config(): |
721 | + logger.info("Configuration is not ready yet. Deferring ingress configuration.") |
722 | + event.defer() |
723 | + return |
724 | + container_port = self.bot_config.getint("httpd", "listen-port") |
725 | + # magic_uuid = self.bot_config.get("httpd", "magic-uuid") |
726 | + ingress_data = { |
727 | + "service-hostname": self.config["external_hostname"], |
728 | + "service-name": self.app.name, |
729 | + "service-port": container_port, |
730 | + # Default is empty string and the ingress won't configure it if that's the case |
731 | + # TODO: Check how a switch from 443 to 80 behave on ingress side |
732 | + "tls-secret-name": self.config["tls_secret_name"], |
733 | + } |
734 | + |
735 | + logger.debug("Ingress relation data: %s", ingress_data) |
736 | + self.ingress.update_config(ingress_data) |
737 | + |
738 | + def _get_pebble_layers(self) -> dict: |
739 | + """Configure pebble layers. |
740 | + |
741 | + :returns: a dict representing the various pebble layers. |
742 | + """ |
743 | + |
744 | + mm_pd_bot_layers = { |
745 | + "summary": "Pebble layers for Mattermost PagerDuty Bot", |
746 | + "description": "All pebble layers for the various mm-pd-bot workload", |
747 | + "services": { |
748 | + "mm-pd-bot": { |
749 | + "override": "replace", |
750 | + "summary": "mm-pd-bot", |
751 | + "command": "bash -c '/app/bot.py --log-level debug /app/bot.cfg &>/proc/1/fd/1'", |
752 | + "startup": "enabled", |
753 | + } |
754 | + }, |
755 | + } |
756 | + |
757 | + return mm_pd_bot_layers |
758 | + |
759 | + def _set_pagerduty_credentials(self, event: ops.charm.ActionEvent): |
760 | + """Set the PagerDuty token in pebble layer |
761 | + |
762 | + :param event: ActionEvent |
763 | + """ |
764 | + if not self._is_pebble_ready(): |
765 | + logger.info("Pebble is not ready.") |
766 | + event.defer() |
767 | + return |
768 | + account: str = event.params.get("account") |
769 | + token: str = event.params.get("token") |
770 | + msg: str = "Setting PagerDuty credentials" |
771 | + logger.debug(msg) |
772 | + event.log(msg) |
773 | + self.bot_config["PagerDuty"]["account"] = account |
774 | + self.bot_config["PagerDuty"]["api-token"] = token |
775 | + self.configure_workload(event) |
776 | + |
777 | + def _set_mm_bot_token(self, event: ops.charm.ActionEvent): |
778 | + """Set the Mattermost Bot token in pebble layer |
779 | + |
780 | + :param event: ActionEvent |
781 | + """ |
782 | + if not self._is_pebble_ready(): |
783 | + logger.info("Pebble is not ready.") |
784 | + event.defer() |
785 | + return |
786 | + token: str = event.params.get("token") |
787 | + msg: str = "Setting Mattermost Bot token" |
788 | + logger.debug(msg) |
789 | + event.log(msg) |
790 | + self.bot_config[BOT_CFG_SECTION]["bot_token"] = token |
791 | + self.configure_workload(event) |
792 | + |
793 | + def _load_current_config(self): |
794 | + """Load the current mm-pd-bot configuration.""" |
795 | + try: |
796 | + deployed_config = self.container.pull("/app/bot.cfg").read() |
797 | + self.current_config = self._load_mm_pd_bot_configuration(deployed_config) |
798 | + except PathError: |
799 | + logger.debug("Current configuration /app/bot.cfg does not exist.") |
800 | + self.current_config = None |
801 | + |
802 | + def _on_pebble_ready(self, event: ops.charm.EventBase) -> None: |
803 | + """Handle PebbleReady event |
804 | + |
805 | + :param event: Event fired. |
806 | + """ |
807 | + if not self._is_pebble_ready(): |
808 | + logger.error("PebbleReady received but pebble is not ready.") |
809 | + event.defer() |
810 | + return |
811 | + logger.info("Pebble is ready.") |
812 | + self.configure_workload(event) |
813 | + |
814 | + def _is_pebble_ready(self) -> bool: |
815 | + """Check that Pebble is ready. |
816 | + |
817 | + Try to get a plan from Pebble. If that fails, it will throw an exception and we will know it was not ready. |
818 | + :returns: True if Pebble is ready. False otherwise. |
819 | + """ |
820 | + logger.debug("Checking Pebble status.") |
821 | + self.container = self.unit.get_container(CONTAINER_NAME) |
822 | + try: |
823 | + self.container.get_plan().to_dict().get("services", {}) |
824 | + logger.debug("Pebble is ready.") |
825 | + logger.debug("Load current configuration if it exists") |
826 | + self._load_current_config() |
827 | + except ConnectionError as exc: |
828 | + logger.info("Pebble is not ready yet.") |
829 | + logger.debug(str(exc)) |
830 | + return False |
831 | + return True |
832 | + |
833 | + def configure_workload(self, event: ops.charm.EventBase) -> None: |
834 | + """Configure the workload container. |
835 | + |
836 | + Configure the workload container and the application once pebble is started and ready. |
837 | + |
838 | + :param event: Event fired. |
839 | + """ |
840 | + |
841 | + if not self._is_pebble_ready(): |
842 | + logger.info("Aborting workload container configuration.") |
843 | + if event: |
844 | + event.defer() |
845 | + return |
846 | + |
847 | + if not self._check_charm_config(): |
848 | + logger.info("Charm configuration is not ready.") |
849 | + return |
850 | + |
851 | + mm_pd_bot_layers = self._get_pebble_layers() |
852 | + |
853 | + # Ensure the ingress relation has the external hostname. |
854 | + self._configure_ingress(event) |
855 | + |
856 | + restart = False |
857 | + if self.bot_config != self.current_config: |
858 | + logger.info("Configuration needs to be updated. Restarting mm-pd-bot...") |
859 | + with io.StringIO() as current, io.StringIO() as new: |
860 | + if self.current_config: |
861 | + self.current_config.write(current) |
862 | + current.seek(0) |
863 | + logger.debug("Current config : %s", current.read()) |
864 | + self.bot_config.write(new) |
865 | + new.seek(0) |
866 | + logger.debug("New config : %s", new.read()) |
867 | + restart = True |
868 | + |
869 | + # Compare the current plan with the one we generated |
870 | + services = self.container.get_plan().to_dict().get("services", {}) |
871 | + if services != mm_pd_bot_layers["services"]: |
872 | + logger.info("Adding pebble layer: %s", mm_pd_bot_layers) |
873 | + self.container.add_layer("mm_pd_bot", mm_pd_bot_layers, combine=True) |
874 | + |
875 | + if restart: |
876 | + # TODO Figure out the UID and lessen the permission or inject environment variables) |
877 | + with io.StringIO() as buffer: |
878 | + logger.debug("Pushing file bot.cfg to /app/bot.cfg") |
879 | + self.bot_config.write(buffer) |
880 | + buffer.seek(0) |
881 | + self.container.push(path="/app/bot.cfg", permissions=0o644, source=buffer.read()) |
882 | + status = self.container.get_service(CONTAINER_NAME) |
883 | + if status.current == ServiceStatus.ACTIVE: |
884 | + logger.debug("Pebble service %s is active. Stopping it", CONTAINER_NAME) |
885 | + self.container.stop(CONTAINER_NAME) |
886 | + logger.debug("Starting pebble service %s", CONTAINER_NAME) |
887 | + self.container.start(CONTAINER_NAME) |
888 | + |
889 | + self.unit.status = ActiveStatus() |
890 | |
891 | def _validate_bot_creds(self) -> None: |
892 | """Validate the authentication method input for the bot. |
893 | @@ -52,15 +254,38 @@ class MmPdBotK8sCharm(CharmBase): |
894 | bot_password. |
895 | """ |
896 | bot_token = self.bot_config.get(BOT_CFG_SECTION, "bot_token", fallback=None) |
897 | - bot_login = self.bot_config.get(BOT_CFG_SECTION, "bot_login", fallback=None) |
898 | - bot_password = self.bot_config.get(BOT_CFG_SECTION, "bot_password", fallback=None) |
899 | - |
900 | - if not bot_token and (not bot_login or not bot_password): |
901 | - err_msg = "bot_token or bot_login/bot_password in {0} section required".format(BOT_CFG_SECTION) |
902 | + pd_account = self.bot_config.get("PagerDuty", "account", fallback=None) |
903 | + pd_token = self.bot_config.get("PagerDuty", "api-token", fallback=None) |
904 | + try: |
905 | + cur_bot_token = self.current_config.get(BOT_CFG_SECTION, "bot_token", fallback=None) |
906 | + cur_pd_account = self.current_config.get("PagerDuty", "account", fallback=None) |
907 | + cur_pd_token = self.current_config.get("PagerDuty", "api-token", fallback=None) |
908 | + except AttributeError: # self.current_config is None |
909 | + cur_bot_token = None |
910 | + cur_pd_account = None |
911 | + cur_pd_token = None |
912 | + |
913 | + # Load the values present in current configuration if not specified. |
914 | + # This way we retrieve previously set values and overwrite when using an action |
915 | + if not bot_token and cur_bot_token: |
916 | + logger.debug("Current secret Mattermost bot_token already set") |
917 | + self.bot_config[BOT_CFG_SECTION]["bot_token"] = cur_bot_token |
918 | + bot_token = cur_bot_token |
919 | + if not pd_account and cur_pd_account: |
920 | + logger.debug("Current secret PagerDuty account already set") |
921 | + self.bot_config["PagerDuty"]["account"] = cur_pd_account |
922 | + pd_account = cur_pd_account |
923 | + if not pd_token and cur_pd_token: |
924 | + logger.debug("Current secret PagerDuty api-token already set") |
925 | + self.bot_config["PagerDuty"]["api-token"] = cur_pd_token |
926 | + pd_token = cur_pd_token |
927 | + |
928 | + if not bot_token: |
929 | + err_msg = 'bot_token in {0} section required. See juju action "set-mattermost-bot-token".' |
930 | logger.error(err_msg) |
931 | raise MmPdBotK8sCharmConfigError(err_msg) |
932 | - if bot_token and bot_login: |
933 | - err_msg = "bot_token and bot_login are both set. Pick one of them" |
934 | + if not pd_account or not pd_token: |
935 | + err_msg = 'PagerDuty credentials incomplete. See juju action "set-pagerduty-credentials".' |
936 | logger.error(err_msg) |
937 | raise MmPdBotK8sCharmConfigError(err_msg) |
938 | |
939 | @@ -71,7 +296,7 @@ class MmPdBotK8sCharm(CharmBase): |
940 | """ |
941 | errors = [] |
942 | for required in REQUIRED_JUJU_SETTINGS: |
943 | - if not self.model.config[required]: |
944 | + if not self.config[required]: |
945 | logger.error("Required setting empty: %s", required) |
946 | errors.append(required) |
947 | if errors: |
948 | @@ -118,156 +343,44 @@ class MmPdBotK8sCharm(CharmBase): |
949 | :returns: Returns a configparser.ConfigParser object with the configuration. |
950 | :raises: MmPdBotK8sCharmConfigError if the configuration is invalid. |
951 | """ |
952 | - config = configparser.ConfigParser(allow_no_value=True) |
953 | + config = configparser.ConfigParser(allow_no_value=True, dict_type=collections.OrderedDict) |
954 | try: |
955 | config.read_string(configuration) |
956 | - except configparser.Error as exc: |
957 | - err_msg = "Error while parsing mm_pd_bot_cfg setting" |
958 | + except configparser.Error: |
959 | + err_msg = "Error while parsing mm_pd_bot_cfg setting. \ |
960 | + Check https://git.launchpad.net/charm-k8s-mm-pd-bot/tree/examples/bot.cfg." |
961 | logger.error(err_msg) |
962 | - raise MmPdBotK8sCharmConfigError(err_msg) from exc |
963 | + self.unit.status = BlockedStatus(err_msg) |
964 | + return |
965 | + for section in config._sections: |
966 | + config._sections[section] = collections.OrderedDict( |
967 | + sorted(config._sections[section].items(), key=lambda t: t[0]) |
968 | + ) |
969 | + config._sections = collections.OrderedDict(sorted(config._sections.items(), key=lambda t: t[0])) |
970 | return config |
971 | |
972 | def _validate_mm_pd_bot_configuration(self) -> None: |
973 | """Check the configuration part related to mm_pd_bot configuration.""" |
974 | - self.bot_config = self._load_mm_pd_bot_configuration(self.model.config["mm_pd_bot_cfg"]) |
975 | + logger.debug("Validate mm-pd-bot configuration.") |
976 | self._validate_mm_pd_bot_configuration_content() |
977 | + logger.debug("Validate mm-pd-bot secrets.") |
978 | self._validate_bot_creds() |
979 | |
980 | - def _check_for_config_problems(self) -> None: |
981 | + def _check_charm_config(self) -> bool: |
982 | """Check that the mandatory configuration items are all set. |
983 | |
984 | Each method will raise an exception if an error is detected. |
985 | - """ |
986 | - self._check_juju_settings() |
987 | - self._validate_mm_pd_bot_configuration() |
988 | - |
989 | - def _make_pod_config(self) -> dict: |
990 | - """Return an envConfig with some core configuration. |
991 | - |
992 | - :returns: A dictionary used for envConfig in podspec |
993 | - """ |
994 | - config = self.model.config |
995 | - pod_config = { |
996 | - "MM_PD_BOT_CFG": config["mm_pd_bot_cfg"], |
997 | - } |
998 | - |
999 | - return pod_config |
1000 | - |
1001 | - def _update_pod_spec_for_k8s_ingress(self, pod_spec: dict) -> None: |
1002 | - """Add resources to pod_spec configuring site ingress, if needed. |
1003 | - |
1004 | - :param pod_spec: pod spec v3 as defined by juju. |
1005 | - """ |
1006 | - |
1007 | - hostname = self.bot_config.get("httpd", "hostname") |
1008 | - container_port = self.bot_config.getint("httpd", "listen-port") |
1009 | - magic_uuid = self.bot_config.get("httpd", "magic-uuid") |
1010 | - tls_secret_name = self.model.config.get("tls_secret_name", None) |
1011 | - |
1012 | - if tls_secret_name: |
1013 | - scheme = "https" |
1014 | - else: |
1015 | - scheme = "http" |
1016 | - |
1017 | - bot_listening_url = "{0}://{1}:{2}/{3}/".format(scheme, hostname, container_port, magic_uuid) |
1018 | - bot_listening_url_parsed = urlparse(bot_listening_url) |
1019 | - |
1020 | - annotations = {} |
1021 | - ingress = { |
1022 | - "name": "{}-ingress".format(self.app.name), |
1023 | - "spec": { |
1024 | - "rules": [ |
1025 | - { |
1026 | - "host": bot_listening_url_parsed.hostname, |
1027 | - "http": { |
1028 | - "paths": [ |
1029 | - { |
1030 | - "path": "/{0}/".format(magic_uuid), |
1031 | - "backend": {"serviceName": self.app.name, "servicePort": container_port}, |
1032 | - } |
1033 | - ] |
1034 | - }, |
1035 | - } |
1036 | - ] |
1037 | - }, |
1038 | - } |
1039 | - if tls_secret_name: |
1040 | - ingress["spec"]["tls"] = [ |
1041 | - {"hosts": [bot_listening_url_parsed.hostname], "secretName": tls_secret_name}, |
1042 | - ] |
1043 | - else: |
1044 | - annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false" |
1045 | - |
1046 | - ingress_whitelist_source_range = self.model.config.get("ingress_whitelist_source_range", None) |
1047 | - if ingress_whitelist_source_range: |
1048 | - annotations["nginx.ingress.kubernetes.io/whitelist-source-range"] = ingress_whitelist_source_range |
1049 | - |
1050 | - if annotations: |
1051 | - ingress["annotations"] = annotations |
1052 | - |
1053 | - # Due to https://github.com/canonical/operator/issues/293 we |
1054 | - # can't use pod.set_spec's k8s_resources argument. |
1055 | - resources = pod_spec.get("kubernetesResources", {}) |
1056 | - resources["ingressResources"] = [ingress] |
1057 | - pod_spec["kubernetesResources"] = resources |
1058 | - |
1059 | - def _make_pod_spec(self) -> None: |
1060 | - """Create a pod spec with some core configuration.""" |
1061 | - |
1062 | - config = self.model.config |
1063 | - container_port = self.bot_config.getint("httpd", "listen-port") |
1064 | - # TODO: The bot replies 501 on HTTP GET so until we have something to probe readiness, magic-uuid is not needed |
1065 | - # here |
1066 | - # magic_uuid = self.bot_config.get('httpd', 'magic-uuid') |
1067 | - image_details = { |
1068 | - "imagePath": config["image_path"], |
1069 | - } |
1070 | - if config.get("image_username", None): |
1071 | - image_details.update({"username": config["image_username"], "password": config["image_password"]}) |
1072 | - pod_config = self._make_pod_config() |
1073 | - |
1074 | - return { |
1075 | - "version": 3, # otherwise resources are ignored |
1076 | - "containers": [ |
1077 | - { |
1078 | - "name": self.app.name, |
1079 | - "imageDetails": image_details, |
1080 | - # TODO: debatable. The idea is that if you want to force an update with the same image name, you |
1081 | - # don't need to empty kubelet cache on each node to have the right version. |
1082 | - # This implies a performance drop upon start. |
1083 | - "imagePullPolicy": "Always", |
1084 | - "ports": [{"containerPort": container_port, "protocol": "TCP"}], |
1085 | - "envConfig": pod_config, |
1086 | - # TODO: Add readiness probe in the bot. |
1087 | - # 'kubernetes': { |
1088 | - # 'readinessProbe': {'httpGet': {'path': '/{0}/'.format(magic_uuid), 'port': container_port}}, |
1089 | - # }, |
1090 | - } |
1091 | - ], |
1092 | - } |
1093 | - |
1094 | - def configure_pod(self, event: ops.framework.EventBase) -> None: |
1095 | - """Assemble the pod spec and apply it, if possible. |
1096 | - |
1097 | - :param event: Event that triggered the method. |
1098 | + :returns: True if the configuration is valid. False otherwise. |
1099 | """ |
1100 | |
1101 | - if not self.unit.is_leader(): |
1102 | - self.unit.status = ActiveStatus() |
1103 | - return |
1104 | try: |
1105 | - self._check_for_config_problems() |
1106 | + self._check_juju_settings() |
1107 | + self._validate_mm_pd_bot_configuration() |
1108 | except MmPdBotK8sCharmConfigError as exc: |
1109 | self.unit.status = BlockedStatus(str(exc)) |
1110 | - return |
1111 | - |
1112 | - logger.info("Assembling pod spec") |
1113 | - pod_spec = self._make_pod_spec() |
1114 | - self._update_pod_spec_for_k8s_ingress(pod_spec) |
1115 | - logger.info("Setting pod spec") |
1116 | - self.model.pod.set_spec(pod_spec) |
1117 | - self.unit.status = ActiveStatus() |
1118 | + return False |
1119 | + return True |
1120 | |
1121 | |
1122 | if __name__ == "__main__": # pragma: no cover |
1123 | - main(MmPdBotK8sCharm, use_juju_for_storage=True) |
1124 | + main(MmPdBotK8sCharm) |
1125 | diff --git a/tests/unit/scenario.py b/tests/unit/scenario.py |
1126 | index e46c189..212ca54 100644 |
1127 | --- a/tests/unit/scenario.py |
1128 | +++ b/tests/unit/scenario.py |
1129 | @@ -57,37 +57,35 @@ def get_juju_settings_default_value() -> list: |
1130 | JUJU_SETTINGS_DEFAULT = get_juju_settings_default_value() |
1131 | |
1132 | TEST_JUJU_SETTINGS = { |
1133 | - "empty_image_path": { |
1134 | - "config": {"mm_pd_bot_cfg": "dummy"}, |
1135 | - "logger": ["ERROR:root:Required setting empty: image_path"], |
1136 | - "expected": "Required setting(s) empty: image_path", |
1137 | - }, |
1138 | "empty_mm_pd_bot_cfg": { |
1139 | - "config": {"image_path": "mm_pd_bot:devel"}, |
1140 | + "config": {"external_hostname": "mm-pd-bot.internal"}, |
1141 | "logger": ["ERROR:root:Required setting empty: mm_pd_bot_cfg"], |
1142 | "expected": "Required setting(s) empty: mm_pd_bot_cfg", |
1143 | }, |
1144 | "empty_all_settings": { |
1145 | "config": {}, |
1146 | - "logger": ["ERROR:root:Required setting empty: image_path", "ERROR:root:Required setting empty: mm_pd_bot_cfg"], |
1147 | - "expected": "Required setting(s) empty: image_path, mm_pd_bot_cfg", |
1148 | + "logger": [ |
1149 | + "ERROR:root:Required setting empty: external_hostname", |
1150 | + "ERROR:root:Required setting empty: mm_pd_bot_cfg", |
1151 | + ], |
1152 | + "expected": "Required setting(s) empty: external_hostname, mm_pd_bot_cfg", |
1153 | }, |
1154 | } |
1155 | |
1156 | |
1157 | VALIDATE_MM_PD_BOT_CFG = { |
1158 | "good_configuration": { |
1159 | - "config": {"image_path": "mm_pd_bot:devel", "mm_pd_bot_cfg": get_cfg_content("good_configuration")}, |
1160 | + "config": {"external_hostname": "mm-pd-bot.internal", "mm_pd_bot_cfg": get_cfg_content("good_configuration")}, |
1161 | "expected": True, |
1162 | "logger": [], |
1163 | }, |
1164 | "bad_formatted_configuration": { |
1165 | - "config": {"image_path": "mm_pd_bot:devel", "mm_pd_bot_cfg": "[PagerDuty"}, |
1166 | + "config": {"external_hostname": "mm-pd-bot.internal", "mm_pd_bot_cfg": "[PagerDuty"}, |
1167 | "expected": "Error while parsing mm_pd_bot_cfg setting", |
1168 | "logger": ["ERROR:root:Error while parsing mm_pd_bot_cfg setting"], |
1169 | }, |
1170 | "missing_sections": { |
1171 | - "config": {"image_path": "mm_pd_bot:devel", "mm_pd_bot_cfg": get_cfg_content("missing_sections")}, |
1172 | + "config": {"external_hostname": "mm-pd-bot.internal", "mm_pd_bot_cfg": get_cfg_content("missing_sections")}, |
1173 | "expected": "Required section(s) missing in bot configuration file: " |
1174 | "MattermostBot, Prometheus, httpd, nickname to email", |
1175 | "logger": [ |
1176 | @@ -98,7 +96,7 @@ VALIDATE_MM_PD_BOT_CFG = { |
1177 | ], |
1178 | }, |
1179 | "missing_options": { |
1180 | - "config": {"image_path": "mm_pd_bot:devel", "mm_pd_bot_cfg": get_cfg_content("missing_options")}, |
1181 | + "config": {"external_hostname": "mm-pd-bot.internal", "mm_pd_bot_cfg": get_cfg_content("missing_options")}, |
1182 | "expected": "Required option(s) missing in section MattermostBot: bot_team, bot_url, " |
1183 | "Required option(s) missing in section PagerDuty: api-token, " |
1184 | "Required option(s) missing in section httpd: hostname, listen-ip, listen-port, magic-uuid", |
1185 | @@ -131,7 +129,7 @@ VALIDATE_POD_SPEC = { |
1186 | "version": 3, # otherwise resources are ignored |
1187 | "containers": [ |
1188 | { |
1189 | - "name": "mm-pd-bot-k8s", |
1190 | + "name": "mm-pd-bot", |
1191 | "imageDetails": { |
1192 | "imagePath": "mm_pd_bot:devel", |
1193 | }, |
1194 | @@ -153,7 +151,7 @@ VALIDATE_POD_SPEC = { |
1195 | "version": 3, # otherwise resources are ignored |
1196 | "containers": [ |
1197 | { |
1198 | - "name": "mm-pd-bot-k8s", |
1199 | + "name": "mm-pd-bot", |
1200 | "imageDetails": {"imagePath": "mm_pd_bot:devel", "username": "rockity", "password": "rock"}, |
1201 | "imagePullPolicy": "Always", |
1202 | "ports": [{"containerPort": 2160, "protocol": "TCP"}], |
1203 | @@ -172,7 +170,7 @@ VALIDATE_POD_SPEC_AND_INGRESS = { |
1204 | "version": 3, # otherwise resources are ignored |
1205 | "containers": [ |
1206 | { |
1207 | - "name": "mm-pd-bot-k8s", |
1208 | + "name": "mm-pd-bot", |
1209 | "imageDetails": {"imagePath": "mm_pd_bot:devel"}, |
1210 | "imagePullPolicy": "Always", |
1211 | "ports": [{"containerPort": 2160, "protocol": "TCP"}], |
1212 | @@ -182,7 +180,7 @@ VALIDATE_POD_SPEC_AND_INGRESS = { |
1213 | "kubernetesResources": { |
1214 | "ingressResources": [ |
1215 | { |
1216 | - "name": "mm-pd-bot-k8s-ingress", |
1217 | + "name": "mm-pd-bot-ingress", |
1218 | "spec": { |
1219 | "rules": [ |
1220 | { |
1221 | @@ -191,7 +189,7 @@ VALIDATE_POD_SPEC_AND_INGRESS = { |
1222 | "paths": [ |
1223 | { |
1224 | "path": "/bdddcacb-ab42-40ac-9106-4275c1db1519/", |
1225 | - "backend": {"serviceName": "mm-pd-bot-k8s", "servicePort": 2160}, |
1226 | + "backend": {"serviceName": "mm-pd-bot", "servicePort": 2160}, |
1227 | }, |
1228 | ], |
1229 | }, |
1230 | @@ -215,7 +213,7 @@ VALIDATE_POD_SPEC_AND_INGRESS = { |
1231 | "version": 3, # otherwise resources are ignored |
1232 | "containers": [ |
1233 | { |
1234 | - "name": "mm-pd-bot-k8s", |
1235 | + "name": "mm-pd-bot", |
1236 | "imageDetails": {"imagePath": "mm_pd_bot:devel"}, |
1237 | "imagePullPolicy": "Always", |
1238 | "ports": [{"containerPort": 2160, "protocol": "TCP"}], |
1239 | @@ -225,7 +223,7 @@ VALIDATE_POD_SPEC_AND_INGRESS = { |
1240 | "kubernetesResources": { |
1241 | "ingressResources": [ |
1242 | { |
1243 | - "name": "mm-pd-bot-k8s-ingress", |
1244 | + "name": "mm-pd-bot-ingress", |
1245 | "spec": { |
1246 | "rules": [ |
1247 | { |
1248 | @@ -234,7 +232,7 @@ VALIDATE_POD_SPEC_AND_INGRESS = { |
1249 | "paths": [ |
1250 | { |
1251 | "path": "/bdddcacb-ab42-40ac-9106-4275c1db1519/", |
1252 | - "backend": {"serviceName": "mm-pd-bot-k8s", "servicePort": 2160}, |
1253 | + "backend": {"serviceName": "mm-pd-bot", "servicePort": 2160}, |
1254 | }, |
1255 | ], |
1256 | }, |
1257 | @@ -258,7 +256,7 @@ VALIDATE_POD_SPEC_AND_INGRESS = { |
1258 | "version": 3, # otherwise resources are ignored |
1259 | "containers": [ |
1260 | { |
1261 | - "name": "mm-pd-bot-k8s", |
1262 | + "name": "mm-pd-bot", |
1263 | "imageDetails": {"imagePath": "mm_pd_bot:devel"}, |
1264 | "imagePullPolicy": "Always", |
1265 | "ports": [{"containerPort": 2160, "protocol": "TCP"}], |
1266 | @@ -268,7 +266,7 @@ VALIDATE_POD_SPEC_AND_INGRESS = { |
1267 | "kubernetesResources": { |
1268 | "ingressResources": [ |
1269 | { |
1270 | - "name": "mm-pd-bot-k8s-ingress", |
1271 | + "name": "mm-pd-bot-ingress", |
1272 | "spec": { |
1273 | "rules": [ |
1274 | { |
1275 | @@ -277,7 +275,7 @@ VALIDATE_POD_SPEC_AND_INGRESS = { |
1276 | "paths": [ |
1277 | { |
1278 | "path": "/bdddcacb-ab42-40ac-9106-4275c1db1519/", |
1279 | - "backend": {"serviceName": "mm-pd-bot-k8s", "servicePort": 2160}, |
1280 | + "backend": {"serviceName": "mm-pd-bot", "servicePort": 2160}, |
1281 | }, |
1282 | ], |
1283 | }, |
1284 | diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py |
1285 | index d56fcde..93527a3 100755 |
1286 | --- a/tests/unit/test_charm.py |
1287 | +++ b/tests/unit/test_charm.py |
1288 | @@ -4,7 +4,7 @@ |
1289 | |
1290 | import unittest |
1291 | import configparser |
1292 | -from unittest.mock import MagicMock, patch |
1293 | +from unittest.mock import patch |
1294 | |
1295 | from charm import ( |
1296 | MmPdBotK8sCharm, |
1297 | @@ -12,17 +12,15 @@ from charm import ( |
1298 | ) |
1299 | |
1300 | from ops import testing |
1301 | -from ops.model import ( |
1302 | - ActiveStatus, |
1303 | - BlockedStatus, |
1304 | -) |
1305 | +# from ops.model import ( |
1306 | +# ActiveStatus, |
1307 | +# BlockedStatus, |
1308 | +# ) |
1309 | |
1310 | from scenario import ( |
1311 | JUJU_SETTINGS_DEFAULT, |
1312 | TEST_JUJU_SETTINGS, |
1313 | VALIDATE_MM_PD_BOT_CFG, |
1314 | - VALIDATE_POD_SPEC, |
1315 | - VALIDATE_POD_SPEC_AND_INGRESS, |
1316 | ) |
1317 | |
1318 | |
1319 | @@ -41,14 +39,15 @@ class TestMmPdBotK8sCharmBlockedStatus(unittest.TestCase): |
1320 | |
1321 | @patch("charm.MmPdBotK8sCharm._validate_mm_pd_bot_configuration") |
1322 | @patch("charm.MmPdBotK8sCharm._check_juju_settings") |
1323 | - def test_check_for_config_problems(self, mock_juju, mock_mm_pd_bot): |
1324 | + def test_check_charm_config(self, mock_juju, mock_mm_pd_bot): |
1325 | """Check the calls for config problems.""" |
1326 | - self.harness.charm._check_for_config_problems() |
1327 | + self.harness.charm._check_charm_config() |
1328 | mock_juju.assert_called_once() |
1329 | mock_mm_pd_bot.assert_called_once() |
1330 | |
1331 | def test_check_juju_settings(self): |
1332 | """Check the required juju settings.""" |
1333 | + self.harness.disable_hooks() |
1334 | for scenario, values in TEST_JUJU_SETTINGS.items(): |
1335 | with self.subTest(scenario=scenario): |
1336 | with self.assertLogs(level="ERROR") as logger: |
1337 | @@ -59,9 +58,11 @@ class TestMmPdBotK8sCharmBlockedStatus(unittest.TestCase): |
1338 | self.assertEqual(sorted(logger.output), sorted(values["logger"])) |
1339 | self.assertEqual(str(exc.exception), values["expected"]) |
1340 | self.harness.update_config(JUJU_SETTINGS_DEFAULT) # You need to clean the config after each run |
1341 | + self.harness.enable_hooks() |
1342 | |
1343 | def test_validate_mm_pd_bot_configuration(self): |
1344 | """Check the MM_PD_BOT_CFG string is a valid INI.""" |
1345 | + self.harness.disable_hooks() |
1346 | for scenario, values in VALIDATE_MM_PD_BOT_CFG.items(): |
1347 | with self.subTest(scenario=scenario): |
1348 | self.harness.update_config(values["config"]) |
1349 | @@ -74,6 +75,7 @@ class TestMmPdBotK8sCharmBlockedStatus(unittest.TestCase): |
1350 | self.assertEqual(str(exc.exception), values["expected"]) |
1351 | self.assertEqual(sorted(logger.output), sorted(values["logger"])) |
1352 | self.harness.update_config(JUJU_SETTINGS_DEFAULT) # You need to clean the config after each run |
1353 | + self.harness.enable_hooks() |
1354 | |
1355 | def test_load_mm_pd_bot_configuration(self): |
1356 | """Test the loading of the configuration for the bot.""" |
1357 | @@ -86,64 +88,70 @@ class TestMmPdBotK8sCharmBlockedStatus(unittest.TestCase): |
1358 | loaded_config = self.harness.charm._load_mm_pd_bot_configuration(config) |
1359 | self.assertEqual(str(exc.exception), VALIDATE_MM_PD_BOT_CFG["bad_formatted_configuration"]["expected"]) |
1360 | |
1361 | - def test_configure_pod(self): |
1362 | - """Test the pod configuration.""" |
1363 | - mock_event = MagicMock() |
1364 | - |
1365 | - # Good configuration but not leader |
1366 | - self.harness.update_config(VALIDATE_MM_PD_BOT_CFG["good_configuration"]["config"]) |
1367 | - self.harness.set_leader(False) |
1368 | - self.harness.charm.unit.status = BlockedStatus("Testing") |
1369 | - self.harness.charm.configure_pod(mock_event) |
1370 | - self.assertEqual(self.harness.charm.unit.status, ActiveStatus()) |
1371 | - self.harness.update_config(JUJU_SETTINGS_DEFAULT) # You need to clean the config after each run |
1372 | - |
1373 | - # Good configuration and leader |
1374 | + def test_configure_workload(self): |
1375 | + """Test the workload configuration.""" |
1376 | + self.harness.disable_hooks() |
1377 | self.harness.update_config(VALIDATE_MM_PD_BOT_CFG["good_configuration"]["config"]) |
1378 | - self.harness.set_leader(True) |
1379 | - self.harness.charm.unit.status = BlockedStatus("Testing") |
1380 | - self.harness.charm.configure_pod(mock_event) |
1381 | - with self.assertLogs(level="INFO") as logger: |
1382 | - self.harness.charm.configure_pod(mock_event) |
1383 | - self.assertEqual( |
1384 | - sorted(logger.output), |
1385 | - ["INFO:root:Assembling pod spec", "INFO:root:Setting pod spec"], |
1386 | - ) |
1387 | - self.assertEqual(self.harness.charm.unit.status, ActiveStatus()) |
1388 | - self.harness.update_config(JUJU_SETTINGS_DEFAULT) # You need to clean the config after each run |
1389 | - |
1390 | - # Bad configuration and leader |
1391 | - config = VALIDATE_MM_PD_BOT_CFG["bad_formatted_configuration"]["config"] |
1392 | - expected = VALIDATE_MM_PD_BOT_CFG["bad_formatted_configuration"]["expected"] |
1393 | - self.harness.update_config(config) |
1394 | - self.harness.set_leader(True) |
1395 | - self.harness.charm.configure_pod(mock_event) |
1396 | - self.assertEqual(self.harness.charm.unit.status, BlockedStatus(expected)) |
1397 | - self.harness.update_config(JUJU_SETTINGS_DEFAULT) # You need to clean the config after each run |
1398 | - |
1399 | - def test_make_pod_spec(self): |
1400 | - """Check the crafting of the pod spec.""" |
1401 | - for scenario, values in VALIDATE_POD_SPEC.items(): |
1402 | - with self.subTest(scenario=scenario): |
1403 | - self.harness.update_config(values["config"]) |
1404 | - self.harness.charm.bot_config = self.harness.charm._load_mm_pd_bot_configuration( |
1405 | - values["config"]["mm_pd_bot_cfg"] |
1406 | - ) |
1407 | - self.assertEqual(self.harness.charm._make_pod_spec(), values["pod_spec"]) |
1408 | - self.harness.update_config(JUJU_SETTINGS_DEFAULT) # You need to clean the config after each run |
1409 | - |
1410 | - def test_update_pod_spec_for_k8s_ingress(self): |
1411 | - """Check the crafting of the ingress part of the pod spec.""" |
1412 | - for scenario, values in VALIDATE_POD_SPEC_AND_INGRESS.items(): |
1413 | - with self.subTest(scenario=scenario): |
1414 | - self.harness.update_config(values["config"]) |
1415 | - self.harness.charm.bot_config = self.harness.charm._load_mm_pd_bot_configuration( |
1416 | - values["config"]["mm_pd_bot_cfg"] |
1417 | - ) |
1418 | - pod_spec = self.harness.charm._make_pod_spec() |
1419 | - self.harness.charm._update_pod_spec_for_k8s_ingress(pod_spec) |
1420 | - self.assertEqual(pod_spec, values["pod_spec"]) |
1421 | - self.harness.update_config(JUJU_SETTINGS_DEFAULT) # You need to clean the config after each run |
1422 | + self.harness.charm.configure_workload(None) |
1423 | + self.harness.enable_hooks() |
1424 | + |
1425 | + # def test_configure_pod(self): |
1426 | + # """Test the pod configuration.""" |
1427 | + |
1428 | + # # Good configuration but not leader |
1429 | + # self.harness.update_config(VALIDATE_MM_PD_BOT_CFG["good_configuration"]["config"]) |
1430 | + # self.harness.set_leader(False) |
1431 | + # self.harness.charm.unit.status = BlockedStatus("Testing") |
1432 | + # self.harness.charm.configure_pod(mock_event) |
1433 | + # self.assertEqual(self.harness.charm.unit.status, ActiveStatus()) |
1434 | + # self.harness.update_config(JUJU_SETTINGS_DEFAULT) # You need to clean the config after each run |
1435 | + |
1436 | + # # Good configuration and leader |
1437 | + # self.harness.update_config(VALIDATE_MM_PD_BOT_CFG["good_configuration"]["config"]) |
1438 | + # self.harness.set_leader(True) |
1439 | + # self.harness.charm.unit.status = BlockedStatus("Testing") |
1440 | + # self.harness.charm.configure_pod(mock_event) |
1441 | + # with self.assertLogs(level="INFO") as logger: |
1442 | + # self.harness.charm.configure_pod(mock_event) |
1443 | + # self.assertEqual( |
1444 | + # sorted(logger.output), |
1445 | + # ["INFO:root:Assembling pod spec", "INFO:root:Setting pod spec"], |
1446 | + # ) |
1447 | + # self.assertEqual(self.harness.charm.unit.status, ActiveStatus()) |
1448 | + # self.harness.update_config(JUJU_SETTINGS_DEFAULT) # You need to clean the config after each run |
1449 | + |
1450 | + # # Bad configuration and leader |
1451 | + # config = VALIDATE_MM_PD_BOT_CFG["bad_formatted_configuration"]["config"] |
1452 | + # expected = VALIDATE_MM_PD_BOT_CFG["bad_formatted_configuration"]["expected"] |
1453 | + # self.harness.update_config(config) |
1454 | + # self.harness.set_leader(True) |
1455 | + # self.harness.charm.configure_pod(mock_event) |
1456 | + # self.assertEqual(self.harness.charm.unit.status, BlockedStatus(expected)) |
1457 | + # self.harness.update_config(JUJU_SETTINGS_DEFAULT) # You need to clean the config after each run |
1458 | + |
1459 | + # def test_make_pod_spec(self): |
1460 | + # """Check the crafting of the pod spec.""" |
1461 | + # for scenario, values in VALIDATE_POD_SPEC.items(): |
1462 | + # with self.subTest(scenario=scenario): |
1463 | + # self.harness.update_config(values["config"]) |
1464 | + # self.harness.charm.bot_config = self.harness.charm._load_mm_pd_bot_configuration( |
1465 | + # values["config"]["mm_pd_bot_cfg"] |
1466 | + # ) |
1467 | + # self.assertEqual(self.harness.charm._make_pod_spec(), values["pod_spec"]) |
1468 | + # self.harness.update_config(JUJU_SETTINGS_DEFAULT) # You need to clean the config after each run |
1469 | + |
1470 | + # def test_update_pod_spec_for_k8s_ingress(self): |
1471 | + # """Check the crafting of the ingress part of the pod spec.""" |
1472 | + # for scenario, values in VALIDATE_POD_SPEC_AND_INGRESS.items(): |
1473 | + # with self.subTest(scenario=scenario): |
1474 | + # self.harness.update_config(values["config"]) |
1475 | + # self.harness.charm.bot_config = self.harness.charm._load_mm_pd_bot_configuration( |
1476 | + # values["config"]["mm_pd_bot_cfg"] |
1477 | + # ) |
1478 | + # pod_spec = self.harness.charm._make_pod_spec() |
1479 | + # self.harness.charm._update_pod_spec_for_k8s_ingress(pod_spec) |
1480 | + # self.assertEqual(pod_spec, values["pod_spec"]) |
1481 | + # self.harness.update_config(JUJU_SETTINGS_DEFAULT) # You need to clean the config after each run |
1482 | |
1483 | |
1484 | if __name__ == "__main__": |