Merge ~axino/charm-k8s-gunicorn/+git/charm-k8s-gunicorn:relations into charm-k8s-gunicorn:master
- Git
- lp:~axino/charm-k8s-gunicorn/+git/charm-k8s-gunicorn
- relations
- Merge into master
Status: | Merged |
---|---|
Approved by: | Junien F |
Approved revision: | 5d73872be9a0b852bfa238fe1d54d4be44d776b8 |
Merged at revision: | 297c99c68838433f5b8d1c497835b7c19bea3d1d |
Proposed branch: | ~axino/charm-k8s-gunicorn/+git/charm-k8s-gunicorn:relations |
Merge into: | charm-k8s-gunicorn:master |
Prerequisite: | ~axino/charm-k8s-gunicorn/+git/tmp:axino |
Diff against target: |
1217 lines (+715/-135) 13 files modified
.jujuignore (+2/-0) Makefile (+14/-13) README.md (+62/-2) config.yaml (+14/-7) docker/app/app/app.py (+4/-3) metadata.yaml (+7/-0) pyproject.toml (+3/-0) requirements.txt (+1/-0) src/charm.py (+210/-40) tests/unit/requirements.txt (+1/-0) tests/unit/scenario.py (+60/-37) tests/unit/test_charm.py (+334/-18) tox.ini (+3/-15) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Junien F | Approve | ||
Tom Haddon | Approve | ||
Facundo Batista (community) | Approve | ||
Review via email: mp+389960@code.launchpad.net |
Commit message
add postgresql and influxdb relations, fix tests, improve documentation and other misc fixes
Description of the change
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
🤖 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.
Tom Haddon (mthaddon) wrote : | # |
Two small comments inline. Once those are addressed I think this is ready for review from charmcrafters.
Tom Haddon (mthaddon) wrote : | # |
Looks good to me, will add charmcrafters for review, but leave my review as is for now. Once we're ready to merge I can add an approving review if needed.
Facundo Batista (facundo) wrote : | # |
Looks awesome!!
I annotated some inline comments, thanks!!
Junien F (axino) wrote : | # |
Thanks ! Fixes incoming
Junien F (axino) wrote : | # |
Forgot to resubmit
Facundo Batista (facundo) wrote : | # |
Approved, but please check the inline comments for two minor details. Thanks!!
Tom Haddon (mthaddon) wrote : | # |
Adding an approving comment so this can be merged.
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
Change successfully merged at revision 297c99c68838433
Preview Diff
1 | diff --git a/.jujuignore b/.jujuignore |
2 | index 423f33d..ee9772c 100644 |
3 | --- a/.jujuignore |
4 | +++ b/.jujuignore |
5 | @@ -1,6 +1,8 @@ |
6 | /env |
7 | *.py[cod] |
8 | *.charm |
9 | +.pytest_cache |
10 | docker |
11 | *swp |
12 | venv |
13 | +*__pycache__* |
14 | diff --git a/Makefile b/Makefile |
15 | index 3e1801a..965b540 100644 |
16 | --- a/Makefile |
17 | +++ b/Makefile |
18 | @@ -1,23 +1,24 @@ |
19 | -blacken: |
20 | +gunicorn.charm: src/*.py requirements.txt metadata.yaml config.yaml test |
21 | + charmcraft build |
22 | + |
23 | +blacken: |
24 | @echo "Normalising python layout with black." |
25 | @tox -e black |
26 | - |
27 | -lint: blacken |
28 | + |
29 | +lint: blacken |
30 | @echo "Running flake8" |
31 | - @tox -e lint |
32 | + @tox -e lint |
33 | |
34 | # We actually use the build directory created by charmcraft, |
35 | # but the .charm file makes a much more convenient sentinel. |
36 | -unittest: gunicorn.charm |
37 | +unittest: |
38 | @tox -e unit |
39 | |
40 | test: lint unittest |
41 | - |
42 | -clean: |
43 | + |
44 | +clean: |
45 | @echo "Cleaning files" |
46 | - @git clean -fXd |
47 | - |
48 | -gunicorn.charm: src/*.py requirements.txt |
49 | - charmcraft build |
50 | - |
51 | -.PHONY: lint test unittest clean |
52 | + @git clean -fXd |
53 | + |
54 | + |
55 | +.PHONY: lint test unittest blacken clean |
56 | diff --git a/README.md b/README.md |
57 | index 646cbc9..b5a44d2 100644 |
58 | --- a/README.md |
59 | +++ b/README.md |
60 | @@ -6,9 +6,69 @@ A charm that allows you to deploy your gunicorn application in kubernetes. |
61 | |
62 | ## Usage |
63 | |
64 | -juju deploy cs:gunicorn my-awesome-app |
65 | -juju config my-awesome-app image\_path=localhost:32000/myapp |
66 | +``` |
67 | +juju deploy cs:gunicorn my-awesome-app --config image_path=localhost:32000/myapp --config external_hostname=my-awesome-app.com |
68 | +``` |
69 | |
70 | ### Scale Out Usage |
71 | |
72 | juju add-unit my-awesome-app |
73 | + |
74 | +## OCI image |
75 | + |
76 | +### Using your own image |
77 | + |
78 | +You can, of course, supply our own OCI image. gunicorn is expected to listen on |
79 | +port 80. |
80 | + |
81 | +### Using gunicorn-base to build an image |
82 | + |
83 | +If you have a gunicorn app that's not available via a Docker image, you can use |
84 | +the provided `gunicorn-base` image. First, build the base image that's |
85 | +available in the `docker/gunicorn-base` directory. You can then use it as a |
86 | +base for other images, and there's an example of that in the `docker/app` |
87 | +directory. |
88 | + |
89 | +This example app will simply display all the environment variables given to |
90 | +your pods. It can be helpful to see what's available and to debug problems |
91 | +related to environment variables. |
92 | + |
93 | +## Environment variables and relations |
94 | + |
95 | +This charm has been designed to easily allow you to pass information coming |
96 | +from relation data to your pods. This is done by using the `environment` config |
97 | +option. This config option is a Jinja2 template for a YAML dict that will be |
98 | +added to the environment of your pods. |
99 | + |
100 | +The context used to render the Jinja2 template is constructed from relation |
101 | +data. For example, if you're relating with influxdb, you could do the following : |
102 | +``` |
103 | +juju deploy cs:gunicorn my-awesome-app --config image_path=localhost:32000/myapp --config external_hostname=my-awesome-app.com |
104 | +juju config my-awesome-app environment="INFLUXDB_HOST: {{influxdb.hostname}}" |
105 | +``` |
106 | + |
107 | +The charm will notice that you're trying to use data from the `influxdb` relation, |
108 | +and will block until such a relation is added. Once the relation is added, the |
109 | +charm will get the `hostname` from the relation, and will make it available to |
110 | +your pod as the `INFLUXDB_HOST` environment variable. |
111 | + |
112 | +If you want the charm to handle more "basic" relations such as the `influxdb` |
113 | +one described above, all you have to do is add the relation to metadata.yaml |
114 | +and rebuild the charm (see below). |
115 | + |
116 | +Some relations, such as the `postgresql` relation, are a bit more complex, in |
117 | +that they're managed by a library. Instead of using raw relation data, you use |
118 | +the library to get useful and usable information out of the relation. If you |
119 | +want to use such a relation, you will need to add a bit more code to make the |
120 | +information provided by the library availeble to the Jinja2 context. An example |
121 | +is provided in the charm with the `postgresql` relation implementation. |
122 | + |
123 | +## Building the charm |
124 | + |
125 | +It's as easy as running : |
126 | +``` |
127 | +make |
128 | +``` |
129 | + |
130 | +This will lint and format your code, then run unit tests, and then build the |
131 | +charm. |
132 | diff --git a/config.yaml b/config.yaml |
133 | index 3a87de3..0754ab4 100644 |
134 | --- a/config.yaml |
135 | +++ b/config.yaml |
136 | @@ -1,28 +1,35 @@ |
137 | options: |
138 | image_path: |
139 | type: string |
140 | - description: | |
141 | + description: > |
142 | The location of the image to use, e.g. "registry.example.com/my_gunicorn_app:v1". |
143 | |
144 | This setting is required. |
145 | default: '' |
146 | image_username: |
147 | type: string |
148 | - description: | |
149 | + description: > |
150 | The username for accessing the registry specified in image_path. |
151 | default: '' |
152 | image_password: |
153 | type: string |
154 | - description: | |
155 | + description: > |
156 | The password associated with image_username for accessing the registry specified in image_path. |
157 | default: '' |
158 | environment: |
159 | type: string |
160 | - description: | |
161 | - This YAML-formatted associative array will be added to the environment of the container. |
162 | + description: > |
163 | + Jinja2 template of a YAML-formatted associative array which will be added |
164 | + to the environment of the container. The context used to render the template |
165 | + is filled with relation data. For example, if you use : |
166 | + DEBUG_LEVEL: 2 |
167 | + DB_URI: {{pg.uri}} |
168 | + your pods will get the "DEBUG_LEVEL" environment variable set to 2, and |
169 | + the "DB_URI" environment variable will be set to the "uri" relation data |
170 | + item provided by the "pg" relation. |
171 | default: '' |
172 | external_hostname: |
173 | type: string |
174 | - description: | |
175 | - External hostname this gunicorn app should respond to |
176 | + description: > |
177 | + External hostname this gunicorn app should respond to. |
178 | default: '' |
179 | diff --git a/docker/app/app/app.py b/docker/app/app/app.py |
180 | index 9d75da5..fbb9fc5 100644 |
181 | --- a/docker/app/app/app.py |
182 | +++ b/docker/app/app/app.py |
183 | @@ -1,11 +1,12 @@ |
184 | import os |
185 | |
186 | + |
187 | def app(environ, start_response): |
188 | status = '200 OK' |
189 | - response_headers = [('Content-type','text/plain')] |
190 | + response_headers = [('Content-type', 'text/plain')] |
191 | start_response(status, response_headers) |
192 | ret = [b'One of the nice things about the new operator framework is how easy it is to get started.\n'] |
193 | - for i,x in os.environ.items(): |
194 | - ret.append("{}: {}\n".format(i,x).encode('utf-8')) |
195 | + for i, x in os.environ.items(): |
196 | + ret.append("{}: {}\n".format(i, x).encode('utf-8')) |
197 | |
198 | return ret |
199 | diff --git a/metadata.yaml b/metadata.yaml |
200 | index 3cbca9b..4e6d497 100644 |
201 | --- a/metadata.yaml |
202 | +++ b/metadata.yaml |
203 | @@ -7,3 +7,10 @@ summary: | |
204 | Gunicorn charm |
205 | series: [kubernetes] |
206 | min-juju-version: 2.8.0 # charm storage in state |
207 | +requires: |
208 | + pg: |
209 | + interface: pgsql |
210 | + limit: 1 |
211 | + influxdb: |
212 | + interface: influxdb-api |
213 | + limit: 1 |
214 | diff --git a/pyproject.toml b/pyproject.toml |
215 | new file mode 100644 |
216 | index 0000000..d2f23b9 |
217 | --- /dev/null |
218 | +++ b/pyproject.toml |
219 | @@ -0,0 +1,3 @@ |
220 | +[tool.black] |
221 | +skip-string-normalization = true |
222 | +line-length = 120 |
223 | diff --git a/requirements.txt b/requirements.txt |
224 | index 2d81d3b..fd6adcd 100644 |
225 | --- a/requirements.txt |
226 | +++ b/requirements.txt |
227 | @@ -1 +1,2 @@ |
228 | ops |
229 | +ops-lib-pgsql |
230 | diff --git a/src/charm.py b/src/charm.py |
231 | index 1bbece3..3772ef0 100755 |
232 | --- a/src/charm.py |
233 | +++ b/src/charm.py |
234 | @@ -2,6 +2,7 @@ |
235 | # Copyright 2020 Canonical Ltd. |
236 | # See LICENSE file for licensing details. |
237 | |
238 | +from jinja2 import Environment, BaseLoader, meta |
239 | import logging |
240 | import yaml |
241 | |
242 | @@ -14,6 +15,7 @@ from ops.model import ( |
243 | BlockedStatus, |
244 | MaintenanceStatus, |
245 | ) |
246 | +import pgsql |
247 | |
248 | |
249 | logger = logging.getLogger(__name__) |
250 | @@ -28,18 +30,72 @@ class GunicornK8sCharmJujuConfigError(Exception): |
251 | pass |
252 | |
253 | |
254 | +class GunicornK8sCharmYAMLError(Exception): |
255 | + """Exception raised when parsing YAML fails""" |
256 | + |
257 | + pass |
258 | + |
259 | + |
260 | class GunicornK8sCharm(CharmBase): |
261 | _stored = StoredState() |
262 | |
263 | def __init__(self, *args): |
264 | super().__init__(*args) |
265 | |
266 | - self.framework.observe(self.on.start, self.configure_pod) |
267 | - self.framework.observe(self.on.config_changed, self.configure_pod) |
268 | - self.framework.observe(self.on.leader_elected, self.configure_pod) |
269 | - self.framework.observe(self.on.upgrade_charm, self.configure_pod) |
270 | + self.framework.observe(self.on.start, self._configure_pod) |
271 | + self.framework.observe(self.on.config_changed, self._configure_pod) |
272 | + self.framework.observe(self.on.leader_elected, self._configure_pod) |
273 | + self.framework.observe(self.on.upgrade_charm, self._configure_pod) |
274 | + |
275 | + # For special-cased relations |
276 | + self._stored.set_default(reldata={}) |
277 | + |
278 | + self._init_postgresql_relation() |
279 | + |
280 | + def _init_postgresql_relation(self) -> None: |
281 | + """Initialization related to the postgresql relation""" |
282 | + if 'pg' not in self._stored.reldata: |
283 | + self._stored.reldata['pg'] = {} |
284 | + self.pg = pgsql.PostgreSQLClient(self, 'pg') |
285 | + self.framework.observe(self.pg.on.database_relation_joined, self._on_database_relation_joined) |
286 | + self.framework.observe(self.pg.on.master_changed, self._on_master_changed) |
287 | + self.framework.observe(self.pg.on.standby_changed, self._on_standby_changed) |
288 | + |
289 | + def _on_database_relation_joined(self, event: pgsql.DatabaseRelationJoinedEvent) -> None: |
290 | + """Handle db-relation-joined.""" |
291 | + if self.model.unit.is_leader(): |
292 | + # Provide requirements to the PostgreSQL server. |
293 | + event.database = self.app.name # Request database named like the Juju app |
294 | + elif event.database != self.app.name: |
295 | + # Leader has not yet set requirements. Defer, in case this unit |
296 | + # becomes leader and needs to perform that operation. |
297 | + event.defer() |
298 | + |
299 | + def _on_master_changed(self, event: pgsql.MasterChangedEvent) -> None: |
300 | + """Handle changes in the primary database unit.""" |
301 | + if event.database != self.app.name: |
302 | + # Leader has not yet set requirements. Wait until next |
303 | + # event, or risk connecting to an incorrect database. |
304 | + return |
305 | + |
306 | + self._stored.reldata['pg']['conn_str'] = None if event.master is None else event.master.conn_str |
307 | + self._stored.reldata['pg']['db_uri'] = None if event.master is None else event.master.uri |
308 | + |
309 | + if event.master is None: |
310 | + return |
311 | + |
312 | + self._configure_pod(event) |
313 | + |
314 | + def _on_standby_changed(self, event: pgsql.StandbyChangedEvent) -> None: |
315 | + """Handle changes in the secondary database unit(s).""" |
316 | + if event.database != self.app.name: |
317 | + # Leader has not yet set requirements. Wait until next |
318 | + # event, or risk connecting to an incorrect database. |
319 | + return |
320 | |
321 | - self._stored.set_default(things=[]) |
322 | + self._stored.reldata['pg']['ro_uris'] = [c.uri for c in event.standbys] |
323 | + |
324 | + # TODO: Emit event when we add support for read replicas |
325 | |
326 | def _check_juju_config(self) -> None: |
327 | """Check if all the required Juju config options are set, |
328 | @@ -56,35 +112,13 @@ class GunicornK8sCharm(CharmBase): |
329 | errors.append(required) |
330 | if errors: |
331 | raise GunicornK8sCharmJujuConfigError( |
332 | - "Required Juju config item not set : {0}".format(", ".join(sorted(errors))) |
333 | - ) |
334 | - |
335 | - # Verify YAML formatting |
336 | - errors = [] |
337 | - for item in JUJU_CONFIG_YAML_DICT_ITEMS: |
338 | - supposed_yaml = self.model.config[item] |
339 | - |
340 | - parsed = None |
341 | - |
342 | - try: |
343 | - parsed = yaml.safe_load(supposed_yaml) |
344 | - except yaml.scanner.ScannerError as e: |
345 | - errors.append(item) |
346 | - logger.error("Juju config item '%s' is not YAML : %s", item, str(e)) |
347 | - |
348 | - if parsed and not isinstance(parsed, dict): |
349 | - errors.append(item) |
350 | - logger.error("Juju config item '%s' is not a YAML dict", item) |
351 | - |
352 | - if errors: |
353 | - raise GunicornK8sCharmJujuConfigError( |
354 | - "YAML parsing failed on the Juju config item(s) : {0} - check \"juju debug-log -l ERROR\"".format( |
355 | - ", ".join(sorted(errors)) |
356 | - ) |
357 | + "Required Juju config item(s) not set : {}".format(", ".join(sorted(errors))) |
358 | ) |
359 | |
360 | def _make_k8s_ingress(self) -> list: |
361 | """Return an ingress that you can use in k8s_resources |
362 | + |
363 | + :returns: A list to be used as k8s ingress |
364 | """ |
365 | |
366 | hostname = self.model.config['external_hostname'] |
367 | @@ -96,28 +130,134 @@ class GunicornK8sCharm(CharmBase): |
368 | { |
369 | "host": hostname, |
370 | "http": { |
371 | - "paths": [{"path": "/", "backend": {"serviceName": self.app.name, "servicePort": 80},}] |
372 | + "paths": [ |
373 | + { |
374 | + "path": "/", |
375 | + "backend": {"serviceName": self.app.name, "servicePort": 80}, |
376 | + } |
377 | + ] |
378 | }, |
379 | } |
380 | ] |
381 | }, |
382 | - "annotations": {'nginx.ingress.kubernetes.io/ssl-redirect': 'false',}, |
383 | + "annotations": { |
384 | + 'nginx.ingress.kubernetes.io/ssl-redirect': 'false', |
385 | + }, |
386 | } |
387 | |
388 | return [ingress] |
389 | |
390 | + def _render_template(self, tmpl: str, ctx: dict) -> str: |
391 | + """Render a Jinja2 template |
392 | + |
393 | + :returns: A rendered Jinja2 template |
394 | + """ |
395 | + j2env = Environment(loader=BaseLoader()) |
396 | + j2template = j2env.from_string(tmpl) |
397 | + |
398 | + return j2template.render(**ctx) |
399 | + |
400 | + def _get_context_from_relations(self) -> dict: |
401 | + """Build a template context from relation data - to be used for Jinja2 |
402 | + template rendering |
403 | + |
404 | + :returns: A dict with relation data that can be used as context for Jinja2 template rendering |
405 | + """ |
406 | + ctx = {} |
407 | + |
408 | + # Add variables from "special" relations |
409 | + for rel in self._stored.reldata: |
410 | + if self._stored.reldata[rel]: |
411 | + ctx[str(rel)] = self._stored.reldata[rel] |
412 | + |
413 | + # Add variables from raw relation data |
414 | + for rels in self.model.relations.values(): |
415 | + if len(rels) > 0: |
416 | + rel = rels[0] |
417 | + |
418 | + if len(rels) > 1: |
419 | + logger.warning( |
420 | + 'Multiple relations of type "%s" detected,' |
421 | + ' using only the first one (id: %s) for relation data.', |
422 | + rel.name, |
423 | + rel.id, |
424 | + ) |
425 | + |
426 | + if len(rel.units) > 0: |
427 | + # We want to always pick the same unit, so sort the set |
428 | + # before picking the first one. |
429 | + u = sorted(rel.units, key=lambda x: x.name)[0] |
430 | + |
431 | + if len(rel.units) > 1: |
432 | + logger.warning( |
433 | + 'Multiple units detected in the relation "%s:%s", ' |
434 | + 'using only the first one (id: %s) for relation data.', |
435 | + rel.name, |
436 | + rel.id, |
437 | + u.name, |
438 | + ) |
439 | + if rel.name not in ctx: # can be present from the "special" relations above |
440 | + ctx[rel.name] = {} |
441 | + for k, v in rel.data[u].items(): |
442 | + ctx[rel.name][k] = v |
443 | + |
444 | + return ctx |
445 | + |
446 | + def _validate_yaml(self, supposed_yaml: str, expected_type: type) -> None: |
447 | + """Validate that the supplied YAML is parsed into the supplied type. |
448 | + |
449 | + :raises GunicornK8sCharmYAMLError: if the YAML is incorrect, or if it's not parsed into the expected type |
450 | + """ |
451 | + err = False |
452 | + parsed = None |
453 | + |
454 | + try: |
455 | + parsed = yaml.safe_load(supposed_yaml) |
456 | + except yaml.scanner.ScannerError as e: |
457 | + logger.error("Error when parsing the following YAML : %s : %s", supposed_yaml, e) |
458 | + err = True |
459 | + else: |
460 | + if not isinstance(parsed, expected_type): |
461 | + err = True |
462 | + logger.error( |
463 | + "Expected type '%s' but got '%s' when parsing YAML : %s", |
464 | + expected_type, |
465 | + parsed.__class__, |
466 | + supposed_yaml, |
467 | + ) |
468 | + |
469 | + if err: |
470 | + raise GunicornK8sCharmYAMLError('YAML parsing failed, please check "juju debug-log -l ERROR"') |
471 | + |
472 | def _make_pod_env(self) -> dict: |
473 | """Return an envConfig with some core configuration. |
474 | |
475 | :returns: A dictionary used for envConfig in podspec |
476 | - :rtype: dict |
477 | """ |
478 | - env = yaml.safe_load(self.model.config['environment']) |
479 | + env = self.model.config['environment'] |
480 | + |
481 | + if not env: |
482 | + return {} |
483 | + |
484 | + ctx = self._get_context_from_relations() |
485 | + rendered_env = self._render_template(env, ctx) |
486 | + |
487 | + try: |
488 | + self._validate_yaml(rendered_env, dict) |
489 | + except GunicornK8sCharmYAMLError: |
490 | + raise GunicornK8sCharmJujuConfigError( |
491 | + "Could not parse Juju config 'environment' as a YAML dict - check \"juju debug-log -l ERROR\"" |
492 | + ) |
493 | |
494 | - return env or {} |
495 | + env = yaml.safe_load(rendered_env) |
496 | + |
497 | + return env |
498 | |
499 | def _make_pod_spec(self) -> dict: |
500 | - """Return a pod spec with some core configuration.""" |
501 | + """Return a pod spec with some core configuration. |
502 | + |
503 | + :returns: A pod spec |
504 | + """ |
505 | |
506 | config = self.model.config |
507 | image_details = { |
508 | @@ -139,17 +279,41 @@ class GunicornK8sCharm(CharmBase): |
509 | 'imagePullPolicy': 'Always', |
510 | 'ports': [{'containerPort': 80, 'protocol': 'TCP'}], |
511 | 'envConfig': pod_env, |
512 | - 'kubernetes': {'readinessProbe': {'httpGet': {'path': '/', 'port': 80}},}, |
513 | + 'kubernetes': { |
514 | + 'readinessProbe': {'httpGet': {'path': '/', 'port': 80}}, |
515 | + }, |
516 | } |
517 | ], |
518 | } |
519 | |
520 | - def configure_pod(self, event: ops.framework.EventBase) -> None: |
521 | + def _configure_pod(self, event: ops.framework.EventBase) -> None: |
522 | """Assemble the pod spec and apply it, if possible. |
523 | |
524 | - :param ops.framework.EventBase event: Event that triggered the method. |
525 | + :param event: Event that triggered the method. |
526 | """ |
527 | |
528 | + env = self.model.config['environment'] |
529 | + ctx = self._get_context_from_relations() |
530 | + |
531 | + if env: |
532 | + j2env = Environment(loader=BaseLoader) |
533 | + j2template = j2env.parse(env) |
534 | + missing_vars = set() |
535 | + |
536 | + for req_var in meta.find_undeclared_variables(j2template): |
537 | + if not ctx.get(req_var): |
538 | + missing_vars.add(req_var) |
539 | + |
540 | + if missing_vars: |
541 | + logger.info( |
542 | + "Missing YAML vars to interpolate the 'environment' config option, " |
543 | + "setting status to 'waiting' : %s", |
544 | + ", ".join(sorted(missing_vars)), |
545 | + ) |
546 | + self.unit.status = BlockedStatus('Waiting for {} relation(s)'.format(", ".join(sorted(missing_vars)))) |
547 | + event.defer() |
548 | + return |
549 | + |
550 | if not self.unit.is_leader(): |
551 | self.unit.status = ActiveStatus() |
552 | return |
553 | @@ -161,13 +325,19 @@ class GunicornK8sCharm(CharmBase): |
554 | return |
555 | |
556 | self.unit.status = MaintenanceStatus('Assembling pod spec') |
557 | - pod_spec = self._make_pod_spec() |
558 | + |
559 | + try: |
560 | + pod_spec = self._make_pod_spec() |
561 | + except GunicornK8sCharmJujuConfigError as e: |
562 | + self.unit.status = BlockedStatus(str(e)) |
563 | + return |
564 | |
565 | resources = pod_spec.get('kubernetesResources', {}) |
566 | resources['ingressResources'] = self._make_k8s_ingress() |
567 | |
568 | self.unit.status = MaintenanceStatus('Setting pod spec') |
569 | self.model.pod.set_spec(pod_spec, k8s_resources={'kubernetesResources': resources}) |
570 | + logger.info("Setting active status") |
571 | self.unit.status = ActiveStatus() |
572 | |
573 | |
574 | diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt |
575 | index 7466bfe..8612ca6 100644 |
576 | --- a/tests/unit/requirements.txt |
577 | +++ b/tests/unit/requirements.txt |
578 | @@ -2,3 +2,4 @@ PyYAML |
579 | pytest |
580 | pytest-cov |
581 | pytest-subtests |
582 | +jinja2 |
583 | diff --git a/tests/unit/scenario.py b/tests/unit/scenario.py |
584 | index 0886672..f71494f 100644 |
585 | --- a/tests/unit/scenario.py |
586 | +++ b/tests/unit/scenario.py |
587 | @@ -29,45 +29,31 @@ def get_juju_default_config() -> dict: |
588 | |
589 | JUJU_DEFAULT_CONFIG = get_juju_default_config() |
590 | |
591 | +TEST_PG_URI = 'postgresql://usr:pwd@1.2.3.4:5432/gunicorn' |
592 | +TEST_PG_CONNSTR = 'dbname=gunicorn host=1.2.3.4 password=pwd port=5432 user=usr' |
593 | + |
594 | TEST_JUJU_CONFIG = { |
595 | 'defaults': { |
596 | 'config': {}, |
597 | - 'logger': ["ERROR:charm:Required Juju config item not set : image_path", 'ERROR:charm:Required Juju config item not set : external_hostname'], |
598 | - 'expected': 'Required Juju config item not set : external_hostname, image_path', |
599 | + 'logger': [ |
600 | + "ERROR:charm:Required Juju config item not set : image_path", |
601 | + 'ERROR:charm:Required Juju config item not set : external_hostname', |
602 | + ], |
603 | + 'expected': 'Required Juju config item(s) not set : external_hostname, image_path', |
604 | }, |
605 | 'missing_image_path': { |
606 | - 'config': {'external_hostname': 'example.com',}, |
607 | - 'logger': ["ERROR:charm:Required Juju config item not set : image_path"], |
608 | - 'expected': 'Required Juju config item not set : image_path', |
609 | - }, |
610 | - 'missing_external_hostname': { |
611 | - 'config': {'image_path': 'my_gunicorn_app:devel',}, |
612 | - 'logger': ["ERROR:charm:Required Juju config item not set : external_hostname"], |
613 | - 'expected': 'Required Juju config item not set : external_hostname', |
614 | - }, |
615 | - 'env_not_yaml': { |
616 | 'config': { |
617 | - 'image_path': 'my_gunicorn_app:devel', |
618 | - 'environment': 'badyaml: :', |
619 | 'external_hostname': 'example.com', |
620 | }, |
621 | - 'logger': [ |
622 | - "ERROR:charm:Juju config item 'environment' is not YAML : mapping values are " |
623 | - 'not allowed here\n' |
624 | - ' in "<unicode string>", line 1, column 10:\n' |
625 | - ' badyaml: :\n' |
626 | - ' ^' |
627 | - ], |
628 | - 'expected': 'YAML parsing failed on the Juju config item(s) : environment - check "juju debug-log -l ERROR"', |
629 | + 'logger': ["ERROR:charm:Required Juju config item not set : image_path"], |
630 | + 'expected': 'Required Juju config item(s) not set : image_path', |
631 | }, |
632 | - 'env_yaml_not_dict': { |
633 | + 'missing_external_hostname': { |
634 | 'config': { |
635 | 'image_path': 'my_gunicorn_app:devel', |
636 | - 'environment': 'not_a_dict', |
637 | - 'external_hostname': 'example.com', |
638 | }, |
639 | - 'logger': ["ERROR:charm:Juju config item 'environment' is not a YAML dict"], |
640 | - 'expected': 'YAML parsing failed on the Juju config item(s) : environment - check "juju debug-log -l ERROR"', |
641 | + 'logger': ["ERROR:charm:Required Juju config item not set : external_hostname"], |
642 | + 'expected': 'Required Juju config item(s) not set : external_hostname', |
643 | }, |
644 | 'good_config_no_env': { |
645 | 'config': {'image_path': 'my_gunicorn_app:devel', 'external_hostname': 'example.com'}, |
646 | @@ -87,11 +73,18 @@ TEST_JUJU_CONFIG = { |
647 | |
648 | TEST_CONFIGURE_POD = { |
649 | 'bad_config': { |
650 | - 'config': {'external_hostname': 'example.com',}, |
651 | - 'expected': 'Required Juju config item not set : image_path', |
652 | + 'config': { |
653 | + 'external_hostname': 'example.com', |
654 | + }, |
655 | + '_leader_get': "5:\n database: gunicorn\n extensions: ''\n roles: ''", |
656 | + 'expected': 'Required Juju config item(s) not set : image_path', |
657 | }, |
658 | 'good_config_no_env': { |
659 | - 'config': {'image_path': 'my_gunicorn_app:devel', 'external_hostname': 'example.com',}, |
660 | + 'config': { |
661 | + 'image_path': 'my_gunicorn_app:devel', |
662 | + 'external_hostname': 'example.com', |
663 | + }, |
664 | + '_leader_get': "5:\n database: gunicorn\n extensions: ''\n roles: ''", |
665 | 'expected': False, |
666 | }, |
667 | 'good_config_with_env': { |
668 | @@ -100,19 +93,25 @@ TEST_CONFIGURE_POD = { |
669 | 'external_hostname': 'example.com', |
670 | 'environment': 'MYENV: foo', |
671 | }, |
672 | + '_leader_get': "5:\n database: gunicorn\n extensions: ''\n roles: ''", |
673 | 'expected': False, |
674 | }, |
675 | } |
676 | |
677 | TEST_MAKE_POD_SPEC = { |
678 | 'basic_no_env': { |
679 | - 'config': {'image_path': 'my_gunicorn_app:devel', 'external_hostname': 'example.com',}, |
680 | + 'config': { |
681 | + 'image_path': 'my_gunicorn_app:devel', |
682 | + 'external_hostname': 'example.com', |
683 | + }, |
684 | 'pod_spec': { |
685 | 'version': 3, # otherwise resources are ignored |
686 | 'containers': [ |
687 | { |
688 | 'name': 'gunicorn', |
689 | - 'imageDetails': {'imagePath': 'my_gunicorn_app:devel',}, |
690 | + 'imageDetails': { |
691 | + 'imagePath': 'my_gunicorn_app:devel', |
692 | + }, |
693 | 'imagePullPolicy': 'Always', |
694 | 'ports': [{'containerPort': 80, 'protocol': 'TCP'}], |
695 | 'envConfig': {}, |
696 | @@ -132,7 +131,9 @@ TEST_MAKE_POD_SPEC = { |
697 | 'containers': [ |
698 | { |
699 | 'name': 'gunicorn', |
700 | - 'imageDetails': {'imagePath': 'my_gunicorn_app:devel',}, |
701 | + 'imageDetails': { |
702 | + 'imagePath': 'my_gunicorn_app:devel', |
703 | + }, |
704 | 'imagePullPolicy': 'Always', |
705 | 'ports': [{'containerPort': 80, 'protocol': 'TCP'}], |
706 | 'envConfig': {'MYENV': 'foo'}, |
707 | @@ -153,7 +154,11 @@ TEST_MAKE_POD_SPEC = { |
708 | 'containers': [ |
709 | { |
710 | 'name': 'gunicorn', |
711 | - 'imageDetails': {'imagePath': 'my_gunicorn_app:devel', 'username': 'foo', 'password': 'bar',}, |
712 | + 'imageDetails': { |
713 | + 'imagePath': 'my_gunicorn_app:devel', |
714 | + 'username': 'foo', |
715 | + 'password': 'bar', |
716 | + }, |
717 | 'imagePullPolicy': 'Always', |
718 | 'ports': [{'containerPort': 80, 'protocol': 'TCP'}], |
719 | 'envConfig': {}, |
720 | @@ -167,7 +172,10 @@ TEST_MAKE_POD_SPEC = { |
721 | |
722 | TEST_MAKE_K8S_INGRESS = { |
723 | 'basic': { |
724 | - 'config': {'image_path': 'my_gunicorn_app:devel', 'external_hostname': 'example.com',}, |
725 | + 'config': { |
726 | + 'image_path': 'my_gunicorn_app:devel', |
727 | + 'external_hostname': 'example.com', |
728 | + }, |
729 | 'expected': [ |
730 | { |
731 | 'name': 'gunicorn-ingress', |
732 | @@ -176,13 +184,28 @@ TEST_MAKE_K8S_INGRESS = { |
733 | { |
734 | 'host': 'example.com', |
735 | 'http': { |
736 | - 'paths': [{'path': '/', 'backend': {'serviceName': 'gunicorn', 'servicePort': 80},},], |
737 | + 'paths': [ |
738 | + { |
739 | + 'path': '/', |
740 | + 'backend': {'serviceName': 'gunicorn', 'servicePort': 80}, |
741 | + }, |
742 | + ], |
743 | }, |
744 | }, |
745 | ], |
746 | }, |
747 | - 'annotations': {'nginx.ingress.kubernetes.io/ssl-redirect': 'false',}, |
748 | + 'annotations': { |
749 | + 'nginx.ingress.kubernetes.io/ssl-redirect': 'false', |
750 | + }, |
751 | }, |
752 | ], |
753 | }, |
754 | } |
755 | + |
756 | +TEST_RENDER_TEMPLATE = { |
757 | + 'working': { |
758 | + 'tmpl': "test {{db.x}}", |
759 | + 'ctx': {'db': {'x': 'foo'}}, |
760 | + 'expected': "test foo", |
761 | + } |
762 | +} |
763 | diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py |
764 | index 827fd57..56ea550 100755 |
765 | --- a/tests/unit/test_charm.py |
766 | +++ b/tests/unit/test_charm.py |
767 | @@ -4,12 +4,9 @@ |
768 | |
769 | import unittest |
770 | |
771 | -from unittest.mock import MagicMock |
772 | +from unittest.mock import MagicMock, patch |
773 | |
774 | -from charm import ( |
775 | - GunicornK8sCharm, |
776 | - GunicornK8sCharmJujuConfigError, |
777 | -) |
778 | +from charm import GunicornK8sCharm, GunicornK8sCharmJujuConfigError, GunicornK8sCharmYAMLError |
779 | |
780 | from ops import testing |
781 | from ops.model import ( |
782 | @@ -23,6 +20,9 @@ from scenario import ( |
783 | TEST_CONFIGURE_POD, |
784 | TEST_MAKE_POD_SPEC, |
785 | TEST_MAKE_K8S_INGRESS, |
786 | + TEST_RENDER_TEMPLATE, |
787 | + TEST_PG_URI, |
788 | + TEST_PG_CONNSTR, |
789 | ) |
790 | |
791 | |
792 | @@ -39,6 +39,110 @@ class TestGunicornK8sCharm(unittest.TestCase): |
793 | """Cleanup the harness.""" |
794 | self.harness.cleanup() |
795 | |
796 | + def test_init_postgresql_relation(self): |
797 | + """Test the _init_postgresql_relation function.""" |
798 | + |
799 | + # We'll only test the case where _stored already |
800 | + # has content. _stored being empty is basically tested |
801 | + # by all the other functions |
802 | + |
803 | + mock_stored = MagicMock() |
804 | + mock_stored.reldata = {'pg': 'foo'} |
805 | + mock_framework = MagicMock() |
806 | + mock_pgsql = MagicMock() |
807 | + |
808 | + with patch('test_charm.GunicornK8sCharm._stored', mock_stored): |
809 | + with patch('pgsql.PostgreSQLClient', mock_pgsql): |
810 | + c = GunicornK8sCharm(mock_framework) |
811 | + self.assertEqual(c._stored, mock_stored) |
812 | + |
813 | + def test_on_database_relation_joined(self): |
814 | + """Test the _on_database_relation_joined function.""" |
815 | + |
816 | + # Unit is leader |
817 | + mock_event = MagicMock() |
818 | + self.harness.disable_hooks() # we don't want leader-set to fire |
819 | + self.harness.set_leader(True) |
820 | + |
821 | + self.harness.charm._on_database_relation_joined(mock_event) |
822 | + |
823 | + self.assertEqual(mock_event.database, self.harness.charm.app.name) |
824 | + |
825 | + # Unit is not leader, DB not ready |
826 | + mock_event = MagicMock() |
827 | + self.harness.disable_hooks() # we don't want leader-set to fire |
828 | + self.harness.set_leader(False) |
829 | + |
830 | + self.harness.charm._on_database_relation_joined(mock_event) |
831 | + |
832 | + mock_event.defer.assert_called_once() |
833 | + |
834 | + # Unit is leader, DB ready |
835 | + mock_event = MagicMock() |
836 | + self.harness.disable_hooks() # we don't want leader-set to fire |
837 | + self.harness.set_leader(False) |
838 | + mock_event.database = self.harness.charm.app.name |
839 | + |
840 | + r = self.harness.charm._on_database_relation_joined(mock_event) |
841 | + self.assertEqual(r, None) |
842 | + |
843 | + def test_on_master_changed(self): |
844 | + """Test the _on_master_changed function.""" |
845 | + |
846 | + # No database |
847 | + mock_event = MagicMock() |
848 | + mock_event.database = None |
849 | + |
850 | + r = self.harness.charm._on_master_changed(mock_event) |
851 | + self.assertEqual(r, None) |
852 | + |
853 | + # Database but no master |
854 | + mock_event = MagicMock() |
855 | + mock_event.database = self.harness.charm.app.name |
856 | + mock_event.master = None |
857 | + |
858 | + r = self.harness.charm._on_master_changed(mock_event) |
859 | + reldata = self.harness.charm._stored.reldata |
860 | + self.assertEqual(reldata['pg']['conn_str'], None) |
861 | + self.assertEqual(reldata['pg']['db_uri'], None) |
862 | + self.assertEqual(r, None) |
863 | + |
864 | + # Database with master |
865 | + mock_event = MagicMock() |
866 | + mock_event.database = self.harness.charm.app.name |
867 | + mock_event.master.conn_str = TEST_PG_CONNSTR |
868 | + mock_event.master.uri = TEST_PG_URI |
869 | + with patch('charm.GunicornK8sCharm._configure_pod') as configure_pod: |
870 | + r = self.harness.charm._on_master_changed(mock_event) |
871 | + |
872 | + reldata = self.harness.charm._stored.reldata |
873 | + self.assertEqual(reldata['pg']['conn_str'], mock_event.master.conn_str) |
874 | + self.assertEqual(reldata['pg']['db_uri'], mock_event.master.uri) |
875 | + self.assertEqual(r, None) |
876 | + configure_pod.assert_called_with(mock_event) |
877 | + |
878 | + def test_on_standby_changed(self): |
879 | + """Test the _on_standby_changed function.""" |
880 | + |
881 | + # Database not ready |
882 | + mock_event = MagicMock() |
883 | + mock_event.database = None |
884 | + |
885 | + r = self.harness.charm._on_standby_changed(mock_event) |
886 | + self.assertEqual(r, None) |
887 | + |
888 | + # Database ready |
889 | + mock_event = MagicMock() |
890 | + mock_event.database = self.harness.charm.app.name |
891 | + |
892 | + mock_event.standbys = [MagicMock()] |
893 | + mock_event.standbys[0].uri = TEST_PG_URI |
894 | + |
895 | + r = self.harness.charm._on_standby_changed(mock_event) |
896 | + |
897 | + reldata = self.harness.charm._stored.reldata |
898 | + self.assertEqual(reldata['pg']['ro_uris'], [TEST_PG_URI]) |
899 | + |
900 | def test_check_juju_config(self): |
901 | """Check the required juju settings.""" |
902 | self.harness.update_config(JUJU_DEFAULT_CONFIG) |
903 | @@ -60,22 +164,187 @@ class TestGunicornK8sCharm(unittest.TestCase): |
904 | # The second argument is the list of key to reset |
905 | self.harness.update_config(JUJU_DEFAULT_CONFIG) |
906 | |
907 | - def test_configure_pod(self): |
908 | + def test_make_k8s_ingress(self): |
909 | + """Check the crafting of the ingress part of the pod spec.""" |
910 | + self.harness.update_config(JUJU_DEFAULT_CONFIG) |
911 | + |
912 | + for scenario, values in TEST_MAKE_K8S_INGRESS.items(): |
913 | + with self.subTest(scenario=scenario): |
914 | + self.harness.update_config(values['config']) |
915 | + self.assertEqual(self.harness.charm._make_k8s_ingress(), values['expected']) |
916 | + self.harness.update_config(JUJU_DEFAULT_CONFIG) # You need to clean the config after each run |
917 | + |
918 | + def test_render_template(self): |
919 | + """Test template rendering.""" |
920 | + |
921 | + for scenario, values in TEST_RENDER_TEMPLATE.items(): |
922 | + with self.subTest(scenario=scenario): |
923 | + r = self.harness.charm._render_template(values['tmpl'], values['ctx']) |
924 | + self.assertEqual(r, values['expected']) |
925 | + |
926 | + def test_get_context_from_relations(self): |
927 | + """Test the _get_context_from_relations function.""" |
928 | + |
929 | + self.harness.disable_hooks() # no need for hooks to fire for this test |
930 | + |
931 | + # Set up PG "special case" relation data |
932 | + reldata = self.harness.charm._stored.reldata |
933 | + reldata['pg'] = {'conn_str': TEST_PG_CONNSTR, 'db_uri': TEST_PG_URI} |
934 | + |
935 | + # Set up PG "raw" relation data |
936 | + relation_id = self.harness.add_relation('pg', 'postgresql') |
937 | + self.harness.add_relation_unit(relation_id, 'postgresql/0') |
938 | + self.harness.update_relation_data(relation_id, 'postgresql/0', {'version': '10'}) |
939 | + |
940 | + # Set up random relation, with 2 units |
941 | + relation_id = self.harness.add_relation('myrel', 'myapp') |
942 | + self.harness.add_relation_unit(relation_id, 'myapp/0') |
943 | + self.harness.add_relation_unit(relation_id, 'myapp/1') |
944 | + self.harness.update_relation_data(relation_id, 'myapp/0', {'thing': 'bli'}) |
945 | + self.harness.update_relation_data(relation_id, 'myapp/1', {'thing': 'blo'}) |
946 | + |
947 | + # Set up same relation but with a different app |
948 | + relation_id = self.harness.add_relation('myrel', 'myapp2') |
949 | + self.harness.add_relation_unit(relation_id, 'myapp2/0') |
950 | + self.harness.update_relation_data(relation_id, 'myapp2/0', {'thing': 'blu'}) |
951 | + |
952 | + # Set up random relation, no unit (can happen during relation init) |
953 | + relation_id = self.harness.add_relation('myrel2', 'myapp2') |
954 | + |
955 | + expected_ret = { |
956 | + 'pg': {'conn_str': TEST_PG_CONNSTR, 'db_uri': TEST_PG_URI, 'version': '10'}, |
957 | + 'myrel': {'thing': 'bli'}, |
958 | + } |
959 | + expected_logger = [ |
960 | + 'WARNING:charm:Multiple relations of type "myrel" detected, ' |
961 | + 'using only the first one (id: 1) for relation data.', |
962 | + 'WARNING:charm:Multiple units detected in the relation "myrel:1", ' |
963 | + 'using only the first one (id: myapp/0) for relation data.', |
964 | + ] |
965 | + |
966 | + with self.assertLogs(level="WARNING") as logger: |
967 | + r = self.harness.charm._get_context_from_relations() |
968 | + |
969 | + self.assertEqual(sorted(logger.output), sorted(expected_logger)) |
970 | + self.assertEqual(r, expected_ret) |
971 | + |
972 | + def test_validate_yaml(self): |
973 | + """Test the _validate_yaml function.""" |
974 | + |
975 | + # Proper YAML and type |
976 | + test_str = "a: b\n1: 2" |
977 | + expected_type = dict |
978 | + |
979 | + r = self.harness.charm._validate_yaml(test_str, expected_type) |
980 | + |
981 | + self.assertEqual(r, None) |
982 | + |
983 | + # Incorrect YAML |
984 | + test_str = "a: :" |
985 | + expected_type = dict |
986 | + expected_output = [ |
987 | + 'ERROR:charm:Error when parsing the following YAML : a: : : mapping values ' |
988 | + 'are not allowed here\n' |
989 | + ' in "<unicode string>", line 1, column 4:\n' |
990 | + ' a: :\n' |
991 | + ' ^' |
992 | + ] |
993 | + expected_exception = 'YAML parsing failed, please check "juju debug-log -l ERROR"' |
994 | + |
995 | + with self.assertLogs(level='ERROR') as logger: |
996 | + with self.assertRaises(GunicornK8sCharmYAMLError) as exc: |
997 | + self.harness.charm._validate_yaml(test_str, expected_type) |
998 | + |
999 | + self.assertEqual(sorted(logger.output), expected_output) |
1000 | + self.assertEqual(str(exc.exception), expected_exception) |
1001 | + |
1002 | + # Proper YAML, incorrect type |
1003 | + test_str = "a: b" |
1004 | + expected_type = str |
1005 | + expected_output = [ |
1006 | + "ERROR:charm:Expected type '<class 'str'>' but got '<class 'dict'>' when " 'parsing YAML : a: b' |
1007 | + ] |
1008 | + |
1009 | + expected_exception = 'YAML parsing failed, please check "juju debug-log -l ERROR"' |
1010 | + |
1011 | + with self.assertLogs(level='ERROR') as logger: |
1012 | + with self.assertRaises(GunicornK8sCharmYAMLError) as exc: |
1013 | + self.harness.charm._validate_yaml(test_str, expected_type) |
1014 | + |
1015 | + self.assertEqual(sorted(logger.output), expected_output) |
1016 | + self.assertEqual(str(exc.exception), expected_exception) |
1017 | + |
1018 | + def test_make_pod_env(self): |
1019 | + """Test the _make_pod_env function.""" |
1020 | + |
1021 | + # No env |
1022 | + self.harness.update_config(JUJU_DEFAULT_CONFIG) |
1023 | + self.harness.update_config({'environment': ''}) |
1024 | + expected_ret = {} |
1025 | + |
1026 | + r = self.harness.charm._make_pod_env() |
1027 | + self.assertEqual(r, expected_ret) |
1028 | + |
1029 | + # Proper env, no templating/relation |
1030 | + self.harness.update_config(JUJU_DEFAULT_CONFIG) |
1031 | + self.harness.update_config({'environment': 'a: b'}) |
1032 | + expected_ret = {'a': 'b'} |
1033 | + |
1034 | + r = self.harness.charm._make_pod_env() |
1035 | + self.assertEqual(r, expected_ret) |
1036 | + |
1037 | + # Proper env with templating/relations |
1038 | + self.harness.update_config(JUJU_DEFAULT_CONFIG) |
1039 | + self.harness.update_config({'environment': "DB: {{pg.db_uri}}\nTHING: {{myrel.thing}}}"}) |
1040 | + expected_ret = {'a': 'b'} |
1041 | + |
1042 | + # Set up PG relation |
1043 | + reldata = self.harness.charm._stored.reldata |
1044 | + reldata['pg'] = {'conn_str': TEST_PG_CONNSTR, 'db_uri': TEST_PG_URI} |
1045 | + |
1046 | + # Set up random relation |
1047 | + self.harness.disable_hooks() # no need for hooks to fire for this test |
1048 | + relation_id = self.harness.add_relation('myrel', 'myapp') |
1049 | + self.harness.add_relation_unit(relation_id, 'myapp/0') |
1050 | + self.harness.update_relation_data(relation_id, 'myapp/0', {'thing': 'bli'}) |
1051 | + |
1052 | + expected_ret = {'DB': TEST_PG_URI, 'THING': 'bli}'} |
1053 | + |
1054 | + r = self.harness.charm._make_pod_env() |
1055 | + self.assertEqual(r, expected_ret) |
1056 | + |
1057 | + # Improper env |
1058 | + self.harness.update_config(JUJU_DEFAULT_CONFIG) |
1059 | + self.harness.update_config({'environment': 'a: :'}) |
1060 | + expected_ret = None |
1061 | + expected_exception = ( |
1062 | + 'Could not parse Juju config \'environment\' as a YAML dict - check "juju debug-log -l ERROR"' |
1063 | + ) |
1064 | + |
1065 | + with self.assertRaises(GunicornK8sCharmJujuConfigError) as exc: |
1066 | + self.harness.charm._make_pod_env() |
1067 | + |
1068 | + self.assertEqual(str(exc.exception), expected_exception) |
1069 | + |
1070 | + @patch('pgsql.pgsql._leader_get') |
1071 | + def test_configure_pod(self, mock_leader_get): |
1072 | """Test the pod configuration.""" |
1073 | + |
1074 | mock_event = MagicMock() |
1075 | self.harness.update_config(JUJU_DEFAULT_CONFIG) |
1076 | |
1077 | self.harness.set_leader(False) |
1078 | self.harness.charm.unit.status = BlockedStatus("Testing") |
1079 | - self.harness.charm.configure_pod(mock_event) |
1080 | + self.harness.charm._configure_pod(mock_event) |
1081 | self.assertEqual(self.harness.charm.unit.status, ActiveStatus()) |
1082 | self.harness.update_config(JUJU_DEFAULT_CONFIG) # You need to clean the config after each run |
1083 | |
1084 | for scenario, values in TEST_CONFIGURE_POD.items(): |
1085 | with self.subTest(scenario=scenario): |
1086 | + mock_leader_get.return_value = values['_leader_get'] |
1087 | self.harness.update_config(values['config']) |
1088 | self.harness.set_leader(True) |
1089 | - self.harness.charm.configure_pod(mock_event) |
1090 | + self.harness.charm._configure_pod(mock_event) |
1091 | if values['expected']: |
1092 | self.assertEqual(self.harness.charm.unit.status, BlockedStatus(values['expected'])) |
1093 | else: |
1094 | @@ -83,6 +352,63 @@ class TestGunicornK8sCharm(unittest.TestCase): |
1095 | |
1096 | self.harness.update_config(JUJU_DEFAULT_CONFIG) # You need to clean the config after each run |
1097 | |
1098 | + # Test missing vars |
1099 | + self.harness.update_config(JUJU_DEFAULT_CONFIG) |
1100 | + self.harness.update_config( |
1101 | + { |
1102 | + 'image_path': 'my_gunicorn_app:devel', |
1103 | + 'external_hostname': 'example.com', |
1104 | + 'environment': 'DB_URI: {{pg.uri}}', |
1105 | + } |
1106 | + ) |
1107 | + self.harness.set_leader(True) |
1108 | + expected_status = 'Waiting for pg relation(s)' |
1109 | + |
1110 | + self.harness.charm._configure_pod(mock_event) |
1111 | + |
1112 | + mock_event.defer.assert_called_once() |
1113 | + self.assertEqual(self.harness.charm.unit.status, BlockedStatus(expected_status)) |
1114 | + |
1115 | + # Test no missing vars |
1116 | + self.harness.update_config(JUJU_DEFAULT_CONFIG) |
1117 | + self.harness.update_config( |
1118 | + { |
1119 | + 'image_path': 'my_gunicorn_app:devel', |
1120 | + 'external_hostname': 'example.com', |
1121 | + 'environment': 'DB_URI: {{pg.uri}}', |
1122 | + } |
1123 | + ) |
1124 | + |
1125 | + reldata = self.harness.charm._stored.reldata |
1126 | + reldata['pg'] = {'conn_str': TEST_PG_CONNSTR, 'db_uri': TEST_PG_URI} |
1127 | + self.harness.set_leader(True) |
1128 | + # Set up random relation |
1129 | + self.harness.disable_hooks() # no need for hooks to fire for this test |
1130 | + relation_id = self.harness.add_relation('myrel', 'myapp') |
1131 | + self.harness.add_relation_unit(relation_id, 'myapp/0') |
1132 | + self.harness.update_relation_data(relation_id, 'myapp/0', {'thing': 'bli'}) |
1133 | + expected_status = 'Waiting for pg relation(s)' |
1134 | + |
1135 | + self.harness.charm._configure_pod(mock_event) |
1136 | + |
1137 | + self.assertEqual(self.harness.charm.unit.status, ActiveStatus()) |
1138 | + |
1139 | + # Test incorrect YAML |
1140 | + self.harness.update_config(JUJU_DEFAULT_CONFIG) |
1141 | + self.harness.update_config( |
1142 | + { |
1143 | + 'image_path': 'my_gunicorn_app:devel', |
1144 | + 'external_hostname': 'example.com', |
1145 | + 'environment': 'a: :', |
1146 | + } |
1147 | + ) |
1148 | + self.harness.set_leader(True) |
1149 | + expected_status = 'Could not parse Juju config \'environment\' as a YAML dict - check "juju debug-log -l ERROR"' |
1150 | + |
1151 | + self.harness.charm._configure_pod(mock_event) |
1152 | + |
1153 | + self.assertEqual(self.harness.charm.unit.status, BlockedStatus(expected_status)) |
1154 | + |
1155 | def test_make_pod_spec(self): |
1156 | """Check the crafting of the pod spec.""" |
1157 | self.harness.update_config(JUJU_DEFAULT_CONFIG) |
1158 | @@ -93,16 +419,6 @@ class TestGunicornK8sCharm(unittest.TestCase): |
1159 | self.assertEqual(self.harness.charm._make_pod_spec(), values['pod_spec']) |
1160 | self.harness.update_config(JUJU_DEFAULT_CONFIG) # You need to clean the config after each run |
1161 | |
1162 | - def test_make_k8s_ingress(self): |
1163 | - """Check the crafting of the ingress part of the pod spec.""" |
1164 | - self.harness.update_config(JUJU_DEFAULT_CONFIG) |
1165 | - |
1166 | - for scenario, values in TEST_MAKE_K8S_INGRESS.items(): |
1167 | - with self.subTest(scenario=scenario): |
1168 | - self.harness.update_config(values['config']) |
1169 | - self.assertEqual(self.harness.charm._make_k8s_ingress(), values['expected']) |
1170 | - self.harness.update_config(JUJU_DEFAULT_CONFIG) # You need to clean the config after each run |
1171 | - |
1172 | |
1173 | if __name__ == '__main__': |
1174 | unittest.main() |
1175 | diff --git a/tox.ini b/tox.ini |
1176 | index 665fad6..8dedaea 100644 |
1177 | --- a/tox.ini |
1178 | +++ b/tox.ini |
1179 | @@ -1,12 +1,10 @@ |
1180 | [tox] |
1181 | skipsdist=True |
1182 | -envlist = unit, functional |
1183 | +envlist = unit |
1184 | skip_missing_interpreters = True |
1185 | |
1186 | [testenv] |
1187 | basepython = python3 |
1188 | -setenv = |
1189 | - PYTHONPATH = {toxinidir}/src:{toxinidir}/build/lib:{toxinidir}/build/venv |
1190 | |
1191 | [testenv:unit] |
1192 | commands = |
1193 | @@ -18,22 +16,12 @@ setenv = |
1194 | PYTHONPATH={toxinidir}/src:{toxinidir}/build/lib:{toxinidir}/build/venv |
1195 | TZ=UTC |
1196 | |
1197 | -[testenv:functional] |
1198 | -passenv = |
1199 | - HOME |
1200 | - JUJU_REPOSITORY |
1201 | - PATH |
1202 | -commands = |
1203 | - pytest -v --ignore mod --ignore {toxinidir}/tests/unit {posargs} |
1204 | -deps = -r{toxinidir}/tests/functional/requirements.txt |
1205 | - -r{toxinidir}/requirements.txt |
1206 | - |
1207 | [testenv:black] |
1208 | -commands = black --skip-string-normalization --line-length=120 src/ tests/ |
1209 | +commands = black src/ tests/ docker/app/app/ |
1210 | deps = black |
1211 | |
1212 | [testenv:lint] |
1213 | -commands = flake8 src/ tests/ |
1214 | +commands = flake8 src/ tests/ docker/app/app/ |
1215 | # Pin flake8 to 3.7.9 to match focal |
1216 | deps = |
1217 | flake8==3.7.9 |
This merge proposal is being monitored by mergebot. Change the status to Approved to merge.