Merge ~jk0ne/charm-k8s-discourse/+git/charm-k8s-discourse:charmcraft-review2 into ~discourse-charmers/charm-k8s-discourse/+git/charmcraft-review:master
- Git
- lp:~jk0ne/charm-k8s-discourse/+git/charm-k8s-discourse
- charmcraft-review2
- Merge into master
Proposed by
Jay Kuri
Status: | Merged |
---|---|
Merge reported by: | Tom Haddon |
Merged at revision: | 80ff3cc0c39a02da8c7ddc413b92e5aa34fca0c0 |
Proposed branch: | ~jk0ne/charm-k8s-discourse/+git/charm-k8s-discourse:charmcraft-review2 |
Merge into: | ~discourse-charmers/charm-k8s-discourse/+git/charmcraft-review:master |
Diff against target: |
630 lines (+534/-23) 9 files modified
image/Dockerfile (+1/-1) src/charm.py (+243/-22) tests/unit/fixtures/config_invalid_missing_cors.yaml (+20/-0) tests/unit/fixtures/config_invalid_missing_db_name.yaml (+18/-0) tests/unit/fixtures/config_valid_complete.yaml (+38/-0) tests/unit/fixtures/config_valid_with_tls.yaml (+38/-0) tests/unit/fixtures/config_valid_without_tls.yaml (+37/-0) tests/unit/requirements.txt (+4/-0) tests/unit/test_charm.py (+135/-0) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Facundo Batista (community) | Approve | ||
Discourse Charm Maintainers | work in progress | Pending | |
Review via email: mp+392672@code.launchpad.net |
Commit message
Complete discourse charm ready for review.
Description of the change
To post a comment you must log in.
- 08fde17... by Jay Kuri
-
Fix up non-leader event handling and fix missing field check per review
Revision history for this message
Tom Haddon (mthaddon) wrote : | # |
One comment inline
- 80ff3cc... by Jay Kuri
-
Tweak missing_field check - remove parens
Revision history for this message
Tom Haddon (mthaddon) wrote : | # |
I'm going to mark this as "Merged" as we want to get this off the review board for MPs for this project. We'll be incorporating the changes from this MP into a new MP against the main charm code.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/image/Dockerfile b/image/Dockerfile |
2 | index 7baf5cb..96d49df 100644 |
3 | --- a/image/Dockerfile |
4 | +++ b/image/Dockerfile |
5 | @@ -13,7 +13,7 @@ ARG CONTAINER_APP_GID |
6 | |
7 | # Copy any args we got into the environment. |
8 | ENV CONTAINER_APP_NAME ${CONTAINER_APP_NAME:-discourse} |
9 | -ENV CONTAINER_APP_VERSION ${CONTAINER_APP_VERSION:-v2.4.3} |
10 | +ENV CONTAINER_APP_VERSION ${CONTAINER_APP_VERSION:-v2.5.2} |
11 | ENV CONTAINER_APP_USERNAME ${CONTAINER_APP_USERNAME:-discourse} |
12 | ENV CONTAINER_APP_UID ${CONTAINER_APP_UID:-200} |
13 | ENV CONTAINER_APP_GROUP ${CONTAINER_APP_GROUP:-discourse} |
14 | diff --git a/src/charm.py b/src/charm.py |
15 | index a16d636..5bb75ad 100755 |
16 | --- a/src/charm.py |
17 | +++ b/src/charm.py |
18 | @@ -2,37 +2,258 @@ |
19 | # Copyright 2020 Canonical Ltd. |
20 | # See LICENSE file for licensing details. |
21 | |
22 | -import logging |
23 | - |
24 | +import ops.lib |
25 | from ops.charm import CharmBase |
26 | from ops.main import main |
27 | from ops.framework import StoredState |
28 | |
29 | -logger = logging.getLogger(__name__) |
30 | +from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus |
31 | + |
32 | + |
33 | +pgsql = ops.lib.use("pgsql", 1, "postgresql-charmers@lists.launchpad.net") |
34 | + |
35 | + |
36 | +def create_discourse_pod_config(config): |
37 | + """Create the pod environment config from the juju config.""" |
38 | + pod_config = { |
39 | + 'DISCOURSE_POSTGRES_USERNAME': config['db_user'], |
40 | + 'DISCOURSE_POSTGRES_PASSWORD': config['db_password'], |
41 | + 'DISCOURSE_POSTGRES_HOST': config['db_host'], |
42 | + 'DISCOURSE_POSTGRES_NAME': config['db_name'], |
43 | + 'DISCOURSE_DEVELOPER_EMAILS': config['developer_emails'], |
44 | + 'DISCOURSE_HOSTNAME': config['external_hostname'], |
45 | + 'DISCOURSE_SMTP_DOMAIN': config['smtp_domain'], |
46 | + 'DISCOURSE_SMTP_ADDRESS': config['smtp_address'], |
47 | + 'DISCOURSE_SMTP_PORT': config['smtp_port'], |
48 | + 'DISCOURSE_SMTP_AUTHENTICATION': config['smtp_authentication'], |
49 | + 'DISCOURSE_SMTP_OPENSSL_VERIFY_MODE': config['smtp_openssl_verify_mode'], |
50 | + 'DISCOURSE_SMTP_USER_NAME': config['smtp_username'], |
51 | + 'DISCOURSE_SMTP_PASSWORD': config['smtp_password'], |
52 | + 'DISCOURSE_REDIS_HOST': config['redis_host'], |
53 | + 'DISCOURSE_ENABLE_CORS': config['enable_cors'], |
54 | + 'DISCOURSE_CORS_ORIGIN': config['cors_origin'], |
55 | + } |
56 | + return pod_config |
57 | + |
58 | + |
59 | +def create_ingress_config(app_name, config): |
60 | + """Create the ingress config form the juju config.""" |
61 | + annotations = {} |
62 | + ingressResource = { |
63 | + "name": app_name + "-ingress", |
64 | + "spec": { |
65 | + "rules": [ |
66 | + { |
67 | + "host": config['external_hostname'], |
68 | + "http": {"paths": [{"path": "/", "backend": {"serviceName": app_name, "servicePort": 3000}}]}, |
69 | + } |
70 | + ] |
71 | + }, |
72 | + } |
73 | + tls_secret_name = config.get('tls_secret_name') |
74 | + if tls_secret_name: |
75 | + ingressResource['spec']['tls'] = [{'hosts': config['external_hostname'], 'secretName': tls_secret_name}] |
76 | + else: |
77 | + annotations['nginx.ingress.kubernetes.io/ssl-redirect'] = 'false' |
78 | + |
79 | + if annotations: |
80 | + ingressResource['annotations'] = annotations |
81 | + |
82 | + return ingressResource |
83 | + |
84 | + |
85 | +def get_pod_spec(app_name, config): |
86 | + """Get the entire pod spec using the juju config. |
87 | + |
88 | + - uses create_discourse_pod_config() to generate pod envConfig. |
89 | + |
90 | + - uses create_ingress_config() to generate pod ingressResources. |
91 | + |
92 | + """ |
93 | + pod_spec = { |
94 | + "version": 3, |
95 | + "containers": [ |
96 | + { |
97 | + "name": app_name, |
98 | + "imageDetails": {"imagePath": config['discourse_image']}, |
99 | + "imagePullPolicy": "IfNotPresent", |
100 | + "ports": [{"containerPort": 3000, "protocol": "TCP"}], |
101 | + "envConfig": create_discourse_pod_config(config), |
102 | + "kubernetes": { |
103 | + "readinessProbe": { |
104 | + "httpGet": { |
105 | + "path": "/srv/status", |
106 | + "port": 3000, |
107 | + } |
108 | + } |
109 | + }, |
110 | + } |
111 | + ], |
112 | + "kubernetesResources": {"ingressResources": [create_ingress_config(app_name, config)]}, |
113 | + } |
114 | + # This handles when we are trying to get an image from a private |
115 | + # registry. |
116 | + if config['image_user'] and config['image_pass']: |
117 | + pod_spec['containers'][0]['imageDetails']['username'] = config['image_user'] |
118 | + pod_spec['containers'][0]['imageDetails']['password'] = config['image_pass'] |
119 | + |
120 | + return pod_spec |
121 | + |
122 | + |
123 | +def check_for_config_problems(config): |
124 | + """Check if there are issues with the juju config. |
125 | + |
126 | + - Primarily looks for missing config options using check_for_missing_config_fields() |
127 | + |
128 | + - Returns a list of errors if any were found. |
129 | + """ |
130 | + errors = [] |
131 | + missing_fields = check_for_missing_config_fields(config) |
132 | + |
133 | + if missing_fields: |
134 | + errors.append('Required configuration missing: {}'.format(" ".join(missing_fields))) |
135 | + |
136 | + if "db_host" not in config or config["db_host"] is None: |
137 | + errors.append("db relation is required") |
138 | + |
139 | + return errors |
140 | + |
141 | + |
142 | +def check_for_missing_config_fields(config): |
143 | + """Check for missing fields in juju config. |
144 | |
145 | + - Returns a list of required fields that are either not present |
146 | + or are empty. |
147 | + """ |
148 | + missing_fields = [] |
149 | |
150 | -class CharmK8SDiscourseCharm(CharmBase): |
151 | - _stored = StoredState() |
152 | + needed_fields = [ |
153 | + 'db_name', |
154 | + 'smtp_address', |
155 | + 'redis_host', |
156 | + 'cors_origin', |
157 | + 'developer_emails', |
158 | + 'smtp_domain', |
159 | + 'discourse_image', |
160 | + 'external_hostname', |
161 | + ] |
162 | + for key in needed_fields: |
163 | + if not config.get(key): |
164 | + missing_fields.append(key) |
165 | + |
166 | + return sorted(missing_fields) |
167 | + |
168 | + |
169 | +class DiscourseCharm(CharmBase): |
170 | + stored = StoredState() |
171 | |
172 | def __init__(self, *args): |
173 | + """Initialization. |
174 | + |
175 | + - Primarily sets up defaults and event handlers. |
176 | + |
177 | + """ |
178 | super().__init__(*args) |
179 | - self.framework.observe(self.on.config_changed, self._on_config_changed) |
180 | - self.framework.observe(self.on.fortune_action, self._on_fortune_action) |
181 | - self._stored.set_default(things=[]) |
182 | - |
183 | - def _on_config_changed(self, _): |
184 | - current = self.model.config["thing"] |
185 | - if current not in self._stored.things: |
186 | - logger.debug("found a new thing: %r", current) |
187 | - self._stored.things.append(current) |
188 | - |
189 | - def _on_fortune_action(self, event): |
190 | - fail = event.params["fail"] |
191 | - if fail: |
192 | - event.fail(fail) |
193 | + |
194 | + # TODO: is_started is unused. Remove? |
195 | + self.stored.set_default(is_started=False, db_user=None, db_password=None, db_host=None) |
196 | + self.framework.observe(self.on.leader_elected, self.configure_pod) |
197 | + self.framework.observe(self.on.config_changed, self.configure_pod) |
198 | + self.framework.observe(self.on.upgrade_charm, self.configure_pod) |
199 | + |
200 | + self.db = pgsql.PostgreSQLClient(self, 'db') |
201 | + self.framework.observe(self.db.on.database_relation_joined, self.on_database_relation_joined) |
202 | + self.framework.observe(self.db.on.master_changed, self.on_database_changed) |
203 | + |
204 | + def check_config_is_valid(self, config): |
205 | + """Check that the provided config is valid. |
206 | + |
207 | + - Returns True if config is valid, False otherwise. |
208 | + |
209 | + - Sets model status as appropriate. |
210 | + """ |
211 | + valid_config = True |
212 | + errors = check_for_config_problems(config) |
213 | + |
214 | + # Set status if we have a bad config. |
215 | + if errors: |
216 | + self.model.unit.status = BlockedStatus(", ".join(errors)) |
217 | + valid_config = False |
218 | + else: |
219 | + self.model.unit.status = MaintenanceStatus("Configuration passed validation") |
220 | + |
221 | + return valid_config |
222 | + |
223 | + def configure_pod(self, event=None): |
224 | + """Configure pod. |
225 | + |
226 | + - Verifies config is valid and unit is leader. |
227 | + |
228 | + - Configures pod using pod_spec generated from config. |
229 | + """ |
230 | + # Set our status while we get configured. |
231 | + self.model.unit.status = MaintenanceStatus('Configuring pod') |
232 | + |
233 | + # Leader must set the pod spec. |
234 | + if self.model.unit.is_leader(): |
235 | + # Merge our config and state into a single dict and set |
236 | + # defaults here, because the helpers avoid dealing with |
237 | + # the framework. |
238 | + config = dict(self.model.config) |
239 | + if not config["db_name"]: |
240 | + config["db_name"] = self.app.name |
241 | + config["db_user"] = self.stored.db_user |
242 | + config["db_password"] = self.stored.db_password |
243 | + config["db_host"] = self.stored.db_host |
244 | + # Get our spec definition. |
245 | + if self.check_config_is_valid(config): |
246 | + # Get pod spec using our app name and config |
247 | + pod_spec = get_pod_spec(self.framework.model.app.name, config) |
248 | + # Set our pod spec. |
249 | + self.model.pod.set_spec(pod_spec) |
250 | + self.stored.is_started = True |
251 | + self.model.unit.status = ActiveStatus() |
252 | else: |
253 | - event.set_results({"fortune": "A bug in the code is worth two in the documentation."}) |
254 | + self.stored.is_started = True |
255 | + self.model.unit.status = ActiveStatus() |
256 | + |
257 | + def on_database_relation_joined(self, event): |
258 | + """Event handler for a newly joined database relation. |
259 | + |
260 | + - Sets the event.database field on the database joined event. |
261 | + |
262 | + - Required because setting the database name is only possible |
263 | + from inside the event handler per https://github.com/canonical/ops-lib-pgsql/issues/2 |
264 | + """ |
265 | + # Per https://github.com/canonical/ops-lib-pgsql/issues/2, |
266 | + # changing the setting in the config will not take effect, |
267 | + # unless the relation is dropped and recreated. |
268 | + if self.model.unit.is_leader(): |
269 | + event.database = self.model.config["db_name"] |
270 | + elif event.database != self.model.config["db_name"]: |
271 | + # Leader has not yet set requirements. Defer, in case this unit |
272 | + # becomes leader and needs to perform that operation. |
273 | + event.defer() |
274 | + return |
275 | + |
276 | + def on_database_changed(self, event): |
277 | + """Event handler for database relation change. |
278 | + |
279 | + - Sets our database parameters based on what was provided |
280 | + in the relation event. |
281 | + """ |
282 | + if event.master is None: |
283 | + self.stored.db_user = None |
284 | + self.stored.db_password = None |
285 | + self.stored.db_host = None |
286 | + return |
287 | + |
288 | + self.stored.db_user = event.master.user |
289 | + self.stored.db_password = event.master.password |
290 | + self.stored.db_host = event.master.host |
291 | + |
292 | + self.configure_pod() |
293 | |
294 | |
295 | -if __name__ == "__main__": |
296 | - main(CharmK8SDiscourseCharm) |
297 | +if __name__ == '__main__': # pragma: no cover |
298 | + main(DiscourseCharm) |
299 | diff --git a/tests/unit/fixtures/config_invalid_missing_cors.yaml b/tests/unit/fixtures/config_invalid_missing_cors.yaml |
300 | new file mode 100644 |
301 | index 0000000..da4a5c8 |
302 | --- /dev/null |
303 | +++ b/tests/unit/fixtures/config_invalid_missing_cors.yaml |
304 | @@ -0,0 +1,20 @@ |
305 | +config: |
306 | + cors_origin: '' |
307 | + db_name: discourse |
308 | + db_host: 10.25.244.12 |
309 | + developer_emails: some.person@example.com |
310 | + discourse_image: discourse-k8s:1.0.7f |
311 | + enable_cors: true |
312 | + external_hostname: discourse.local |
313 | + image_pass: '' |
314 | + image_user: '' |
315 | + redis_host: 10.9.89.197 |
316 | + smtp_address: 167.89.123.58 |
317 | + smtp_authentication: login |
318 | + smtp_domain: example.com |
319 | + smtp_openssl_verify_mode: none |
320 | + smtp_password: OBV10USLYF4K3 |
321 | + smtp_port: 587 |
322 | + smtp_username: apikey |
323 | +missing_fields: |
324 | + - 'cors_origin' |
325 | diff --git a/tests/unit/fixtures/config_invalid_missing_db_name.yaml b/tests/unit/fixtures/config_invalid_missing_db_name.yaml |
326 | new file mode 100644 |
327 | index 0000000..0e2ea39 |
328 | --- /dev/null |
329 | +++ b/tests/unit/fixtures/config_invalid_missing_db_name.yaml |
330 | @@ -0,0 +1,18 @@ |
331 | +config: |
332 | + cors_origin: '*' |
333 | + developer_emails: some.person@example.com |
334 | + discourse_image: discourse-k8s:1.0.7f |
335 | + enable_cors: true |
336 | + external_hostname: discourse.local |
337 | + image_pass: '' |
338 | + image_user: '' |
339 | + redis_host: 10.9.89.197 |
340 | + smtp_address: 167.89.123.58 |
341 | + smtp_authentication: login |
342 | + smtp_domain: example.com |
343 | + smtp_openssl_verify_mode: none |
344 | + smtp_password: OBV10USLYF4K3 |
345 | + smtp_port: 587 |
346 | + smtp_username: apikey |
347 | +missing_fields: |
348 | + - 'db_name' |
349 | diff --git a/tests/unit/fixtures/config_valid_complete.yaml b/tests/unit/fixtures/config_valid_complete.yaml |
350 | new file mode 100644 |
351 | index 0000000..28e7367 |
352 | --- /dev/null |
353 | +++ b/tests/unit/fixtures/config_valid_complete.yaml |
354 | @@ -0,0 +1,38 @@ |
355 | +config: |
356 | + cors_origin: '*' |
357 | + db_host: 10.9.89.237 |
358 | + db_name: discourse |
359 | + db_password: a_real_password |
360 | + db_user: discourse_m |
361 | + developer_emails: some.person@example.com |
362 | + discourse_image: discourse-k8s:1.0.7f |
363 | + enable_cors: true |
364 | + external_hostname: discourse.local |
365 | + image_pass: '' |
366 | + image_user: '' |
367 | + redis_host: 10.9.89.197 |
368 | + smtp_address: 167.89.123.58 |
369 | + smtp_authentication: login |
370 | + smtp_domain: example.com |
371 | + smtp_openssl_verify_mode: none |
372 | + smtp_password: OBV10USLYF4K3 |
373 | + smtp_port: 587 |
374 | + smtp_username: apikey |
375 | + tls_secret_name: discourse_local |
376 | +pod_config: |
377 | + DISCOURSE_CORS_ORIGIN: '*' |
378 | + DISCOURSE_DEVELOPER_EMAILS: some.person@example.com |
379 | + DISCOURSE_ENABLE_CORS: true |
380 | + DISCOURSE_HOSTNAME: discourse.local |
381 | + DISCOURSE_POSTGRES_HOST: 10.9.89.237 |
382 | + DISCOURSE_POSTGRES_NAME: discourse |
383 | + DISCOURSE_POSTGRES_PASSWORD: a_real_password |
384 | + DISCOURSE_POSTGRES_USERNAME: discourse_m |
385 | + DISCOURSE_REDIS_HOST: 10.9.89.197 |
386 | + DISCOURSE_SMTP_ADDRESS: 167.89.123.58 |
387 | + DISCOURSE_SMTP_AUTHENTICATION: login |
388 | + DISCOURSE_SMTP_DOMAIN: example.com |
389 | + DISCOURSE_SMTP_OPENSSL_VERIFY_MODE: none |
390 | + DISCOURSE_SMTP_PASSWORD: OBV10USLYF4K3 |
391 | + DISCOURSE_SMTP_PORT: 587 |
392 | + DISCOURSE_SMTP_USER_NAME: apikey |
393 | diff --git a/tests/unit/fixtures/config_valid_with_tls.yaml b/tests/unit/fixtures/config_valid_with_tls.yaml |
394 | new file mode 100644 |
395 | index 0000000..fa19ce1 |
396 | --- /dev/null |
397 | +++ b/tests/unit/fixtures/config_valid_with_tls.yaml |
398 | @@ -0,0 +1,38 @@ |
399 | +config: |
400 | + cors_origin: '*' |
401 | + db_host: 10.9.89.237 |
402 | + db_name: discourse |
403 | + db_password: a_real_password |
404 | + db_user: discourse_m |
405 | + developer_emails: some.person@example.com |
406 | + discourse_image: discourse-k8s:1.0.7f |
407 | + enable_cors: true |
408 | + external_hostname: discourse.local |
409 | + image_pass: 'discourse123' |
410 | + image_user: 'discourse_role' |
411 | + redis_host: 10.9.89.197 |
412 | + smtp_address: 167.89.123.58 |
413 | + smtp_authentication: login |
414 | + smtp_domain: example.com |
415 | + smtp_openssl_verify_mode: none |
416 | + smtp_password: OBV10USLYF4K3 |
417 | + smtp_port: 587 |
418 | + smtp_username: apikey |
419 | + tls_secret_name: discourse-local |
420 | +pod_config: |
421 | + DISCOURSE_CORS_ORIGIN: '*' |
422 | + DISCOURSE_DEVELOPER_EMAILS: some.person@example.com |
423 | + DISCOURSE_ENABLE_CORS: true |
424 | + DISCOURSE_HOSTNAME: discourse.local |
425 | + DISCOURSE_POSTGRES_HOST: 10.9.89.237 |
426 | + DISCOURSE_POSTGRES_NAME: discourse |
427 | + DISCOURSE_POSTGRES_PASSWORD: a_real_password |
428 | + DISCOURSE_POSTGRES_USERNAME: discourse_m |
429 | + DISCOURSE_REDIS_HOST: 10.9.89.197 |
430 | + DISCOURSE_SMTP_ADDRESS: 167.89.123.58 |
431 | + DISCOURSE_SMTP_AUTHENTICATION: login |
432 | + DISCOURSE_SMTP_DOMAIN: example.com |
433 | + DISCOURSE_SMTP_OPENSSL_VERIFY_MODE: none |
434 | + DISCOURSE_SMTP_PASSWORD: OBV10USLYF4K3 |
435 | + DISCOURSE_SMTP_PORT: 587 |
436 | + DISCOURSE_SMTP_USER_NAME: apikey |
437 | diff --git a/tests/unit/fixtures/config_valid_without_tls.yaml b/tests/unit/fixtures/config_valid_without_tls.yaml |
438 | new file mode 100644 |
439 | index 0000000..8c440b8 |
440 | --- /dev/null |
441 | +++ b/tests/unit/fixtures/config_valid_without_tls.yaml |
442 | @@ -0,0 +1,37 @@ |
443 | +config: |
444 | + cors_origin: '*' |
445 | + db_host: 10.9.89.237 |
446 | + db_name: discourse |
447 | + db_password: a_real_password |
448 | + db_user: discourse_m |
449 | + developer_emails: is-admin@example.com |
450 | + discourse_image: discourse-k8s:1.0.8 |
451 | + enable_cors: true |
452 | + external_hostname: discourse.example.com |
453 | + image_pass: '' |
454 | + image_user: '' |
455 | + redis_host: 10.25.242.12 |
456 | + smtp_address: smtp.internal |
457 | + smtp_authentication: login |
458 | + smtp_domain: example.com |
459 | + smtp_openssl_verify_mode: none |
460 | + smtp_password: |
461 | + smtp_port: 587 |
462 | + smtp_username: apikey |
463 | +pod_config: |
464 | + DISCOURSE_CORS_ORIGIN: '*' |
465 | + DISCOURSE_DEVELOPER_EMAILS: is-admin@example.com |
466 | + DISCOURSE_ENABLE_CORS: true |
467 | + DISCOURSE_HOSTNAME: discourse.example.com |
468 | + DISCOURSE_POSTGRES_HOST: 10.9.89.237 |
469 | + DISCOURSE_POSTGRES_NAME: discourse |
470 | + DISCOURSE_POSTGRES_PASSWORD: a_real_password |
471 | + DISCOURSE_POSTGRES_USERNAME: discourse_m |
472 | + DISCOURSE_REDIS_HOST: 10.25.242.12 |
473 | + DISCOURSE_SMTP_ADDRESS: smtp.internal |
474 | + DISCOURSE_SMTP_AUTHENTICATION: login |
475 | + DISCOURSE_SMTP_DOMAIN: example.com |
476 | + DISCOURSE_SMTP_OPENSSL_VERIFY_MODE: none |
477 | + DISCOURSE_SMTP_PASSWORD: null |
478 | + DISCOURSE_SMTP_PORT: 587 |
479 | + DISCOURSE_SMTP_USER_NAME: apikey |
480 | diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt |
481 | new file mode 100644 |
482 | index 0000000..65431fc |
483 | --- /dev/null |
484 | +++ b/tests/unit/requirements.txt |
485 | @@ -0,0 +1,4 @@ |
486 | +mock |
487 | +pytest |
488 | +pytest-cov |
489 | +pyyaml |
490 | diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py |
491 | new file mode 100644 |
492 | index 0000000..8dc65d8 |
493 | --- /dev/null |
494 | +++ b/tests/unit/test_charm.py |
495 | @@ -0,0 +1,135 @@ |
496 | +#!/usr/bin/env python3 |
497 | + |
498 | +# Copyright 2020 Canonical Ltd. |
499 | +# See LICENSE file for licensing details. |
500 | + |
501 | +import copy |
502 | +import glob |
503 | +import mock |
504 | +import os |
505 | +import unittest |
506 | +import yaml |
507 | + |
508 | +from types import SimpleNamespace |
509 | + |
510 | +from charm import ( |
511 | + check_for_missing_config_fields, |
512 | + create_discourse_pod_config, |
513 | + get_pod_spec, |
514 | + DiscourseCharm, |
515 | +) |
516 | + |
517 | +from ops import testing |
518 | + |
519 | + |
520 | +def load_configs(directory): |
521 | + """Load configs for use by tests. |
522 | + |
523 | + Valid and invalid configs are present in the fixtures directory. The files |
524 | + contain the juju config, along with the spec that that config should |
525 | + produce in the case of valid configs. In the case of invalid configs, they |
526 | + contain the juju config and the error that should be triggered by the |
527 | + config. These scenarios are tested below. Additional config variations can |
528 | + be created by creating an appropriately named config file in the fixtures |
529 | + directory. Valid configs should be named: config_valid_###.yaml and invalid |
530 | + configs should be named: config_invalid_###.yaml.""" |
531 | + configs = {} |
532 | + for filename in glob.glob(os.path.join(directory, 'config*.yaml')): |
533 | + with open(filename) as file: |
534 | + name, _ = os.path.splitext(os.path.basename(filename)) |
535 | + configs[name] = yaml.full_load(file) |
536 | + return configs |
537 | + |
538 | + |
539 | +class TestDiscourseK8sCharmHooksDisabled(unittest.TestCase): |
540 | + def setUp(self): |
541 | + self.harness = testing.Harness(DiscourseCharm) |
542 | + self.harness.disable_hooks() |
543 | + self.harness.set_leader(True) |
544 | + self.harness.begin() |
545 | + self.configs = load_configs(os.path.join(os.path.dirname(__file__), 'fixtures')) |
546 | + |
547 | + def test_valid_configs_are_ok(self): |
548 | + """Test that a valid config is considered valid.""" |
549 | + for config_key in self.configs: |
550 | + if config_key.startswith('config_valid_'): |
551 | + config_valid = self.harness.charm.check_config_is_valid(self.configs[config_key]['config']) |
552 | + pod_config = create_discourse_pod_config(self.configs[config_key]['config']) |
553 | + self.assertEqual(config_valid, True, 'Valid config is not recognized as valid') |
554 | + self.assertEqual( |
555 | + pod_config, |
556 | + self.configs[config_key]['pod_config'], |
557 | + 'Valid config {} is does not produce expected config options for pod.'.format(config_key), |
558 | + ) |
559 | + |
560 | + def test_invalid_configs_are_recognized(self): |
561 | + """Test that bad configs are identified by the charm.""" |
562 | + for config_key in self.configs: |
563 | + if config_key.startswith('config_invalid_'): |
564 | + config_valid = self.harness.charm.check_config_is_valid(self.configs[config_key]['config']) |
565 | + missing_fields = check_for_missing_config_fields(self.configs[config_key]['config']) |
566 | + self.assertEqual(config_valid, False, 'Invalid config is not recognized as invalid') |
567 | + self.assertEqual( |
568 | + missing_fields, |
569 | + self.configs[config_key]['missing_fields'], |
570 | + 'Invalid config {} does not fail as expected.'.format(config_key), |
571 | + ) |
572 | + |
573 | + def test_charm_config_process(self): |
574 | + """Test that the entire config process for the charm works.""" |
575 | + test_config = copy.deepcopy(self.configs['config_valid_complete']['config']) |
576 | + test_config['db_user'] = 'discourse_m' |
577 | + test_config['db_password'] = 'a_real_password' |
578 | + test_config['db_host'] = '10.9.89.237' |
579 | + db_event = SimpleNamespace() |
580 | + db_event.master = SimpleNamespace() |
581 | + db_event.master.user = 'discourse_m' |
582 | + db_event.master.password = 'a_real_password' |
583 | + db_event.master.host = '10.9.89.237' |
584 | + expected_spec = (get_pod_spec(self.harness.charm.framework.model.app.name, test_config), None) |
585 | + self.harness.update_config(self.configs['config_valid_complete']['config']) |
586 | + self.harness.charm.on_database_relation_joined(db_event) |
587 | + self.harness.charm.on_database_changed(db_event) |
588 | + configured_spec = self.harness.get_pod_spec() |
589 | + self.assertEqual(configured_spec, expected_spec, 'Configured spec does not match expected pod spec.') |
590 | + |
591 | + def test_charm_config_process_not_leader(self): |
592 | + """Test that the config process on a non-leader does not trigger a pod_spec change.""" |
593 | + non_leader_harness = testing.Harness(DiscourseCharm) |
594 | + non_leader_harness.disable_hooks() |
595 | + non_leader_harness.set_leader(False) |
596 | + non_leader_harness.begin() |
597 | + |
598 | + action_event = mock.Mock() |
599 | + non_leader_harness.update_config(self.configs['config_valid_complete']['config']) |
600 | + non_leader_harness.charm.configure_pod(action_event) |
601 | + result = non_leader_harness.get_pod_spec() |
602 | + self.assertEqual(result, None, 'Non-leader does not set pod spec.') |
603 | + |
604 | + def test_lost_db_relation(self): |
605 | + """Test that losing our DB relation triggers a drop of pod config.""" |
606 | + self.harness.update_config(self.configs['config_valid_complete']['config']) |
607 | + db_event = SimpleNamespace() |
608 | + db_event.master = None |
609 | + self.harness.charm.on_database_changed(db_event) |
610 | + configured_spec = self.harness.get_pod_spec() |
611 | + self.assertEqual(configured_spec, None, 'Lost DB relation does not result in empty spec as expected') |
612 | + |
613 | + def test_on_database_relation_joined(self): |
614 | + """Test joining the DB relation.""" |
615 | + # First test with a non-leader, confirm the database property isn't set. |
616 | + non_leader_harness = testing.Harness(DiscourseCharm) |
617 | + non_leader_harness.disable_hooks() |
618 | + non_leader_harness.set_leader(False) |
619 | + non_leader_harness.begin() |
620 | + |
621 | + action_event = mock.Mock() |
622 | + action_event.database = None |
623 | + non_leader_harness.update_config(self.configs['config_valid_complete']['config']) |
624 | + non_leader_harness.charm.on_database_relation_joined(action_event) |
625 | + self.assertEqual(action_event.database, None) |
626 | + |
627 | + # Now test with a leader, the database property is set. |
628 | + self.harness.update_config(self.configs['config_valid_complete']['config']) |
629 | + self.harness.charm.on_database_relation_joined(action_event) |
630 | + self.assertEqual(action_event.database, "discourse") |
It looks great! Approved, but check a small suggestion in an inline comment.