Merge ~mthaddon/charm-k8s-discourse/+git/charm-k8s-discourse:redis-relation into charm-k8s-discourse:master

Proposed by Tom Haddon
Status: Merged
Approved by: Tom Haddon
Approved revision: 8635ad01c30596a19ce219a4a470c96e81ed218b
Merged at revision: ab22995f6d560f85d94adec0d2a9d4d1b8813221
Proposed branch: ~mthaddon/charm-k8s-discourse/+git/charm-k8s-discourse:redis-relation
Merge into: charm-k8s-discourse:master
Diff against target: 506 lines (+173/-38)
12 files modified
Makefile (+2/-2)
README.md (+7/-16)
config.yaml (+4/-4)
lib/charms/redis_k8s/v0/redis.py (+97/-0)
metadata.yaml (+4/-1)
src/charm.py (+42/-7)
tests/unit/fixtures/config_valid_complete.yaml (+4/-3)
tests/unit/fixtures/config_valid_no_saml_fingerprint.yaml (+1/-0)
tests/unit/fixtures/config_valid_no_saml_target_url.yaml (+1/-0)
tests/unit/fixtures/config_valid_no_tls.yaml (+4/-3)
tests/unit/fixtures/config_valid_with_tls.yaml (+1/-0)
tests/unit/test_charm.py (+6/-2)
Reviewer Review Type Date Requested Status
Joel Sing (community) +1 Approve
🤖 prod-jenkaas-is (community) continuous-integration Approve
Canonical IS Reviewers Pending
Review via email: mp+402637@code.launchpad.net

Commit message

Add a redis relation, using the new redis-k8s library, but continue to support static redis config as well

To post a comment you must log in.
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

This merge proposal is being monitored by mergebot. Change the status to Approved to merge.

Revision history for this message
🤖 prod-jenkaas-is (prod-jenkaas-is) wrote :

A CI job is currently in progress. A follow up comment will be added when it completes.

Revision history for this message
🤖 prod-jenkaas-is (prod-jenkaas-is) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
🤖 prod-jenkaas-is (prod-jenkaas-is) wrote :

A CI job is currently in progress. A follow up comment will be added when it completes.

Revision history for this message
🤖 prod-jenkaas-is (prod-jenkaas-is) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
🤖 prod-jenkaas-is (prod-jenkaas-is) wrote :

A CI job is currently in progress. A follow up comment will be added when it completes.

Revision history for this message
🤖 prod-jenkaas-is (prod-jenkaas-is) wrote :
review: Approve (continuous-integration)
Revision history for this message
Joel Sing (jsing) wrote :

LGTM - see various minor comments inline.

review: Approve (+1)
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

Change successfully merged at revision ab22995f6d560f85d94adec0d2a9d4d1b8813221

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/Makefile b/Makefile
2index 95aa3b7..6a7bbe1 100644
3--- a/Makefile
4+++ b/Makefile
5@@ -12,7 +12,7 @@ lint: blacken
6
7 # We actually use the build directory created by charmcraft,
8 # but the .charm file makes a much more convenient sentinel.
9-unittest: discourse.charm
10+unittest: discourse-k8s.charm
11 @tox -e unit
12
13 test: lint unittest
14@@ -21,7 +21,7 @@ clean:
15 @echo "Cleaning files"
16 @git clean -fXd
17
18-discourse.charm: src/*.py requirements.txt
19+discourse-k8s.charm: src/*.py requirements.txt
20 charmcraft build
21
22 build-image:
23diff --git a/README.md b/README.md
24index 09898e4..b49813d 100644
25--- a/README.md
26+++ b/README.md
27@@ -15,22 +15,13 @@ Discourse than the one currently deployed.
28 For details on using Kubernetes with Juju [see here](https://juju.is/docs/kubernetes), and for
29 details on using Juju with MicroK8s for easy local testing [see here](https://juju.is/docs/microk8s-cloud).
30
31-To get started with a test environment, first deploy Redis in an IaaS model:
32-
33- juju deploy cs:~redis-charmers/redis # Note the deployed IP as ${REDIS_IP}
34-
35-Now deploy the Discourse and PostgreSQL charms within a Juju Kubernetes model
36-as follows:
37-
38- juju deploy cs:~postgresql-charmers/postgresql-k8s postgresql
39- juju deploy cs:~discourse-charmers/discourse-k8s discourse \
40- --config redis_host=${REDIS_IP} \
41- --config developer_emails="user@foo.internal" \
42- --config external_hostname="foo.internal" \
43- --config smtp_address="127.0.0.1" \
44- --config smtp_domain="foo.internal"
45- juju add-relation discourse postgresql:db-admin
46- juju expose discourse
47+To deploy into a Juju K8s model:
48+
49+ juju deploy postgresql-k8s
50+ juju deploy redis-k8s
51+ juju deploy discourse-k8s
52+ juju relate discourse-k8s postgresql-k8s:db-admin
53+ juju relate discourse-k8s redis-k8s
54
55 Once the deployment is completed and the "discourse" workload state in `juju
56 status` has changed to "active" you can visit http://{$discourse_ip}:3000 in a
57diff --git a/config.yaml b/config.yaml
58index a55dafd..85f0677 100644
59--- a/config.yaml
60+++ b/config.yaml
61@@ -18,7 +18,7 @@ options:
62 external_hostname:
63 type: string
64 description: "External hostname this discourse instance should respond to"
65- default: ""
66+ default: "foo.internal"
67 enable_cors:
68 type: boolean
69 description: "Enable Cross-origin Resource Sharing (CORS) at the application level (required for SSO)"
70@@ -30,15 +30,15 @@ options:
71 developer_emails:
72 type: string
73 description: "Comma delimited list of email addresses that should have developer level access"
74- default: ""
75+ default: "user@foo.internal"
76 smtp_domain:
77 type: string
78 description: "Hostname that email sent by this discourse should appear to come from"
79- default: ""
80+ default: "foo.internal"
81 smtp_address:
82 type: string
83 description: "Hostname / IP that should be used to send SMTP mail"
84- default: ""
85+ default: "127.0.0.1"
86 smtp_port:
87 type: int
88 description: "Port to use when connecting to SMTP server"
89diff --git a/lib/charms/redis_k8s/v0/redis.py b/lib/charms/redis_k8s/v0/redis.py
90new file mode 100644
91index 0000000..70787b6
92--- /dev/null
93+++ b/lib/charms/redis_k8s/v0/redis.py
94@@ -0,0 +1,97 @@
95+"""Library for the redis relation.
96+
97+This library contains the Requires and Provides classes for handling the
98+redis interface.
99+
100+Import `RedisRequires` in your charm by adding the following to `src/charm.py`:
101+```
102+from charms.redis_k8s.v0.redis import RedisRequires
103+
104+# In your charm's `__init__` method.
105+self.redis = RedisRequires(self, self._stored)
106+```
107+And then wherever you need to reference the relation data:
108+```
109+for redis_unit in self._stored.redis_relation:
110+ redis_host = self._stored.redis_relation[redis_unit]["hostname"]
111+ redis_port = self._stored.redis_relation[redis_unit]["port"]
112+```
113+"""
114+import logging
115+
116+from ops.charm import CharmEvents
117+from ops.framework import EventBase, EventSource, Object
118+
119+# The unique Charmhub library identifier, never change it
120+LIBID = "fe18a608cec5465fa5153e419abcad7b"
121+
122+# Increment this major API version when introducing breaking changes
123+LIBAPI = 0
124+
125+# Increment this PATCH version before using `charmcraft publish-lib` or reset
126+# to 0 if you are raising the major API version
127+LIBPATCH = 1
128+
129+logger = logging.getLogger(__name__)
130+
131+
132+class RedisRelationUpdatedEvent(EventBase):
133+ pass
134+
135+
136+class RedisRelationCharmEvents(CharmEvents):
137+ redis_relation_updated = EventSource(RedisRelationUpdatedEvent)
138+
139+
140+class RedisRequires(Object):
141+ def __init__(self, charm, _stored):
142+ # Define a constructor that takes the charm and it's StoredState
143+ super().__init__(charm, "redis")
144+ self.framework.observe(charm.on.redis_relation_changed, self._on_relation_changed)
145+ self.framework.observe(charm.on.redis_relation_broken, self._on_relation_broken)
146+ self._stored = _stored
147+ self.charm = charm
148+
149+ def _on_relation_changed(self, event):
150+ if not event.unit:
151+ return
152+
153+ hostname = event.relation.data[event.unit].get("hostname")
154+ port = event.relation.data[event.unit].get("port")
155+ # Store some data from the relation in local state
156+ self._stored.redis_relation[event.relation.id] = {"hostname": hostname, "port": port}
157+
158+ # Trigger an event that our charm can react to.
159+ self.charm.on.redis_relation_updated.emit()
160+
161+ def _on_relation_broken(self, event):
162+ # Remove the unit data from local state
163+ self._stored.redis_relation.pop(event.relation.id, None)
164+
165+ # Trigger an event that our charm can react to.
166+ self.charm.on.redis_relation_updated.emit()
167+
168+
169+class RedisProvides(Object):
170+ def __init__(self, charm, port):
171+ super().__init__(charm, "redis")
172+ self.framework.observe(charm.on.redis_relation_changed, self._on_relation_changed)
173+ self._port = port
174+
175+ def _on_relation_changed(self, event):
176+ if not self.model.unit.is_leader():
177+ logger.debug("Relation changes ignored by non-leader")
178+ return
179+
180+ event.relation.data[self.model.unit]['hostname'] = str(self._bind_address(event))
181+ event.relation.data[self.model.unit]['port'] = str(self._port)
182+ # The reactive Redis charm exposes also 'password'. When tackling
183+ # https://github.com/canonical/redis-operator/issues/7 add 'password'
184+ # field so that it matches the exposed interface information from it.
185+ # event.relation.data[self.unit]['password'] = ''
186+
187+ def _bind_address(self, event):
188+ relation = self.model.get_relation(event.relation.name, event.relation.id)
189+ if address := self.model.get_binding(relation).network.bind_address:
190+ return address
191+ return self.app.name
192diff --git a/metadata.yaml b/metadata.yaml
193index 82e1aa8..0c8beb2 100644
194--- a/metadata.yaml
195+++ b/metadata.yaml
196@@ -1,4 +1,5 @@
197-name: discourse
198+name: discourse-k8s
199+display-name: Discourse
200 summary: Discourse is the 100% open source discussion platform built for the next decade of the Internet.
201 description: |
202 Discourse is the 100% open source discussion platform built for the next decade of the Internet.
203@@ -15,6 +16,8 @@ series:
204 - kubernetes
205 min-juju-version: 2.8.0
206 requires:
207+ redis:
208+ interface: redis
209 db:
210 interface: pgsql
211 limit: 1
212diff --git a/src/charm.py b/src/charm.py
213index d8f210b..0ef9f01 100755
214--- a/src/charm.py
215+++ b/src/charm.py
216@@ -2,7 +2,13 @@
217 # Copyright 2020 Canonical Ltd.
218 # See LICENSE file for licensing details.
219
220+import logging
221+
222 import ops.lib
223+from charms.redis_k8s.v0.redis import (
224+ RedisRelationCharmEvents,
225+ RedisRequires,
226+)
227 from ops.charm import CharmBase
228 from ops.main import main
229 from ops.framework import StoredState
230@@ -10,6 +16,9 @@ from ops.framework import StoredState
231 from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, WaitingStatus
232
233
234+logger = logging.getLogger(__name__)
235+
236+
237 pgsql = ops.lib.use("pgsql", 1, "postgresql-charmers@lists.launchpad.net")
238
239 THROTTLE_LEVELS = {
240@@ -52,7 +61,7 @@ def create_discourse_pod_config(config):
241 'DISCOURSE_SMTP_USER_NAME': config['smtp_username'],
242 'DISCOURSE_SMTP_PASSWORD': config['smtp_password'],
243 'DISCOURSE_REDIS_HOST': config['redis_host'],
244- 'DISCOURSE_REDIS_PORT': 6379,
245+ 'DISCOURSE_REDIS_PORT': config['redis_port'],
246 'DISCOURSE_ENABLE_CORS': config['enable_cors'],
247 'DISCOURSE_CORS_ORIGIN': config['cors_origin'],
248 'DISCOURSE_REFRESH_MAXMIND_DB_DURING_PRECOMPILE_DAYS': "0",
249@@ -161,7 +170,7 @@ def get_pod_spec(app_name, config):
250 return pod_spec
251
252
253-def check_for_config_problems(config):
254+def check_for_config_problems(config, stored):
255 """Check if there are issues with the juju config.
256
257 - Primarily looks for missing config options using check_for_missing_config_fields()
258@@ -169,7 +178,7 @@ def check_for_config_problems(config):
259 - Returns a list of errors if any were found.
260 """
261 errors = []
262- missing_fields = check_for_missing_config_fields(config)
263+ missing_fields = check_for_missing_config_fields(config, stored)
264
265 if missing_fields:
266 errors.append('Required configuration missing: {}'.format(" ".join(missing_fields)))
267@@ -183,7 +192,7 @@ def check_for_config_problems(config):
268 return errors
269
270
271-def check_for_missing_config_fields(config):
272+def check_for_missing_config_fields(config, stored):
273 """Check for missing fields in juju config.
274
275 - Returns a list of required fields that are either not present
276@@ -194,13 +203,18 @@ def check_for_missing_config_fields(config):
277 needed_fields = [
278 'db_name',
279 'smtp_address',
280- 'redis_host',
281 'cors_origin',
282 'developer_emails',
283 'smtp_domain',
284 'discourse_image',
285 'external_hostname',
286 ]
287+ # See if Redis connection information has been provided via a relation.
288+ redis_hostname = None
289+ for redis_unit in stored.redis_relation:
290+ redis_hostname = stored.redis_relation[redis_unit]["hostname"]
291+ if not redis_hostname:
292+ needed_fields.append("redis_host")
293 for key in needed_fields:
294 if not config.get(key):
295 missing_fields.append(key)
296@@ -209,6 +223,7 @@ def check_for_missing_config_fields(config):
297
298
299 class DiscourseCharm(CharmBase):
300+ on = RedisRelationCharmEvents()
301 stored = StoredState()
302
303 def __init__(self, *args):
304@@ -220,7 +235,13 @@ class DiscourseCharm(CharmBase):
305 super().__init__(*args)
306
307 self.stored.set_default(
308- db_name=None, db_user=None, db_password=None, db_host=None, has_db_relation=False, has_db_credentials=False
309+ db_name=None,
310+ db_user=None,
311+ db_password=None,
312+ db_host=None,
313+ has_db_relation=False,
314+ has_db_credentials=False,
315+ redis_relation={},
316 )
317 self.framework.observe(self.on.leader_elected, self.configure_pod)
318 self.framework.observe(self.on.config_changed, self.configure_pod)
319@@ -230,6 +251,9 @@ class DiscourseCharm(CharmBase):
320 self.framework.observe(self.db.on.database_relation_joined, self.on_database_relation_joined)
321 self.framework.observe(self.db.on.master_changed, self.on_database_changed)
322
323+ self.redis = RedisRequires(self, self.stored)
324+ self.framework.observe(self.on.redis_relation_updated, self.configure_pod)
325+
326 def check_config_is_valid(self, config):
327 """Check that the provided config is valid.
328
329@@ -238,7 +262,7 @@ class DiscourseCharm(CharmBase):
330 - Sets model status as appropriate.
331 """
332 valid_config = True
333- errors = check_for_config_problems(config)
334+ errors = check_for_config_problems(config, self.stored)
335
336 # Set status if we have a bad config.
337 if errors:
338@@ -266,6 +290,15 @@ class DiscourseCharm(CharmBase):
339
340 - Configures pod using pod_spec generated from config.
341 """
342+ # Get redis connection information from config but allow overriding
343+ # via a relation.
344+ redis_hostname = self.config["redis_host"]
345+ redis_port = 6379
346+ for redis_unit in self.stored.redis_relation:
347+ redis_hostname = self.stored.redis_relation[redis_unit]["hostname"]
348+ redis_port = self.stored.redis_relation[redis_unit]["port"]
349+ logging.debug("Got redis connection details from relation of %s:%s", redis_hostname, redis_port)
350+
351 # Set our status while we get configured.
352 self.model.unit.status = MaintenanceStatus('Configuring pod')
353
354@@ -282,6 +315,8 @@ class DiscourseCharm(CharmBase):
355 config["db_user"] = self.stored.db_user
356 config["db_password"] = self.stored.db_password
357 config["db_host"] = self.stored.db_host
358+ config["redis_host"] = redis_hostname
359+ config["redis_port"] = redis_port
360 # Get our spec definition.
361 if self.check_config_is_valid(config):
362 # Get pod spec using our app name and config
363diff --git a/tests/unit/fixtures/config_valid_complete.yaml b/tests/unit/fixtures/config_valid_complete.yaml
364index 7815983..946388d 100644
365--- a/tests/unit/fixtures/config_valid_complete.yaml
366+++ b/tests/unit/fixtures/config_valid_complete.yaml
367@@ -12,6 +12,7 @@ config:
368 image_user: 'someuser'
369 max_body_size: 25
370 redis_host: 10.9.89.197
371+ redis_port: 6379
372 smtp_address: 167.89.123.58
373 smtp_authentication: login
374 smtp_domain: example.com
375@@ -51,7 +52,7 @@ pod_config:
376 pod_spec:
377 version: 3
378 containers:
379- - name: discourse
380+ - name: discourse-k8s
381 envConfig:
382 DISCOURSE_CORS_ORIGIN: '*'
383 DISCOURSE_DEVELOPER_EMAILS: some.person@example.com
384@@ -100,14 +101,14 @@ pod_spec:
385 nginx.ingress.kubernetes.io/session-cookie-max-age: '3600'
386 nginx.ingress.kubernetes.io/session-cookie-name: 'DISCOURSE_AFFINITY'
387 nginx.ingress.kubernetes.io/session-cookie-samesite: 'Lax'
388- name: discourse-ingress
389+ name: discourse-k8s-ingress
390 spec:
391 rules:
392 - host: discourse.local
393 http:
394 paths:
395 - backend:
396- serviceName: discourse
397+ serviceName: discourse-k8s
398 servicePort: 3000
399 path: '/'
400 tls:
401diff --git a/tests/unit/fixtures/config_valid_no_saml_fingerprint.yaml b/tests/unit/fixtures/config_valid_no_saml_fingerprint.yaml
402index 4f7365a..cc76606 100644
403--- a/tests/unit/fixtures/config_valid_no_saml_fingerprint.yaml
404+++ b/tests/unit/fixtures/config_valid_no_saml_fingerprint.yaml
405@@ -11,6 +11,7 @@ config:
406 image_pass: ''
407 image_user: ''
408 redis_host: 10.9.89.197
409+ redis_port: 6379
410 smtp_address: 167.89.123.58
411 smtp_authentication: login
412 smtp_domain: example.com
413diff --git a/tests/unit/fixtures/config_valid_no_saml_target_url.yaml b/tests/unit/fixtures/config_valid_no_saml_target_url.yaml
414index 2f31a4f..1364998 100644
415--- a/tests/unit/fixtures/config_valid_no_saml_target_url.yaml
416+++ b/tests/unit/fixtures/config_valid_no_saml_target_url.yaml
417@@ -11,6 +11,7 @@ config:
418 image_pass: ''
419 image_user: ''
420 redis_host: 10.9.89.197
421+ redis_port: 6379
422 smtp_address: 167.89.123.58
423 smtp_authentication: login
424 smtp_domain: example.com
425diff --git a/tests/unit/fixtures/config_valid_no_tls.yaml b/tests/unit/fixtures/config_valid_no_tls.yaml
426index 1f7e021..8179e1a 100644
427--- a/tests/unit/fixtures/config_valid_no_tls.yaml
428+++ b/tests/unit/fixtures/config_valid_no_tls.yaml
429@@ -12,6 +12,7 @@ config:
430 image_user: ''
431 max_body_size: 20
432 redis_host: 10.9.89.197
433+ redis_port: 6379
434 smtp_address: 167.89.123.58
435 smtp_authentication: login
436 smtp_domain: example.com
437@@ -51,7 +52,7 @@ pod_config:
438 pod_spec:
439 version: 3
440 containers:
441- - name: discourse
442+ - name: discourse-k8s
443 envConfig:
444 DISCOURSE_CORS_ORIGIN: '*'
445 DISCOURSE_DEVELOPER_EMAILS: some.person@example.com
446@@ -99,13 +100,13 @@ pod_spec:
447 nginx.ingress.kubernetes.io/session-cookie-name: 'DISCOURSE_AFFINITY'
448 nginx.ingress.kubernetes.io/session-cookie-samesite: 'Lax'
449 nginx.ingress.kubernetes.io/ssl-redirect: 'false'
450- name: discourse-ingress
451+ name: discourse-k8s-ingress
452 spec:
453 rules:
454 - host: discourse.local
455 http:
456 paths:
457 - backend:
458- serviceName: discourse
459+ serviceName: discourse-k8s
460 servicePort: 3000
461 path: '/'
462diff --git a/tests/unit/fixtures/config_valid_with_tls.yaml b/tests/unit/fixtures/config_valid_with_tls.yaml
463index 8e9e225..a4eff75 100644
464--- a/tests/unit/fixtures/config_valid_with_tls.yaml
465+++ b/tests/unit/fixtures/config_valid_with_tls.yaml
466@@ -11,6 +11,7 @@ config:
467 image_pass: 'discourse123'
468 image_user: 'discourse_role'
469 redis_host: 10.9.89.197
470+ redis_port: 6379
471 smtp_address: 167.89.123.58
472 smtp_authentication: login
473 smtp_domain: example.com
474diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py
475index 20fe84e..40da7db 100644
476--- a/tests/unit/test_charm.py
477+++ b/tests/unit/test_charm.py
478@@ -56,7 +56,7 @@ class TestDiscourseK8sCharmHooksDisabled(unittest.TestCase):
479 if config_key.startswith('config_valid_'):
480 config_valid = self.harness.charm.check_config_is_valid(self.configs[config_key]['config'])
481 pod_config = create_discourse_pod_config(self.configs[config_key]['config'])
482- self.assertEqual(config_valid, True, 'Valid config is not recognized as valid')
483+ self.assertEqual(config_valid, True, 'Valid config {} is not recognized as valid'.format(config_key))
484 self.assertEqual(
485 pod_config,
486 self.configs[config_key]['pod_config'],
487@@ -77,7 +77,9 @@ class TestDiscourseK8sCharmHooksDisabled(unittest.TestCase):
488 for config_key in self.configs:
489 if config_key.startswith('config_invalid_'):
490 config_valid = self.harness.charm.check_config_is_valid(self.configs[config_key]['config'])
491- missing_fields = check_for_missing_config_fields(self.configs[config_key]['config'])
492+ stored = SimpleNamespace()
493+ stored.redis_relation = {}
494+ missing_fields = check_for_missing_config_fields(self.configs[config_key]['config'], stored)
495 self.assertEqual(config_valid, False, 'Invalid config is not recognized as invalid')
496 self.assertEqual(
497 missing_fields,
498@@ -91,6 +93,8 @@ class TestDiscourseK8sCharmHooksDisabled(unittest.TestCase):
499 test_config['db_user'] = 'discourse_m'
500 test_config['db_password'] = 'a_real_password'
501 test_config['db_host'] = '10.9.89.237'
502+ test_config['redis_host'] = '10.9.89.197'
503+ test_config['redis_port'] = 6379
504 db_event = SimpleNamespace()
505 db_event.master = SimpleNamespace()
506 db_event.master.user = 'discourse_m'

Subscribers

People subscribed via source and target branches