Merge ~axino/charm-k8s-gunicorn/+git/charm-k8s-gunicorn:relations into charm-k8s-gunicorn:master

Proposed by Junien F
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)
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

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

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

Revision history for this message
🤖 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.

Revision history for this message
Tom Haddon (mthaddon) wrote :

Two small comments inline. Once those are addressed I think this is ready for review from charmcrafters.

Revision history for this message
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.

Revision history for this message
Facundo Batista (facundo) wrote :

Looks awesome!!

I annotated some inline comments, thanks!!

review: Needs Fixing
Revision history for this message
Junien F (axino) wrote :

Thanks ! Fixes incoming

Revision history for this message
Junien F (axino) wrote :

Forgot to resubmit

review: Needs Resubmitting
Revision history for this message
Facundo Batista (facundo) wrote :

Approved, but please check the inline comments for two minor details. Thanks!!

review: Approve
Revision history for this message
Tom Haddon (mthaddon) wrote :

Adding an approving comment so this can be merged.

review: Approve
Revision history for this message
Junien F (axino) wrote :

Let's go mergebot !

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

Change successfully merged at revision 297c99c68838433f5b8d1c497835b7c19bea3d1d

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.jujuignore b/.jujuignore
2index 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__*
14diff --git a/Makefile b/Makefile
15index 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
56diff --git a/README.md b/README.md
57index 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.
132diff --git a/config.yaml b/config.yaml
133index 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: ''
179diff --git a/docker/app/app/app.py b/docker/app/app/app.py
180index 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
199diff --git a/metadata.yaml b/metadata.yaml
200index 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
214diff --git a/pyproject.toml b/pyproject.toml
215new file mode 100644
216index 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
223diff --git a/requirements.txt b/requirements.txt
224index 2d81d3b..fd6adcd 100644
225--- a/requirements.txt
226+++ b/requirements.txt
227@@ -1 +1,2 @@
228 ops
229+ops-lib-pgsql
230diff --git a/src/charm.py b/src/charm.py
231index 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
574diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt
575index 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
583diff --git a/tests/unit/scenario.py b/tests/unit/scenario.py
584index 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+}
763diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py
764index 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()
1175diff --git a/tox.ini b/tox.ini
1176index 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

Subscribers

People subscribed via source and target branches

to all changes: