Merge ~arturo-seijas/charm-k8s-discourse:sidecar into charm-k8s-discourse:master
- Git
- lp:~arturo-seijas/charm-k8s-discourse
- sidecar
- Merge into master
Status: | Superseded |
---|---|
Proposed branch: | ~arturo-seijas/charm-k8s-discourse:sidecar |
Merge into: | charm-k8s-discourse:master |
Diff against target: |
1109 lines (+662/-113) (has conflicts) 21 files modified
README.md (+10/-3) config.yaml (+5/-2) dev/null (+0/-4) lib/charms/nginx_ingress_integrator/v0/ingress.py (+200/-0) metadata.yaml (+12/-3) pyproject.toml (+4/-0) src/charm.py (+189/-70) tests/__init__.py (+0/-0) tests/unit/__init__.py (+6/-0) tests/unit/_patched_charm.py (+59/-0) tests/unit/fixtures/config_invalid_bad_throttle_mode.yaml (+3/-0) tests/unit/fixtures/config_invalid_force_saml_no_saml_target.yaml (+3/-0) tests/unit/fixtures/config_invalid_missing_cors.yaml (+3/-0) tests/unit/fixtures/config_valid_complete.yaml (+3/-0) tests/unit/fixtures/config_valid_no_saml_fingerprint.yaml (+3/-0) tests/unit/fixtures/config_valid_no_saml_target_url.yaml (+3/-0) tests/unit/fixtures/config_valid_no_tls.yaml (+3/-0) tests/unit/fixtures/config_valid_with_tls.yaml (+3/-0) tests/unit/fixtures/config_valid_without_tls.yaml (+3/-0) tests/unit/test_charm.py (+140/-31) tox.ini (+10/-0) Conflict in config.yaml Conflict in src/charm.py Conflict in tests/unit/fixtures/config_invalid_bad_throttle_mode.yaml Conflict in tests/unit/fixtures/config_invalid_force_saml_no_saml_target.yaml Conflict in tests/unit/fixtures/config_invalid_missing_cors.yaml Conflict in tests/unit/fixtures/config_invalid_missing_db_name.yaml Conflict in tests/unit/fixtures/config_invalid_missing_required_s3_options.yaml Conflict in tests/unit/fixtures/config_valid_complete.yaml Conflict in tests/unit/fixtures/config_valid_no_saml_fingerprint.yaml Conflict in tests/unit/fixtures/config_valid_no_saml_target_url.yaml Conflict in tests/unit/fixtures/config_valid_no_tls.yaml Conflict in tests/unit/fixtures/config_valid_with_tls.yaml Conflict in tests/unit/fixtures/config_valid_without_tls.yaml Conflict in tests/unit/test_charm.py Conflict in tox.ini |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Johann David Krister Andersson (community) | Needs Fixing | ||
Canonical IS Reviewers | Pending | ||
Discourse Charm Maintainers | Pending | ||
Review via email:
|
This proposal has been superseded by a proposal from 2022-09-15.
Commit message
Description of the change
Conversion to sidecar. Based on the MR https:/
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
Unable to determine commit message from repository - please click "Set commit message" and enter the commit message manually.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Johann David Krister Andersson (jdkandersson) wrote : | # |
A few comments, it looks like there are a bunch of merge conflicts in here
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Arturo Enrique Seijas Fernández (arturo-seijas) : | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Mariyan Dimitrov (merkata) wrote (last edit ): | # |
Good stuff, only thing is that there are some merge conflicts currently that need fixing.
*UPDATE* I was looking at a previous version, this looks good!
Unmerged commits
- 63a1dce... by Arturo Enrique Seijas Fernández
-
Code style
- 67dcce1... by Arturo Enrique Seijas Fernández
-
Code style
- 00a6ef9... by Arturo Enrique Seijas Fernández
-
Migrate to sidecar pattern
- 5ea251e... by Jay Kuri
-
Update readme with ingress details, update external_hostname to default to app_name
- 46439f2... by Jay Kuri
-
change test to use correct call for generating env. Comment old non-sidecar items until they can be rewritten.
- c0471b7... by Jay Kuri
-
Updates to startup process + logging - WIP
- cd00284... by Jay Kuri
-
Revisions for today. WIP
- ddbdca5... by Jay Kuri
-
Add ingress integrator and attempt to integrate it. Still WIP.
- 070e47d... by Jay Kuri
-
Revisions for today - WIP.
- f3cbf83... by Jay Kuri
-
Initial tentative steps at converting to sidecar
Preview Diff
1 | diff --git a/README.md b/README.md |
2 | index b49813d..12c4d16 100644 |
3 | --- a/README.md |
4 | +++ b/README.md |
5 | @@ -23,8 +23,15 @@ To deploy into a Juju K8s model: |
6 | juju relate discourse-k8s postgresql-k8s:db-admin |
7 | juju relate discourse-k8s redis-k8s |
8 | |
9 | +Then expose Discourse via the Kubernetes ingress: |
10 | + |
11 | + juju deploy nginx-ingress-integrator |
12 | + juju relate discourse-k8s nginx-ingress-integrator |
13 | + |
14 | Once the deployment is completed and the "discourse" workload state in `juju |
15 | -status` has changed to "active" you can visit http://{$discourse_ip}:3000 in a |
16 | -browser and log in to your Discourse instance. |
17 | +status` has changed to "active" you can add `discourse-k8s` to `/etc/hosts` |
18 | +with the IP address of your Kubernetes cluster's ingress (127.0.0.1 if you're |
19 | +using MicroK8s) and visit `http://discourse-k8s` in a browser and log in to |
20 | +your Discourse instance. |
21 | |
22 | -For further details, [see here](https://charmhub.io/discourse-charmers-discourse-k8s/docs). |
23 | +For further details, [see here](https://charmhub.io/discourse-k8s/docs). |
24 | diff --git a/config.yaml b/config.yaml |
25 | index e74a8c3..01704f1 100644 |
26 | --- a/config.yaml |
27 | +++ b/config.yaml |
28 | @@ -1,4 +1,5 @@ |
29 | options: |
30 | +<<<<<<< config.yaml |
31 | discourse_image: |
32 | type: string |
33 | description: "Discourse image to use" |
34 | @@ -11,14 +12,16 @@ options: |
35 | type: string |
36 | description: "Private registry password" |
37 | default: "" |
38 | +======= |
39 | +>>>>>>> config.yaml |
40 | db_name: |
41 | type: string |
42 | description: "PostgreSQL database name. Defaults to Juju Application name." |
43 | default: "" |
44 | external_hostname: |
45 | type: string |
46 | - description: "External hostname this discourse instance should respond to" |
47 | - default: "foo.internal" |
48 | + description: "External hostname this discourse instance should respond to. Defaults to the deployed application name." |
49 | + default: "" |
50 | enable_cors: |
51 | type: boolean |
52 | description: "Enable Cross-origin Resource Sharing (CORS) at the application level (required for SSO)" |
53 | diff --git a/lib/charms/nginx_ingress_integrator/v0/ingress.py b/lib/charms/nginx_ingress_integrator/v0/ingress.py |
54 | new file mode 100644 |
55 | index 0000000..4d98ee5 |
56 | --- /dev/null |
57 | +++ b/lib/charms/nginx_ingress_integrator/v0/ingress.py |
58 | @@ -0,0 +1,200 @@ |
59 | +"""Library for the ingress relation. |
60 | + |
61 | +This library contains the Requires and Provides classes for handling |
62 | +the ingress interface. |
63 | + |
64 | +Import `IngressRequires` in your charm, with two required options: |
65 | + - "self" (the charm itself) |
66 | + - config_dict |
67 | + |
68 | +`config_dict` accepts the following keys: |
69 | + - service-hostname (required) |
70 | + - service-name (required) |
71 | + - service-port (required) |
72 | + - limit-rps |
73 | + - limit-whitelist |
74 | + - max-body-size |
75 | + - path-routes |
76 | + - retry-errors |
77 | + - service-namespace |
78 | + - session-cookie-max-age |
79 | + - tls-secret-name |
80 | + |
81 | +See [the config section](https://charmhub.io/nginx-ingress-integrator/configure) for descriptions |
82 | +of each, along with the required type. |
83 | + |
84 | +As an example, add the following to `src/charm.py`: |
85 | +``` |
86 | +from charms.nginx_ingress_integrator.v0.ingress import IngressRequires |
87 | + |
88 | +# In your charm's `__init__` method. |
89 | +self.ingress = IngressRequires(self, {"service-hostname": self.config["external_hostname"], |
90 | + "service-name": self.app.name, |
91 | + "service-port": 80}) |
92 | + |
93 | +# In your charm's `config-changed` handler. |
94 | +self.ingress.update_config({"service-hostname": self.config["external_hostname"]}) |
95 | +``` |
96 | +And then add the following to `metadata.yaml`: |
97 | +``` |
98 | +requires: |
99 | + ingress: |
100 | + interface: ingress |
101 | +``` |
102 | +""" |
103 | + |
104 | +import logging |
105 | + |
106 | +from ops.charm import CharmEvents |
107 | +from ops.framework import EventBase, EventSource, Object |
108 | +from ops.model import BlockedStatus |
109 | + |
110 | +# The unique Charmhub library identifier, never change it |
111 | +LIBID = "db0af4367506491c91663468fb5caa4c" |
112 | + |
113 | +# Increment this major API version when introducing breaking changes |
114 | +LIBAPI = 0 |
115 | + |
116 | +# Increment this PATCH version before using `charmcraft publish-lib` or reset |
117 | +# to 0 if you are raising the major API version |
118 | +LIBPATCH = 7 |
119 | + |
120 | +logger = logging.getLogger(__name__) |
121 | + |
122 | +REQUIRED_INGRESS_RELATION_FIELDS = { |
123 | + "service-hostname", |
124 | + "service-name", |
125 | + "service-port", |
126 | +} |
127 | + |
128 | +OPTIONAL_INGRESS_RELATION_FIELDS = { |
129 | + "limit-rps", |
130 | + "limit-whitelist", |
131 | + "max-body-size", |
132 | + "retry-errors", |
133 | + "service-namespace", |
134 | + "session-cookie-max-age", |
135 | + "tls-secret-name", |
136 | + "path-routes", |
137 | +} |
138 | + |
139 | + |
140 | +class IngressAvailableEvent(EventBase): |
141 | + pass |
142 | + |
143 | + |
144 | +class IngressCharmEvents(CharmEvents): |
145 | + """Custom charm events.""" |
146 | + |
147 | + ingress_available = EventSource(IngressAvailableEvent) |
148 | + |
149 | + |
150 | +class IngressRequires(Object): |
151 | + """This class defines the functionality for the 'requires' side of the 'ingress' relation. |
152 | + |
153 | + Hook events observed: |
154 | + - relation-changed |
155 | + """ |
156 | + |
157 | + def __init__(self, charm, config_dict): |
158 | + super().__init__(charm, "ingress") |
159 | + |
160 | + self.framework.observe(charm.on["ingress"].relation_changed, self._on_relation_changed) |
161 | + |
162 | + self.config_dict = config_dict |
163 | + |
164 | + def _config_dict_errors(self, update_only=False): |
165 | + """Check our config dict for errors.""" |
166 | + blocked_message = "Error in ingress relation, check `juju debug-log`" |
167 | + unknown = [ |
168 | + x |
169 | + for x in self.config_dict |
170 | + if x not in REQUIRED_INGRESS_RELATION_FIELDS | OPTIONAL_INGRESS_RELATION_FIELDS |
171 | + ] |
172 | + if unknown: |
173 | + logger.error( |
174 | + "Ingress relation error, unknown key(s) in config dictionary found: %s", |
175 | + ", ".join(unknown), |
176 | + ) |
177 | + self.model.unit.status = BlockedStatus(blocked_message) |
178 | + return True |
179 | + if not update_only: |
180 | + missing = [x for x in REQUIRED_INGRESS_RELATION_FIELDS if x not in self.config_dict] |
181 | + if missing: |
182 | + logger.error( |
183 | + "Ingress relation error, missing required key(s) in config dictionary: %s", |
184 | + ", ".join(missing), |
185 | + ) |
186 | + self.model.unit.status = BlockedStatus(blocked_message) |
187 | + return True |
188 | + return False |
189 | + |
190 | + def _on_relation_changed(self, event): |
191 | + """Handle the relation-changed event.""" |
192 | + # `self.unit` isn't available here, so use `self.model.unit`. |
193 | + if self.model.unit.is_leader(): |
194 | + if self._config_dict_errors(): |
195 | + return |
196 | + for key in self.config_dict: |
197 | + event.relation.data[self.model.app][key] = str(self.config_dict[key]) |
198 | + |
199 | + def update_config(self, config_dict): |
200 | + """Allow for updates to relation.""" |
201 | + if self.model.unit.is_leader(): |
202 | + self.config_dict = config_dict |
203 | + if self._config_dict_errors(update_only=True): |
204 | + return |
205 | + relation = self.model.get_relation("ingress") |
206 | + if relation: |
207 | + for key in self.config_dict: |
208 | + relation.data[self.model.app][key] = str(self.config_dict[key]) |
209 | + |
210 | + |
211 | +class IngressProvides(Object): |
212 | + """This class defines the functionality for the 'provides' side of the 'ingress' relation. |
213 | + |
214 | + Hook events observed: |
215 | + - relation-changed |
216 | + """ |
217 | + |
218 | + def __init__(self, charm): |
219 | + super().__init__(charm, "ingress") |
220 | + # Observe the relation-changed hook event and bind |
221 | + # self.on_relation_changed() to handle the event. |
222 | + self.framework.observe(charm.on["ingress"].relation_changed, self._on_relation_changed) |
223 | + self.charm = charm |
224 | + |
225 | + def _on_relation_changed(self, event): |
226 | + """Handle a change to the ingress relation. |
227 | + |
228 | + Confirm we have the fields we expect to receive.""" |
229 | + # `self.unit` isn't available here, so use `self.model.unit`. |
230 | + if not self.model.unit.is_leader(): |
231 | + return |
232 | + |
233 | + ingress_data = { |
234 | + field: event.relation.data[event.app].get(field) |
235 | + for field in REQUIRED_INGRESS_RELATION_FIELDS | OPTIONAL_INGRESS_RELATION_FIELDS |
236 | + } |
237 | + |
238 | + missing_fields = sorted( |
239 | + [ |
240 | + field |
241 | + for field in REQUIRED_INGRESS_RELATION_FIELDS |
242 | + if ingress_data.get(field) is None |
243 | + ] |
244 | + ) |
245 | + |
246 | + if missing_fields: |
247 | + logger.error( |
248 | + "Missing required data fields for ingress relation: {}".format( |
249 | + ", ".join(missing_fields) |
250 | + ) |
251 | + ) |
252 | + self.model.unit.status = BlockedStatus( |
253 | + "Missing fields for ingress: {}".format(", ".join(missing_fields)) |
254 | + ) |
255 | + |
256 | + # Create an event that our charm can use to decide it's okay to |
257 | + # configure the ingress. |
258 | + self.charm.on.ingress_available.emit() |
259 | diff --git a/metadata.yaml b/metadata.yaml |
260 | index 0c8beb2..b382d31 100644 |
261 | --- a/metadata.yaml |
262 | +++ b/metadata.yaml |
263 | @@ -12,12 +12,21 @@ maintainers: |
264 | tags: |
265 | - applications |
266 | - forum |
267 | -series: |
268 | - - kubernetes |
269 | -min-juju-version: 2.8.0 |
270 | requires: |
271 | redis: |
272 | interface: redis |
273 | db: |
274 | interface: pgsql |
275 | limit: 1 |
276 | + ingress: |
277 | + interface: ingress |
278 | + limit: 1 |
279 | + |
280 | +containers: |
281 | + discourse: |
282 | + resource: discourse-image |
283 | + |
284 | +resources: |
285 | + discourse-image: |
286 | + type: oci-image |
287 | + description: OCI image for discourse |
288 | diff --git a/pyproject.toml b/pyproject.toml |
289 | index d2f23b9..8d92e28 100644 |
290 | --- a/pyproject.toml |
291 | +++ b/pyproject.toml |
292 | @@ -1,3 +1,7 @@ |
293 | +[tool.coverage.report] |
294 | +fail_under = 85 |
295 | +show_missing = true |
296 | + |
297 | [tool.black] |
298 | skip-string-normalization = true |
299 | line-length = 120 |
300 | diff --git a/src/charm.py b/src/charm.py |
301 | index 4710b2c..cc4c11f 100755 |
302 | --- a/src/charm.py |
303 | +++ b/src/charm.py |
304 | @@ -12,13 +12,11 @@ from charms.redis_k8s.v0.redis import ( |
305 | from ops.charm import CharmBase |
306 | from ops.main import main |
307 | from ops.framework import StoredState |
308 | - |
309 | from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, WaitingStatus |
310 | +from charms.nginx_ingress_integrator.v0.ingress import IngressRequires |
311 | |
312 | |
313 | logger = logging.getLogger(__name__) |
314 | - |
315 | - |
316 | pgsql = ops.lib.use("pgsql", 1, "postgresql-charmers@lists.launchpad.net") |
317 | |
318 | THROTTLE_LEVELS = { |
319 | @@ -42,6 +40,7 @@ THROTTLE_LEVELS = { |
320 | } |
321 | REQUIRED_S3_SETTINGS = ['s3_access_key_id', 's3_bucket', 's3_region', 's3_secret_access_key'] |
322 | |
323 | +<<<<<<< src/charm.py |
324 | |
325 | def create_discourse_pod_config(config): |
326 | """Create the pod environment config from the juju config.""" |
327 | @@ -262,11 +261,14 @@ def check_for_missing_config_fields(config, stored): |
328 | missing_fields.append(key) |
329 | |
330 | return sorted(missing_fields) |
331 | +======= |
332 | +SERVICE_NAME = "discourse" |
333 | +>>>>>>> src/charm.py |
334 | |
335 | |
336 | class DiscourseCharm(CharmBase): |
337 | on = RedisRelationCharmEvents() |
338 | - stored = StoredState() |
339 | + _stored = StoredState() |
340 | |
341 | def __init__(self, *args): |
342 | """Initialization. |
343 | @@ -276,27 +278,41 @@ class DiscourseCharm(CharmBase): |
344 | """ |
345 | super().__init__(*args) |
346 | |
347 | - self.stored.set_default( |
348 | + self._stored.set_default( |
349 | db_name=None, |
350 | db_user=None, |
351 | db_password=None, |
352 | db_host=None, |
353 | - has_db_relation=False, |
354 | - has_db_credentials=False, |
355 | redis_relation={}, |
356 | ) |
357 | - self.framework.observe(self.on.leader_elected, self.configure_pod) |
358 | - self.framework.observe(self.on.config_changed, self.configure_pod) |
359 | - self.framework.observe(self.on.upgrade_charm, self.configure_pod) |
360 | + self.ingress = IngressRequires(self, self._ingress_config()) |
361 | + self.framework.observe(self.on.leader_elected, self.config_changed) |
362 | + self.framework.observe(self.on.discourse_pebble_ready, self.config_changed) |
363 | + self.framework.observe(self.on.config_changed, self.config_changed) |
364 | + self.framework.observe(self.on.upgrade_charm, self.config_changed) |
365 | |
366 | self.db = pgsql.PostgreSQLClient(self, 'db') |
367 | self.framework.observe(self.db.on.database_relation_joined, self.on_database_relation_joined) |
368 | self.framework.observe(self.db.on.master_changed, self.on_database_changed) |
369 | |
370 | - self.redis = RedisRequires(self, self.stored) |
371 | - self.framework.observe(self.on.redis_relation_updated, self.configure_pod) |
372 | - |
373 | - def check_config_is_valid(self, config): |
374 | + self.redis = RedisRequires(self, self._stored) |
375 | + self.framework.observe(self.on.redis_relation_updated, self.config_changed) |
376 | + |
377 | + def _ingress_config(self): |
378 | + """Return a dict of our ingress config.""" |
379 | + ingress_config = { |
380 | + "service-hostname": self.config['external_hostname'] or self.app.name, |
381 | + "service-name": self.app.name, |
382 | + "service-port": 3000, |
383 | + "session-cookie-max-age": 3600, |
384 | + } |
385 | + if self.config["tls_secret_name"]: |
386 | + ingress_config["tls-secret-name"] = self.config["tls_secret_name"] |
387 | + if self.config["max_body_size"]: |
388 | + ingress_config["max-body-size"] = self.config["max_body_size"] |
389 | + return ingress_config |
390 | + |
391 | + def check_config_is_valid(self): |
392 | """Check that the provided config is valid. |
393 | |
394 | - Returns True if config is valid, False otherwise. |
395 | @@ -304,7 +320,7 @@ class DiscourseCharm(CharmBase): |
396 | - Sets model status as appropriate. |
397 | """ |
398 | valid_config = True |
399 | - errors = check_for_config_problems(config, self.stored) |
400 | + errors = self.check_for_config_problems() |
401 | |
402 | # Set status if we have a bad config. |
403 | if errors: |
404 | @@ -315,58 +331,164 @@ class DiscourseCharm(CharmBase): |
405 | |
406 | return valid_config |
407 | |
408 | - def check_db_is_valid(self, state): |
409 | - if not state.has_db_relation: |
410 | - self.model.unit.status = BlockedStatus("db relation is required") |
411 | - return False |
412 | - if not state.has_db_credentials: |
413 | - self.model.unit.status = WaitingStatus("db relation is setting up") |
414 | - return False |
415 | - self.model.unit.status = MaintenanceStatus("db relation is ready") |
416 | - return True |
417 | + def get_saml_config(self): |
418 | + saml_fingerprints = { |
419 | + 'https://login.ubuntu.com/+saml': '32:15:20:9F:A4:3C:8E:3E:8E:47:72:62:9A:86:8D:0E:E6:CF:45:D5' |
420 | + } |
421 | + saml_config = {} |
422 | + |
423 | + if self.config.get('saml_target_url'): |
424 | + saml_config['DISCOURSE_SAML_TARGET_URL'] = self.config['saml_target_url'] |
425 | + saml_config['DISCOURSE_SAML_FULL_SCREEN_LOGIN'] = "true" if self.config['force_saml_login'] else "false" |
426 | + fingerprint = saml_fingerprints.get(self.config['saml_target_url']) |
427 | + if fingerprint: |
428 | + saml_config['DISCOURSE_SAML_CERT_FINGERPRINT'] = fingerprint |
429 | + |
430 | + return saml_config |
431 | + |
432 | + def check_for_config_problems(self): |
433 | + """Check if there are issues with the juju config. |
434 | |
435 | - def configure_pod(self, event=None): |
436 | - """Configure pod. |
437 | + - Primarily looks for missing config options using check_for_missing_config_fields() |
438 | |
439 | - - Verifies config is valid and unit is leader. |
440 | + - Returns a list of errors if any were found. |
441 | + """ |
442 | + errors = [] |
443 | + missing_fields = self.check_for_missing_config_fields() |
444 | + |
445 | + if missing_fields: |
446 | + errors.append('Required configuration missing: {}'.format(" ".join(missing_fields))) |
447 | + |
448 | + if not THROTTLE_LEVELS.get(self.config['throttle_level']): |
449 | + errors.append('throttle_level must be one of: ' + ' '.join(THROTTLE_LEVELS.keys())) |
450 | + |
451 | + if self.config['force_saml_login'] and self.config['saml_target_url'] == '': |
452 | + errors.append('force_saml_login can not be true without a saml_target_url') |
453 | |
454 | - - Configures pod using pod_spec generated from config. |
455 | + return errors |
456 | + |
457 | + def check_for_missing_config_fields(self): |
458 | + """Check for missing fields in juju config. |
459 | + |
460 | + - Returns a list of required fields that are either not present |
461 | + or are empty. |
462 | """ |
463 | + missing_fields = [] |
464 | + |
465 | + needed_fields = [ |
466 | + 'smtp_address', |
467 | + 'cors_origin', |
468 | + 'developer_emails', |
469 | + 'smtp_domain', |
470 | + 'external_hostname', |
471 | + ] |
472 | + # See if Redis connection information has been provided via a relation. |
473 | + redis_hostname = None |
474 | + for redis_unit in self._stored.redis_relation: |
475 | + redis_hostname = self._stored.redis_relation[redis_unit]["hostname"] |
476 | + if not redis_hostname: |
477 | + needed_fields.append("redis_host") |
478 | + for key in needed_fields: |
479 | + if not self.config.get(key): |
480 | + missing_fields.append(key) |
481 | + |
482 | + return sorted(missing_fields) |
483 | + |
484 | + def check_db_is_valid(self): |
485 | + return self._stored.db_name |
486 | + |
487 | + def create_discourse_environment_settings(self): |
488 | + """Create the pod environment config from the existing config.""" |
489 | + |
490 | # Get redis connection information from config but allow overriding |
491 | # via a relation. |
492 | redis_hostname = self.config["redis_host"] |
493 | redis_port = 6379 |
494 | - for redis_unit in self.stored.redis_relation: |
495 | - redis_hostname = self.stored.redis_relation[redis_unit]["hostname"] |
496 | - redis_port = self.stored.redis_relation[redis_unit]["port"] |
497 | - logging.debug("Got redis connection details from relation of %s:%s", redis_hostname, redis_port) |
498 | + for redis_unit in self._stored.redis_relation: |
499 | + redis_hostname = self._stored.redis_relation[redis_unit]["hostname"] |
500 | + redis_port = self._stored.redis_relation[redis_unit]["port"] |
501 | + logger.debug("Got redis connection details from relation of %s:%s", redis_hostname, redis_port) |
502 | + |
503 | + pod_config = { |
504 | + 'DISCOURSE_CORS_ORIGIN': self.config['cors_origin'], |
505 | + 'DISCOURSE_DB_HOST': self._stored.db_host, |
506 | + 'DISCOURSE_DB_NAME': self._stored.db_name, |
507 | + 'DISCOURSE_DB_PASSWORD': self._stored.db_password, |
508 | + 'DISCOURSE_DB_USERNAME': self._stored.db_user, |
509 | + 'DISCOURSE_DEVELOPER_EMAILS': self.config['developer_emails'], |
510 | + 'DISCOURSE_ENABLE_CORS': self.config['enable_cors'], |
511 | + 'DISCOURSE_HOSTNAME': self.config['external_hostname'], |
512 | + 'DISCOURSE_REDIS_HOST': redis_hostname, |
513 | + 'DISCOURSE_REDIS_PORT': redis_port, |
514 | + 'DISCOURSE_REFRESH_MAXMIND_DB_DURING_PRECOMPILE_DAYS': "0", |
515 | + 'DISCOURSE_SERVE_STATIC_ASSETS': "true", |
516 | + 'DISCOURSE_SMTP_ADDRESS': self.config['smtp_address'], |
517 | + 'DISCOURSE_SMTP_AUTHENTICATION': self.config['smtp_authentication'], |
518 | + 'DISCOURSE_SMTP_DOMAIN': self.config['smtp_domain'], |
519 | + 'DISCOURSE_SMTP_OPENSSL_VERIFY_MODE': self.config['smtp_openssl_verify_mode'], |
520 | + 'DISCOURSE_SMTP_PASSWORD': self.config['smtp_password'], |
521 | + 'DISCOURSE_SMTP_PORT': self.config['smtp_port'], |
522 | + 'DISCOURSE_SMTP_USER_NAME': self.config['smtp_username'], |
523 | + } |
524 | + |
525 | + saml_config = self.get_saml_config() |
526 | + for key in saml_config: |
527 | + pod_config[key] = saml_config[key] |
528 | + |
529 | + # We only get valid throttle levels here, otherwise it would be caught |
530 | + # by `check_for_config_problems`, so we can be sure this won't raise a |
531 | + # KeyError. |
532 | + for key in THROTTLE_LEVELS[self.config['throttle_level']]: |
533 | + pod_config[key] = THROTTLE_LEVELS[self.config['throttle_level']][key] |
534 | + |
535 | + return pod_config |
536 | + |
537 | + def create_layer_config(self): |
538 | + """Create a layer config based on our current configuration. |
539 | + |
540 | + - uses create_discourse_environment_settings to genreate the environment we need. |
541 | + """ |
542 | + logger.info("Generating Layer config") |
543 | + layer_config = { |
544 | + "summary": "Discourse layer", |
545 | + "description": "Discourse layer", |
546 | + "services": { |
547 | + "discourse": { |
548 | + "override": "replace", |
549 | + "summary": "Discourse web application", |
550 | + "command": "sh -c '/srv/scripts/pod_start >>/srv/discourse/discourse.log 2&>1'", |
551 | + "startup": "enabled", |
552 | + "environment": self.create_discourse_environment_settings(), |
553 | + } |
554 | + }, |
555 | + } |
556 | + return layer_config |
557 | |
558 | - # Set our status while we get configured. |
559 | - self.model.unit.status = MaintenanceStatus('Configuring pod') |
560 | + def config_changed(self, event): |
561 | + """Configure service. |
562 | |
563 | - # Leader must set the pod spec. |
564 | - if self.model.unit.is_leader(): |
565 | - if not self.check_db_is_valid(self.stored): |
566 | - return |
567 | - |
568 | - # Merge our config and state into a single dict and set |
569 | - # defaults here, because the helpers avoid dealing with |
570 | - # the framework. |
571 | - config = dict(self.model.config) |
572 | - config["db_name"] = self.stored.db_name |
573 | - config["db_user"] = self.stored.db_user |
574 | - config["db_password"] = self.stored.db_password |
575 | - config["db_host"] = self.stored.db_host |
576 | - config["redis_host"] = redis_hostname |
577 | - config["redis_port"] = redis_port |
578 | - # Get our spec definition. |
579 | - if self.check_config_is_valid(config): |
580 | - # Get pod spec using our app name and config |
581 | - pod_spec = get_pod_spec(self.framework.model.app.name, config) |
582 | - # Set our pod spec. |
583 | - self.model.pod.set_spec(pod_spec) |
584 | - self.model.unit.status = ActiveStatus() |
585 | - else: |
586 | + - Verifies config is valid |
587 | + |
588 | + - Configures pod using pebble and layer generated from config. |
589 | + """ |
590 | + |
591 | + self.model.unit.status = MaintenanceStatus('Configuring service') |
592 | + |
593 | + if not self.check_db_is_valid(): |
594 | + self.model.unit.status = WaitingStatus("Waiting for database relation") |
595 | + event.defer() |
596 | + return |
597 | + |
598 | + container = self.unit.get_container(SERVICE_NAME) |
599 | + if not container.can_connect(): |
600 | + event.defer() |
601 | + return |
602 | + |
603 | + if self.check_config_is_valid(): |
604 | + layer_config = self.create_layer_config() |
605 | + container.add_layer(SERVICE_NAME, layer_config, combine=True) |
606 | + container.pebble.replan_services() |
607 | + self.ingress.update_config(self._ingress_config()) |
608 | self.model.unit.status = ActiveStatus() |
609 | |
610 | def on_database_relation_joined(self, event): |
611 | @@ -377,7 +499,6 @@ class DiscourseCharm(CharmBase): |
612 | - Required because setting the database name is only possible |
613 | from inside the event handler per https://github.com/canonical/ops-lib-pgsql/issues/2 |
614 | """ |
615 | - self.stored.has_db_relation = True |
616 | # Ensure event.database is always set to a non-empty string. PostgreSQL |
617 | # can infer this if it's in the same model as Discourse, but not if |
618 | # we're using cross-model relations. |
619 | @@ -400,22 +521,20 @@ class DiscourseCharm(CharmBase): |
620 | in the relation event. |
621 | """ |
622 | if event.master is None: |
623 | - self.stored.db_name = None |
624 | - self.stored.db_user = None |
625 | - self.stored.db_password = None |
626 | - self.stored.db_host = None |
627 | - self.stored.has_db_credentials = False |
628 | + self._stored.db_name = None |
629 | + self._stored.db_user = None |
630 | + self._stored.db_password = None |
631 | + self._stored.db_host = None |
632 | self.model.unit.status = WaitingStatus("waiting for db relation") |
633 | return |
634 | |
635 | - self.stored.db_name = event.master.dbname |
636 | - self.stored.db_user = event.master.user |
637 | - self.stored.db_password = event.master.password |
638 | - self.stored.db_host = event.master.host |
639 | - self.stored.has_db_credentials = True |
640 | + self._stored.db_name = event.master.dbname |
641 | + self._stored.db_user = event.master.user |
642 | + self._stored.db_password = event.master.password |
643 | + self._stored.db_host = event.master.host |
644 | |
645 | - self.configure_pod() |
646 | + self.config_changed(event) |
647 | |
648 | |
649 | if __name__ == '__main__': # pragma: no cover |
650 | - main(DiscourseCharm) |
651 | + main(DiscourseCharm, use_juju_for_storage=True) |
652 | diff --git a/tests/__init__.py b/tests/__init__.py |
653 | new file mode 100644 |
654 | index 0000000..e69de29 |
655 | --- /dev/null |
656 | +++ b/tests/__init__.py |
657 | diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py |
658 | new file mode 100644 |
659 | index 0000000..0c77dd5 |
660 | --- /dev/null |
661 | +++ b/tests/unit/__init__.py |
662 | @@ -0,0 +1,6 @@ |
663 | +# Copyright 2022 Canonical Ltd. |
664 | +# See LICENSE file for licensing details. |
665 | + |
666 | +import ops.testing |
667 | + |
668 | +ops.testing.SIMULATE_CAN_CONNECT = True |
669 | diff --git a/tests/unit/_patched_charm.py b/tests/unit/_patched_charm.py |
670 | new file mode 100644 |
671 | index 0000000..14a612b |
672 | --- /dev/null |
673 | +++ b/tests/unit/_patched_charm.py |
674 | @@ -0,0 +1,59 @@ |
675 | +# Copyright 2022 Canonical Ltd. |
676 | +# See LICENSE file for licensing details. |
677 | + |
678 | +"""Patch the ``ops-lib-pgsql`` library for unit testing. |
679 | + |
680 | +This script is used to monkey patch necessary code to allow running unit tests with |
681 | +``ops-lib-pgsql``. This patch needs to be run prior to the importing of the main module since |
682 | +the main module uses ``ops.lib.use`` which runs ``exec_module`` internally and the |
683 | +``ops-lib-pgsql`` just happens to use ``from .client import *``. Combined, that makes patching |
684 | +any private variables inside ``pgsql.client`` afterwards impossible. |
685 | +""" |
686 | + |
687 | +from unittest.mock import MagicMock, patch |
688 | + |
689 | +import ops.lib |
690 | +import pgsql.client |
691 | + |
692 | +__all__ = ["DiscourseCharm", "pgsql_patch"] |
693 | + |
694 | +_og_use = ops.lib.use |
695 | + |
696 | + |
697 | +def _use(*args, **kwargs): |
698 | + print("use: ", args) |
699 | + if args == ("pgsql", 1, "postgresql-charmers@lists.launchpad.net"): |
700 | + return pgsql |
701 | + else: |
702 | + return _og_use(*args, **kwargs) |
703 | + |
704 | + |
705 | +ops.lib.use = _use |
706 | + |
707 | + |
708 | +class _PGSQLPatch: |
709 | + def __init__(self): |
710 | + # borrow some code from |
711 | + # https://github.com/canonical/ops-lib-pgsql/blob/master/tests/test_client.py |
712 | + self._leadership_data = {} |
713 | + self._patch = patch.multiple( |
714 | + pgsql.client, |
715 | + _is_ready=MagicMock(return_value=True), |
716 | + _get_pgsql_leader_data=self._leadership_data.copy, |
717 | + _set_pgsql_leader_data=self._leadership_data.update, |
718 | + ) |
719 | + |
720 | + def _reset_leadership_data(self): |
721 | + self._leadership_data.clear() |
722 | + |
723 | + def start(self): |
724 | + self._reset_leadership_data() |
725 | + self._patch.start() |
726 | + |
727 | + def stop(self): |
728 | + self._reset_leadership_data() |
729 | + self._patch.stop() |
730 | + |
731 | + |
732 | +pgsql_patch = _PGSQLPatch() |
733 | +DiscourseCharm = __import__("charm").DiscourseCharm |
734 | diff --git a/tests/unit/fixtures/config_invalid_bad_throttle_mode.yaml b/tests/unit/fixtures/config_invalid_bad_throttle_mode.yaml |
735 | index a8da6f4..501bc9a 100644 |
736 | --- a/tests/unit/fixtures/config_invalid_bad_throttle_mode.yaml |
737 | +++ b/tests/unit/fixtures/config_invalid_bad_throttle_mode.yaml |
738 | @@ -1,3 +1,4 @@ |
739 | +<<<<<<< tests/unit/fixtures/config_invalid_bad_throttle_mode.yaml |
740 | config: |
741 | cors_origin: '*' |
742 | db_name: discourse |
743 | @@ -23,3 +24,5 @@ config: |
744 | config_problems: |
745 | - 'throttle_level must be one of: none permissive strict' |
746 | missing_fields: [] |
747 | +======= |
748 | +>>>>>>> tests/unit/fixtures/config_invalid_bad_throttle_mode.yaml |
749 | diff --git a/tests/unit/fixtures/config_invalid_force_saml_no_saml_target.yaml b/tests/unit/fixtures/config_invalid_force_saml_no_saml_target.yaml |
750 | index 514f436..b158648 100644 |
751 | --- a/tests/unit/fixtures/config_invalid_force_saml_no_saml_target.yaml |
752 | +++ b/tests/unit/fixtures/config_invalid_force_saml_no_saml_target.yaml |
753 | @@ -1,3 +1,4 @@ |
754 | +<<<<<<< tests/unit/fixtures/config_invalid_force_saml_no_saml_target.yaml |
755 | config: |
756 | cors_origin: '*' |
757 | db_name: discourse |
758 | @@ -25,3 +26,5 @@ config_problems: |
759 | - "'force_saml_login' cannot be true without a 'saml_target_url'" |
760 | - "'saml_sync_groups' cannot be specified without a 'saml_target_url'" |
761 | missing_fields: [] |
762 | +======= |
763 | +>>>>>>> tests/unit/fixtures/config_invalid_force_saml_no_saml_target.yaml |
764 | diff --git a/tests/unit/fixtures/config_invalid_missing_cors.yaml b/tests/unit/fixtures/config_invalid_missing_cors.yaml |
765 | index 14019e0..7ba779c 100644 |
766 | --- a/tests/unit/fixtures/config_invalid_missing_cors.yaml |
767 | +++ b/tests/unit/fixtures/config_invalid_missing_cors.yaml |
768 | @@ -1,3 +1,4 @@ |
769 | +<<<<<<< tests/unit/fixtures/config_invalid_missing_cors.yaml |
770 | config: |
771 | cors_origin: '' |
772 | db_name: discourse |
773 | @@ -23,3 +24,5 @@ config_problems: |
774 | - 'Required configuration missing: cors_origin' |
775 | missing_fields: |
776 | - 'cors_origin' |
777 | +======= |
778 | +>>>>>>> tests/unit/fixtures/config_invalid_missing_cors.yaml |
779 | diff --git a/tests/unit/fixtures/config_valid_complete.yaml b/tests/unit/fixtures/config_valid_complete.yaml |
780 | index 1d14364..33809df 100644 |
781 | --- a/tests/unit/fixtures/config_valid_complete.yaml |
782 | +++ b/tests/unit/fixtures/config_valid_complete.yaml |
783 | @@ -1,3 +1,4 @@ |
784 | +<<<<<<< tests/unit/fixtures/config_valid_complete.yaml |
785 | config: |
786 | cors_origin: '*' |
787 | db_name: discourse |
788 | @@ -144,3 +145,5 @@ pod_spec: |
789 | - hosts: |
790 | - 'discourse.local' |
791 | secretName: 'discourse_local' |
792 | +======= |
793 | +>>>>>>> tests/unit/fixtures/config_valid_complete.yaml |
794 | diff --git a/tests/unit/fixtures/config_valid_no_saml_fingerprint.yaml b/tests/unit/fixtures/config_valid_no_saml_fingerprint.yaml |
795 | index b3f7f54..897377c 100644 |
796 | --- a/tests/unit/fixtures/config_valid_no_saml_fingerprint.yaml |
797 | +++ b/tests/unit/fixtures/config_valid_no_saml_fingerprint.yaml |
798 | @@ -1,3 +1,4 @@ |
799 | +<<<<<<< tests/unit/fixtures/config_valid_no_saml_fingerprint.yaml |
800 | config: |
801 | cors_origin: '*' |
802 | db_name: discourse |
803 | @@ -44,3 +45,5 @@ pod_config: |
804 | DISCOURSE_SAML_FULL_SCREEN_LOGIN: "true" |
805 | DISCOURSE_MAX_REQS_PER_IP_MODE: "none" |
806 | DISCOURSE_MAX_REQS_RATE_LIMIT_ON_PRIVATE: "false" |
807 | +======= |
808 | +>>>>>>> tests/unit/fixtures/config_valid_no_saml_fingerprint.yaml |
809 | diff --git a/tests/unit/fixtures/config_valid_no_saml_target_url.yaml b/tests/unit/fixtures/config_valid_no_saml_target_url.yaml |
810 | index 0999b90..94878ff 100644 |
811 | --- a/tests/unit/fixtures/config_valid_no_saml_target_url.yaml |
812 | +++ b/tests/unit/fixtures/config_valid_no_saml_target_url.yaml |
813 | @@ -1,3 +1,4 @@ |
814 | +<<<<<<< tests/unit/fixtures/config_valid_no_saml_target_url.yaml |
815 | config: |
816 | cors_origin: '*' |
817 | db_name: discourse |
818 | @@ -42,3 +43,5 @@ pod_config: |
819 | DISCOURSE_SERVE_STATIC_ASSETS: 'true' |
820 | DISCOURSE_MAX_REQS_PER_IP_MODE: "none" |
821 | DISCOURSE_MAX_REQS_RATE_LIMIT_ON_PRIVATE: "false" |
822 | +======= |
823 | +>>>>>>> tests/unit/fixtures/config_valid_no_saml_target_url.yaml |
824 | diff --git a/tests/unit/fixtures/config_valid_no_tls.yaml b/tests/unit/fixtures/config_valid_no_tls.yaml |
825 | index 2a57ded..d95507b 100644 |
826 | --- a/tests/unit/fixtures/config_valid_no_tls.yaml |
827 | +++ b/tests/unit/fixtures/config_valid_no_tls.yaml |
828 | @@ -1,3 +1,4 @@ |
829 | +<<<<<<< tests/unit/fixtures/config_valid_no_tls.yaml |
830 | config: |
831 | cors_origin: '*' |
832 | db_name: discourse |
833 | @@ -107,3 +108,5 @@ pod_spec: |
834 | serviceName: discourse-k8s |
835 | servicePort: 3000 |
836 | path: '/' |
837 | +======= |
838 | +>>>>>>> tests/unit/fixtures/config_valid_no_tls.yaml |
839 | diff --git a/tests/unit/fixtures/config_valid_with_tls.yaml b/tests/unit/fixtures/config_valid_with_tls.yaml |
840 | index 18c68bc..d18cdce 100644 |
841 | --- a/tests/unit/fixtures/config_valid_with_tls.yaml |
842 | +++ b/tests/unit/fixtures/config_valid_with_tls.yaml |
843 | @@ -1,3 +1,4 @@ |
844 | +<<<<<<< tests/unit/fixtures/config_valid_with_tls.yaml |
845 | config: |
846 | cors_origin: '*' |
847 | db_name: discourse |
848 | @@ -49,3 +50,5 @@ pod_config: |
849 | DISCOURSE_MAX_USER_API_REQS_PER_MINUTE: 400 |
850 | DISCOURSE_MAX_ASSET_REQS_PER_IP_PER_10_SECONDS: 400 |
851 | DISCOURSE_MAX_REQS_RATE_LIMIT_ON_PRIVATE: "false" |
852 | +======= |
853 | +>>>>>>> tests/unit/fixtures/config_valid_with_tls.yaml |
854 | diff --git a/tests/unit/fixtures/config_valid_without_tls.yaml b/tests/unit/fixtures/config_valid_without_tls.yaml |
855 | index 5091acc..d757c76 100644 |
856 | --- a/tests/unit/fixtures/config_valid_without_tls.yaml |
857 | +++ b/tests/unit/fixtures/config_valid_without_tls.yaml |
858 | @@ -1,3 +1,4 @@ |
859 | +<<<<<<< tests/unit/fixtures/config_valid_without_tls.yaml |
860 | config: |
861 | cors_origin: '*' |
862 | db_name: discourse |
863 | @@ -48,3 +49,5 @@ pod_config: |
864 | DISCOURSE_MAX_USER_API_REQS_PER_MINUTE: 100 |
865 | DISCOURSE_MAX_ASSET_REQS_PER_IP_PER_10_SECONDS: 200 |
866 | DISCOURSE_MAX_REQS_RATE_LIMIT_ON_PRIVATE: "false" |
867 | +======= |
868 | +>>>>>>> tests/unit/fixtures/config_valid_without_tls.yaml |
869 | diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt |
870 | deleted file mode 100644 |
871 | index 65431fc..0000000 |
872 | --- a/tests/unit/requirements.txt |
873 | +++ /dev/null |
874 | @@ -1,4 +0,0 @@ |
875 | -mock |
876 | -pytest |
877 | -pytest-cov |
878 | -pyyaml |
879 | diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py |
880 | index 5342278..d4f73c8 100644 |
881 | --- a/tests/unit/test_charm.py |
882 | +++ b/tests/unit/test_charm.py |
883 | @@ -3,15 +3,12 @@ |
884 | # Copyright 2020 Canonical Ltd. |
885 | # See LICENSE file for licensing details. |
886 | |
887 | -import copy |
888 | -import glob |
889 | -import mock |
890 | -import os |
891 | import unittest |
892 | -import yaml |
893 | |
894 | -from types import SimpleNamespace |
895 | +from ops.model import ActiveStatus, BlockedStatus, WaitingStatus |
896 | +from ops.testing import Harness |
897 | |
898 | +<<<<<<< tests/unit/test_charm.py |
899 | from charm import ( |
900 | check_for_config_problems, |
901 | check_for_missing_config_fields, |
902 | @@ -19,35 +16,18 @@ from charm import ( |
903 | get_pod_spec, |
904 | DiscourseCharm, |
905 | ) |
906 | +======= |
907 | +from tests.unit._patched_charm import DiscourseCharm, pgsql_patch |
908 | +>>>>>>> tests/unit/test_charm.py |
909 | |
910 | -from ops import testing |
911 | |
912 | - |
913 | -def load_configs(directory): |
914 | - """Load configs for use by tests. |
915 | - |
916 | - Valid and invalid configs are present in the fixtures directory. The files |
917 | - contain the juju config, along with the spec that that config should |
918 | - produce in the case of valid configs. In the case of invalid configs, they |
919 | - contain the juju config and the error that should be triggered by the |
920 | - config. These scenarios are tested below. Additional config variations can |
921 | - be created by creating an appropriately named config file in the fixtures |
922 | - directory. Valid configs should be named: config_valid_###.yaml and invalid |
923 | - configs should be named: config_invalid_###.yaml.""" |
924 | - configs = {} |
925 | - for filename in glob.glob(os.path.join(directory, 'config*.yaml')): |
926 | - with open(filename) as file: |
927 | - name, _ = os.path.splitext(os.path.basename(filename)) |
928 | - configs[name] = yaml.full_load(file) |
929 | - return configs |
930 | - |
931 | - |
932 | -class TestDiscourseK8sCharmHooksDisabled(unittest.TestCase): |
933 | +class TestDiscourseK8sCharm(unittest.TestCase): |
934 | def setUp(self): |
935 | - self.harness = testing.Harness(DiscourseCharm) |
936 | - self.harness.disable_hooks() |
937 | - self.harness.set_leader(True) |
938 | + pgsql_patch.start() |
939 | + self.harness = Harness(DiscourseCharm) |
940 | + self.addCleanup(self.harness.cleanup) |
941 | self.harness.begin() |
942 | +<<<<<<< tests/unit/test_charm.py |
943 | self.maxDiff = None |
944 | # Let's list fields we store in config dict() which are not actual charm options |
945 | self.not_actual_charm_config = ['db_user', 'db_password', 'db_host', 'redis_port'] |
946 | @@ -167,3 +147,132 @@ class TestDiscourseK8sCharmHooksDisabled(unittest.TestCase): |
947 | self.harness.update_config(self.configs['config_valid_complete']['config'], unset=self.not_actual_charm_config) |
948 | self.harness.charm.on_database_relation_joined(action_event) |
949 | self.assertEqual(action_event.database, "discourse") |
950 | +======= |
951 | + self.harness.set_leader(True) |
952 | + |
953 | + def tearDown(self): |
954 | + pgsql_patch.stop() |
955 | + |
956 | + def test_db_relation_not_ready(self): |
957 | + self.harness.container_pebble_ready("discourse") |
958 | + self.assertEqual( |
959 | + self.harness.model.unit.status, |
960 | + WaitingStatus("Waiting for database relation"), |
961 | + ) |
962 | + |
963 | + def test_config_changed_when_no_saml_target(self): |
964 | + self.add_database_relations() |
965 | + self.harness.container_pebble_ready("discourse") |
966 | + self.harness.update_config( |
967 | + { |
968 | + "external_hostname": "discourse.local", |
969 | + "force_saml_login": True, |
970 | + } |
971 | + ) |
972 | + self.assertEqual( |
973 | + self.harness.model.unit.status, |
974 | + BlockedStatus("force_saml_login can not be true without a saml_target_url"), |
975 | + ) |
976 | + |
977 | + def test_config_changed_when_no_cors(self): |
978 | + self.add_database_relations() |
979 | + self.harness.container_pebble_ready("discourse") |
980 | + self.harness.update_config( |
981 | + { |
982 | + "cors_origin": "", |
983 | + "external_hostname": "discourse.local", |
984 | + } |
985 | + ) |
986 | + self.assertEqual( |
987 | + self.harness.model.unit.status, |
988 | + BlockedStatus("Required configuration missing: cors_origin"), |
989 | + ) |
990 | + |
991 | + def test_config_changed_when_throttle_mode_invalid(self): |
992 | + self.add_database_relations() |
993 | + self.harness.container_pebble_ready("discourse") |
994 | + self.harness.update_config( |
995 | + { |
996 | + "external_hostname": "discourse.local", |
997 | + "throttle_level": "Scream", |
998 | + } |
999 | + ) |
1000 | + self.assertEqual( |
1001 | + self.harness.model.unit.status, |
1002 | + BlockedStatus("throttle_level must be one of: none permissive strict"), |
1003 | + ) |
1004 | + |
1005 | + def test_config_changed_when_valid(self): |
1006 | + self.add_database_relations() |
1007 | + self.harness.container_pebble_ready("discourse") |
1008 | + self.harness.update_config( |
1009 | + { |
1010 | + "enable_cors": True, |
1011 | + "external_hostname": "discourse.local", |
1012 | + "force_saml_login": True, |
1013 | + "saml_target_url": "https://login.ubuntu.com/+saml", |
1014 | + "smtp_password": "OBV10USLYF4K3", |
1015 | + "smtp_username": "apikey", |
1016 | + "tls_secret_name": "somesecret", |
1017 | + } |
1018 | + ) |
1019 | + |
1020 | + updated_plan = self.harness.get_container_pebble_plan("discourse").to_dict() |
1021 | + print(updated_plan) |
1022 | + updated_plan_env = updated_plan["services"]["discourse"]["environment"] |
1023 | + |
1024 | + self.assertEqual("*", updated_plan_env["DISCOURSE_CORS_ORIGIN"]) |
1025 | + self.assertEqual("dbhost", updated_plan_env["DISCOURSE_DB_HOST"]) |
1026 | + self.assertEqual("discourse-k8s", updated_plan_env["DISCOURSE_DB_NAME"]) |
1027 | + self.assertEqual("somepasswd", updated_plan_env["DISCOURSE_DB_PASSWORD"]) |
1028 | + self.assertEqual("someuser", updated_plan_env["DISCOURSE_DB_USERNAME"]) |
1029 | + self.assertEqual("user@foo.internal", updated_plan_env["DISCOURSE_DEVELOPER_EMAILS"]) |
1030 | + self.assertTrue(updated_plan_env["DISCOURSE_ENABLE_CORS"]) |
1031 | + self.assertEqual("discourse.local", updated_plan_env["DISCOURSE_HOSTNAME"]) |
1032 | + self.assertEqual("redis-host", updated_plan_env["DISCOURSE_REDIS_HOST"]) |
1033 | + self.assertEqual(1010, updated_plan_env["DISCOURSE_REDIS_PORT"]) |
1034 | + self.assertEqual( |
1035 | + "32:15:20:9F:A4:3C:8E:3E:8E:47:72:62:9A:86:8D:0E:E6:CF:45:D5", |
1036 | + updated_plan_env["DISCOURSE_SAML_CERT_FINGERPRINT"], |
1037 | + ) |
1038 | + self.assertEqual("true", updated_plan_env["DISCOURSE_SAML_FULL_SCREEN_LOGIN"]) |
1039 | + self.assertEqual("https://login.ubuntu.com/+saml", updated_plan_env["DISCOURSE_SAML_TARGET_URL"]) |
1040 | + self.assertTrue(updated_plan_env["DISCOURSE_SERVE_STATIC_ASSETS"]) |
1041 | + self.assertEqual("127.0.0.1", updated_plan_env["DISCOURSE_SMTP_ADDRESS"]) |
1042 | + self.assertEqual("none", updated_plan_env["DISCOURSE_SMTP_AUTHENTICATION"]) |
1043 | + self.assertEqual("foo.internal", updated_plan_env["DISCOURSE_SMTP_DOMAIN"]) |
1044 | + self.assertEqual("none", updated_plan_env["DISCOURSE_SMTP_OPENSSL_VERIFY_MODE"]) |
1045 | + self.assertEqual("OBV10USLYF4K3", updated_plan_env["DISCOURSE_SMTP_PASSWORD"]) |
1046 | + self.assertEqual(587, updated_plan_env["DISCOURSE_SMTP_PORT"]) |
1047 | + self.assertEqual("apikey", updated_plan_env["DISCOURSE_SMTP_USER_NAME"]) |
1048 | + |
1049 | + self.assertEqual(self.harness.model.unit.status, ActiveStatus()) |
1050 | + |
1051 | + self.assertEqual("discourse.local", self.harness.charm.ingress.config_dict["service-hostname"]) |
1052 | + self.assertEqual("somesecret", self.harness.charm.ingress.config_dict["tls-secret-name"]) |
1053 | + self.assertEqual(20, self.harness.charm.ingress.config_dict["max-body-size"]) |
1054 | + |
1055 | + def test_db_relation(self): |
1056 | + self.add_database_relations() |
1057 | + self.harness.set_leader(True) |
1058 | + # testing harness not re-emits deferred events, manually trigger that |
1059 | + self.harness.framework.reemit() |
1060 | + db_relation_data = self.harness.get_relation_data(self.db_relation_id, self.harness.charm.app.name) |
1061 | + self.assertEqual( |
1062 | + db_relation_data.get("database"), |
1063 | + "discourse-k8s", |
1064 | + "database name should be set after relation joined", |
1065 | + ) |
1066 | + |
1067 | + def add_database_relations(self): |
1068 | + self.harness.charm._stored.db_name = "discourse-k8s" |
1069 | + self.harness.charm._stored.db_user = "someuser" |
1070 | + self.harness.charm._stored.db_password = "somepasswd" |
1071 | + self.harness.charm._stored.db_host = "dbhost" |
1072 | + self.db_relation_id = self.harness.add_relation("db", self.harness.charm.app.name) |
1073 | + self.harness.add_relation_unit(self.db_relation_id, "postgresql/0") |
1074 | + |
1075 | + redis_relation_id = self.harness.add_relation("redis", self.harness.charm.app.name) |
1076 | + self.harness.add_relation_unit(redis_relation_id, "redis/0") |
1077 | + self.harness.charm._stored.redis_relation = {redis_relation_id: {"hostname": "redis-host", "port": 1010}} |
1078 | +>>>>>>> tests/unit/test_charm.py |
1079 | diff --git a/tox.ini b/tox.ini |
1080 | index 6c0b24c..63e371f 100644 |
1081 | --- a/tox.ini |
1082 | +++ b/tox.ini |
1083 | @@ -5,6 +5,13 @@ skip_missing_interpreters = True |
1084 | |
1085 | [testenv] |
1086 | basepython = python3 |
1087 | +<<<<<<< tox.ini |
1088 | +======= |
1089 | +setenv = |
1090 | + PYTHONPATH = {toxinidir}/lib:{toxinidir}/src:{toxinidir}/tests:{toxinidir}/build/lib:{toxinidir}/build/venv |
1091 | +passenv = |
1092 | + PYTHONPATH |
1093 | +>>>>>>> tox.ini |
1094 | |
1095 | [testenv:unit] |
1096 | commands = |
1097 | @@ -12,9 +19,12 @@ commands = |
1098 | {posargs:-v --cov=src --cov-report=term-missing --cov-branch} |
1099 | deps = -r{toxinidir}/tests/unit/requirements.txt |
1100 | -r{toxinidir}/requirements.txt |
1101 | +<<<<<<< tox.ini |
1102 | setenv = |
1103 | PYTHONPATH = {toxinidir}/lib:{toxinidir}/src |
1104 | TZ=UTC |
1105 | +======= |
1106 | +>>>>>>> tox.ini |
1107 | |
1108 | [testenv:black] |
1109 | commands = black src/ tests/ |
This merge proposal is being monitored by mergebot. Change the status to Approved to merge.