Merge ~gtrkiller/charm-k8s-gunicorn:master into charm-k8s-gunicorn:master

Proposed by Franco Luciano Forneron Buschiazzo
Status: Rejected
Rejected by: Tom Haddon
Proposed branch: ~gtrkiller/charm-k8s-gunicorn:master
Merge into: charm-k8s-gunicorn:master
Diff against target: 1098 lines (+401/-384)
10 files modified
.gitignore (+1/-0)
Makefile (+1/-1)
charmcraft.yaml (+8/-0)
lib/charms/nginx_ingress_integrator/v0/ingress.py (+174/-0)
metadata.yaml (+10/-6)
requirements.txt (+0/-1)
src/charm.py (+109/-156)
tests/unit/scenario.py (+0/-80)
tests/unit/test_charm.py (+97/-139)
tox.ini (+1/-1)
Reviewer Review Type Date Requested Status
Tom Haddon Approve
Arturo Enrique Seijas Fernández (community) Needs Fixing
Mariyan Dimitrov Pending
David Andersson Pending
Weii Wang Pending
Canonical IS Reviewers Pending
Review via email: mp+428496@code.launchpad.net

Commit message

Adding health checks to axino's sidecar migration branch

Description of the change

This charm was already migrated to sidecar, and apparently works just fine as it was. I tested hitting the charm unit's IP on port 80 on both gunicorn's main branch (podspec) and the branch with the sidecar migration, they throw the same result on the browser. Added a health check to see if the gunicorn server is ready by hitting the address where it is binded (0.0.0.0:80)

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
Tom Haddon (mthaddon) wrote :

Please check the diff before marking it as "Needs Review". There are some vscode artifacts here that shouldn't be included as well as a "sidecar.diff" file.

I haven't taken a deeper look at other bits yet, will wait til those are cleaned up.

review: Needs Fixing
Revision history for this message
Arturo Enrique Seijas Fernández (arturo-seijas) :
review: Needs Fixing
Revision history for this message
Tom Haddon (mthaddon) wrote :

Some comments inline.

Also, I get a `ModuleNotFoundError: No module named 'charms'` error from `make test`. I think you'll want to update tox.ini in the testenv:unit target from:

PYTHONPATH={toxinidir}/src:{toxinidir}/build/lib:{toxinidir}/build/venv

To:

PYTHONPATH={toxinidir}/src:{toxinidir}/lib

Revision history for this message
Franco Luciano Forneron Buschiazzo (gtrkiller) :
Revision history for this message
Arturo Enrique Seijas Fernández (arturo-seijas) :
Revision history for this message
Arturo Enrique Seijas Fernández (arturo-seijas) :
Revision history for this message
Tom Haddon (mthaddon) :
Revision history for this message
Franco Luciano Forneron Buschiazzo (gtrkiller) :
Revision history for this message
Arturo Enrique Seijas Fernández (arturo-seijas) :
Revision history for this message
Franco Luciano Forneron Buschiazzo (gtrkiller) :
~gtrkiller/charm-k8s-gunicorn:master updated
62a2e32... by Franco Luciano Forneron Buschiazzo

changing charmctaft.yaml

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

Some comments inline

~gtrkiller/charm-k8s-gunicorn:master updated
4f70318... by Franco Luciano Forneron Buschiazzo

changing check to 127.0.0.1

d608cbf... by Franco Luciano Forneron Buschiazzo

changing check to 127.0.0.1 with tests

Revision history for this message
Arturo Enrique Seijas Fernández (arturo-seijas) :
review: Needs Fixing
~gtrkiller/charm-k8s-gunicorn:master updated
e91ac73... by Franco Luciano Forneron Buschiazzo

taking out exceptions and splitting tests

Revision history for this message
Arturo Enrique Seijas Fernández (arturo-seijas) wrote :

LGTM. Please, add a follow up story for the comment below

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

I'm going to add an approving comment as I think this is a minor change, but see my inline comment about CONTAINER_NAME.

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

We've now moved this to Github, so I'll close out this MP.

Unmerged commits

e91ac73... by Franco Luciano Forneron Buschiazzo

taking out exceptions and splitting tests

d608cbf... by Franco Luciano Forneron Buschiazzo

changing check to 127.0.0.1 with tests

4f70318... by Franco Luciano Forneron Buschiazzo

changing check to 127.0.0.1

62a2e32... by Franco Luciano Forneron Buschiazzo

changing charmctaft.yaml

c861c1a... by Franco Luciano Forneron Buschiazzo

deleting unecessary exceptions & changing check address

f61b2c3... by Franco Luciano Forneron Buschiazzo

deleting unecessary exceptions

482ccbf... by Franco Luciano Forneron Buschiazzo

putting comments into assert method as third argument

445e552... by Franco Luciano Forneron Buschiazzo

addressing comments

40bf392... by Franco Luciano Forneron Buschiazzo

cleaning up trash files

e27b30f... by Franco Luciano Forneron Buschiazzo

adding health checks to axino's sidecar branch

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/.gitignore b/.gitignore
index 58a7dc6..3d7f690 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
3*.charm3*.charm
4.tox4.tox
5.coverage5.coverage
6.vscode
6__pycache__7__pycache__
7build8build
8venv9venv
diff --git a/Makefile b/Makefile
index 6d1b61d..56217db 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,5 @@
1gunicorn-k8s.charm: src/*.py requirements.txt metadata.yaml config.yaml test1gunicorn-k8s.charm: src/*.py requirements.txt metadata.yaml config.yaml test
2 charmcraft build2 charmcraft pack
33
4blacken:4blacken:
5 @echo "Normalising python layout with black."5 @echo "Normalising python layout with black."
diff --git a/charmcraft.yaml b/charmcraft.yaml
6new file mode 1006446new file mode 100644
index 0000000..add3b33
--- /dev/null
+++ b/charmcraft.yaml
@@ -0,0 +1,8 @@
1type: charm
2bases:
3 - build-on:
4 - name: "ubuntu"
5 channel: "20.04"
6 run-on:
7 - name: "ubuntu"
8 channel: "20.04"
diff --git a/lib/charms/nginx_ingress_integrator/v0/ingress.py b/lib/charms/nginx_ingress_integrator/v0/ingress.py
0new file mode 1006449new file mode 100644
index 0000000..65d08a7
--- /dev/null
+++ b/lib/charms/nginx_ingress_integrator/v0/ingress.py
@@ -0,0 +1,174 @@
1"""Library for the ingress relation.
2
3This library contains the Requires and Provides classes for handling
4the ingress interface.
5
6Import `IngressRequires` in your charm, with two required options:
7 - "self" (the charm itself)
8 - config_dict
9
10`config_dict` accepts the following keys:
11 - service-hostname (required)
12 - service-name (required)
13 - service-port (required)
14 - limit-rps
15 - limit-whitelist
16 - max_body-size
17 - retry-errors
18 - service-namespace
19 - session-cookie-max-age
20 - tls-secret-name
21
22See `config.yaml` for descriptions of each, along with the required type.
23
24As an example:
25```
26from charms.nginx_ingress_integrator.v0.ingress import IngressRequires
27
28# In your charm's `__init__` method.
29self.ingress = IngressRequires(self, {"service-hostname": self.config["external_hostname"],
30 "service-name": self.app.name,
31 "service-port": 80})
32
33# In your charm's `config-changed` handler.
34self.ingress.update_config({"service-hostname": self.config["external_hostname"]})
35```
36"""
37
38import logging
39
40from ops.charm import CharmEvents
41from ops.framework import EventBase, EventSource, Object
42from ops.model import BlockedStatus
43
44# The unique Charmhub library identifier, never change it
45LIBID = "db0af4367506491c91663468fb5caa4c"
46
47# Increment this major API version when introducing breaking changes
48LIBAPI = 0
49
50# Increment this PATCH version before using `charmcraft push-lib` or reset
51# to 0 if you are raising the major API version
52LIBPATCH = 1
53
54logger = logging.getLogger(__name__)
55
56REQUIRED_INGRESS_RELATION_FIELDS = {
57 "service-hostname",
58 "service-name",
59 "service-port",
60}
61
62OPTIONAL_INGRESS_RELATION_FIELDS = {
63 "limit-rps",
64 "limit-whitelist",
65 "max-body-size",
66 "retry-errors",
67 "service-namespace",
68 "session-cookie-max-age",
69 "tls-secret-name",
70}
71
72
73class IngressAvailableEvent(EventBase):
74 pass
75
76
77class IngressCharmEvents(CharmEvents):
78 """Custom charm events."""
79
80 ingress_available = EventSource(IngressAvailableEvent)
81
82
83class IngressRequires(Object):
84 """This class defines the functionality for the 'requires' side of the 'ingress' relation.
85
86 Hook events observed:
87 - relation-changed
88 """
89
90 def __init__(self, charm, config_dict):
91 super().__init__(charm, "ingress")
92
93 self.framework.observe(charm.on["ingress"].relation_changed, self._on_relation_changed)
94
95 self.config_dict = config_dict
96
97 def _config_dict_errors(self, update_only=False):
98 """Check our config dict for errors."""
99 block_status = False
100 unknown = [
101 x for x in self.config_dict if x not in REQUIRED_INGRESS_RELATION_FIELDS | OPTIONAL_INGRESS_RELATION_FIELDS
102 ]
103 if unknown:
104 logger.error("Unknown key(s) in config dictionary found: %s", ", ".join(unknown))
105 block_status = True
106 if not update_only:
107 missing = [x for x in REQUIRED_INGRESS_RELATION_FIELDS if x not in self.config_dict]
108 if missing:
109 logger.error("Missing required key(s) in config dictionary: %s", ", ".join(missing))
110 block_status = True
111 if block_status:
112 self.model.unit.status = BlockedStatus("Error in ingress relation, check `juju debug-log`")
113 return True
114 return False
115
116 def _on_relation_changed(self, event):
117 """Handle the relation-changed event."""
118 # `self.unit` isn't available here, so use `self.model.unit`.
119 if self.model.unit.is_leader():
120 if self._config_dict_errors():
121 return
122 for key in self.config_dict:
123 event.relation.data[self.model.app][key] = str(self.config_dict[key])
124
125 def update_config(self, config_dict):
126 """Allow for updates to relation."""
127 if self.model.unit.is_leader():
128 self.config_dict = config_dict
129 if self._config_dict_errors(update_only=True):
130 return
131 relation = self.model.get_relation("ingress")
132 if relation:
133 for key in self.config_dict:
134 relation.data[self.model.app][key] = str(self.config_dict[key])
135
136
137class IngressProvides(Object):
138 """This class defines the functionality for the 'provides' side of the 'ingress' relation.
139
140 Hook events observed:
141 - relation-changed
142 """
143
144 def __init__(self, charm):
145 super().__init__(charm, "ingress")
146 # Observe the relation-changed hook event and bind
147 # self.on_relation_changed() to handle the event.
148 self.framework.observe(charm.on["ingress"].relation_changed, self._on_relation_changed)
149 self.charm = charm
150
151 def _on_relation_changed(self, event):
152 """Handle a change to the ingress relation.
153
154 Confirm we have the fields we expect to receive."""
155 # `self.unit` isn't available here, so use `self.model.unit`.
156 if not self.model.unit.is_leader():
157 return
158
159 ingress_data = {
160 field: event.relation.data[event.app].get(field)
161 for field in REQUIRED_INGRESS_RELATION_FIELDS | OPTIONAL_INGRESS_RELATION_FIELDS
162 }
163
164 missing_fields = sorted(
165 [field for field in REQUIRED_INGRESS_RELATION_FIELDS if ingress_data.get(field) is None]
166 )
167
168 if missing_fields:
169 logger.error("Missing required data fields for ingress relation: {}".format(", ".join(missing_fields)))
170 self.model.unit.status = BlockedStatus("Missing fields for ingress: {}".format(", ".join(missing_fields)))
171
172 # Create an event that our charm can use to decide it's okay to
173 # configure the ingress.
174 self.charm.on.ingress_available.emit()
diff --git a/metadata.yaml b/metadata.yaml
index 7ce6e17..d2730e5 100644
--- a/metadata.yaml
+++ b/metadata.yaml
@@ -6,15 +6,17 @@ docs: https://discourse.charmhub.io/t/gunicorn-docs-index/4606
6description: |6description: |
7 A charm for deploying and managing Gunicorn workloads7 A charm for deploying and managing Gunicorn workloads
8summary: |8summary: |
9 A charm for deploying and managing Gunicorn workloads9 Gunicorn charm
10series: [kubernetes]10
11min-juju-version: 2.8.0 # charm storage in state11containers:
12 gunicorn:
13 resource: gunicorn-image
14
12resources:15resources:
13 gunicorn-image:16 gunicorn-image:
14 type: oci-image17 type: oci-image
15 description: docker image for Gunicorn18 description: Docker image for gunicorn to run
16 auto-fetch: true19
17 upstream-source: 'gunicorncharmers/gunicorn-app:20.0.4-20.04_edge'
18requires:20requires:
19 pg:21 pg:
20 interface: pgsql22 interface: pgsql
@@ -22,3 +24,5 @@ requires:
22 influxdb:24 influxdb:
23 interface: influxdb-api25 interface: influxdb-api
24 limit: 126 limit: 1
27 ingress:
28 interface: ingress
diff --git a/requirements.txt b/requirements.txt
index 6b14e66..fd6adcd 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,2 @@
1ops1ops
2ops-lib-pgsql2ops-lib-pgsql
3https://github.com/juju-solutions/resource-oci-image/archive/master.zip
diff --git a/src/charm.py b/src/charm.py
index c25df78..2d6865a 100755
--- a/src/charm.py
+++ b/src/charm.py
@@ -6,8 +6,8 @@ from jinja2 import Environment, BaseLoader, meta
6import logging6import logging
7import yaml7import yaml
88
9from charms.nginx_ingress_integrator.v0.ingress import IngressRequires
9import ops10import ops
10from oci_image import OCIImageResource, OCIImageResourceError
11from ops.framework import StoredState11from ops.framework import StoredState
12from ops.charm import CharmBase12from ops.charm import CharmBase
13from ops.main import main13from ops.main import main
@@ -23,37 +23,109 @@ logger = logging.getLogger(__name__)
2323
24REQUIRED_JUJU_CONFIG = ['external_hostname']24REQUIRED_JUJU_CONFIG = ['external_hostname']
25JUJU_CONFIG_YAML_DICT_ITEMS = ['environment']25JUJU_CONFIG_YAML_DICT_ITEMS = ['environment']
26CONTAINER_NAME = yaml.full_load(open('metadata.yaml', 'r')).get('name').replace("-k8s", "")
2627
28class GunicornK8sCharm(CharmBase):
29 _stored = StoredState()
2730
28class GunicornK8sCharmJujuConfigError(Exception):31 def __init__(self, *args):
29 """Exception when the Juju config is bad."""32 super().__init__(*args)
3033
31 pass34 self.framework.observe(self.on.config_changed, self._on_config_changed)
35 self.framework.observe(self.on.gunicorn_pebble_ready, self._on_gunicorn_pebble_ready)
3236
37 self.ingress = IngressRequires(
38 self,
39 {
40 "service-hostname": self.config["external_hostname"],
41 "service-name": self.app.name,
42 "service-port": 80,
43 },
44 )
3345
34class GunicornK8sCharmYAMLError(Exception):46 self._stored.set_default(
35 """Exception raised when parsing YAML fails"""47 reldata={},
48 )
3649
37 pass50 self._init_postgresql_relation()
3851
52 def _get_pebble_config(self, event: ops.framework.EventBase) -> dict:
53 """Generate pebble config."""
54 pebble_config = {
55 "summary": "gunicorn layer",
56 "description": "gunicorn layer",
57 "services": {
58 "gunicorn": {
59 "override": "replace",
60 "summary": "gunicorn service",
61 "command": "/srv/gunicorn/run",
62 "startup": "enabled",
63 }
64 },
65 "checks": {
66 "gunicorn-ready": {
67 "override": "replace",
68 "level": "ready",
69 "http": {"url": "http://127.0.0.1:80"},
70 },
71 },
72 }
3973
40class GunicornK8sCharm(CharmBase):74 # Update pod environment config.
41 _stored = StoredState()75 pod_env_config = self._make_pod_env()
76 if type(pod_env_config) is bool:
77 logger.error("Error getting pod_env_config: %s",
78 "Could not parse Juju config 'environment' as a YAML dict - check \"juju debug-log -l ERROR\"")
79 self.unit.status = BlockedStatus('Error getting pod_env_config')
80 return {}
81 elif type(pod_env_config) is set:
82 self.unit.status = BlockedStatus(
83 'Waiting for {} relation(s)'.format(", ".join(sorted(pod_env_config)))
84 )
85 event.defer()
86 return {}
4287
43 def __init__(self, *args):88 juju_conf = self._check_juju_config()
44 super().__init__(*args)89 if juju_conf:
90 self.unit.status = BlockedStatus(str(juju_conf))
91 return {}
4592
46 self.image = OCIImageResource(self, 'gunicorn-image')93 if pod_env_config:
94 pebble_config["services"]["gunicorn"]["environment"] = pod_env_config
95 return pebble_config
4796
48 self.framework.observe(self.on.start, self._configure_pod)97 def _on_config_changed(self, event: ops.framework.EventBase) -> None:
49 self.framework.observe(self.on.config_changed, self._configure_pod)98 """Handle the config changed event."""
50 self.framework.observe(self.on.leader_elected, self._configure_pod)
51 self.framework.observe(self.on.upgrade_charm, self._configure_pod)
5299
53 # For special-cased relations100 self._configure_workload(event)
54 self._stored.set_default(reldata={})
55101
56 self._init_postgresql_relation()102 def _on_gunicorn_pebble_ready(self, event: ops.framework.EventBase) -> None:
103 """Handle the workload ready event."""
104
105 self._configure_workload(event)
106
107 def _configure_workload(self, event: ops.charm.EventBase) -> None:
108 """Configure the workload container."""
109 pebble_config = self._get_pebble_config(event)
110 if not pebble_config:
111 # Charm will be in blocked status.
112 return
113
114 # Ensure the ingress relation has the external hostname.
115 self.ingress.update_config({"service-hostname": self.config["external_hostname"]})
116
117 container = self.unit.get_container(CONTAINER_NAME)
118 # pebble may not be ready, in which case we just return
119 if not container.can_connect():
120 self.unit.status = MaintenanceStatus('waiting for pebble to start')
121 logger.debug('waiting for pebble to start')
122 return
123
124 logger.debug("About to add_layer with pebble_config:\n{}".format(yaml.dump(pebble_config)))
125 container.add_layer(CONTAINER_NAME, pebble_config, combine=True)
126 container.pebble.replan_services()
127
128 self.unit.status = ActiveStatus()
57129
58 def _init_postgresql_relation(self) -> None:130 def _init_postgresql_relation(self) -> None:
59 """Initialization related to the postgresql relation"""131 """Initialization related to the postgresql relation"""
@@ -87,7 +159,7 @@ class GunicornK8sCharm(CharmBase):
87 if event.master is None:159 if event.master is None:
88 return160 return
89161
90 self._configure_pod(event)162 self._on_config_changed(event)
91163
92 def _on_standby_changed(self, event: pgsql.StandbyChangedEvent) -> None:164 def _on_standby_changed(self, event: pgsql.StandbyChangedEvent) -> None:
93 """Handle changes in the secondary database unit(s)."""165 """Handle changes in the secondary database unit(s)."""
@@ -103,8 +175,6 @@ class GunicornK8sCharm(CharmBase):
103 def _check_juju_config(self) -> None:175 def _check_juju_config(self) -> None:
104 """Check if all the required Juju config options are set,176 """Check if all the required Juju config options are set,
105 and if all the Juju config options are properly formatted177 and if all the Juju config options are properly formatted
106
107 :raises GunicornK8sCharmJujuConfigError: if a required config is not set
108 """178 """
109179
110 # Verify required items180 # Verify required items
@@ -114,41 +184,7 @@ class GunicornK8sCharm(CharmBase):
114 logger.error("Required Juju config item not set : %s", required)184 logger.error("Required Juju config item not set : %s", required)
115 errors.append(required)185 errors.append(required)
116 if errors:186 if errors:
117 raise GunicornK8sCharmJujuConfigError(187 return "Required Juju config item(s) not set : {}".format(", ".join(sorted(errors)))
118 "Required Juju config item(s) not set : {}".format(", ".join(sorted(errors)))
119 )
120
121 def _make_k8s_ingress(self) -> list:
122 """Return an ingress that you can use in k8s_resources
123
124 :returns: A list to be used as k8s ingress
125 """
126
127 hostname = self.model.config['external_hostname']
128
129 ingress = {
130 "name": "{}-ingress".format(self.app.name),
131 "spec": {
132 "rules": [
133 {
134 "host": hostname,
135 "http": {
136 "paths": [
137 {
138 "path": "/",
139 "backend": {"serviceName": self.app.name, "servicePort": 80},
140 }
141 ]
142 },
143 }
144 ]
145 },
146 "annotations": {
147 'nginx.ingress.kubernetes.io/ssl-redirect': 'false',
148 },
149 }
150
151 return [ingress]
152188
153 def _render_template(self, tmpl: str, ctx: dict) -> str:189 def _render_template(self, tmpl: str, ctx: dict) -> str:
154 """Render a Jinja2 template190 """Render a Jinja2 template
@@ -207,10 +243,7 @@ class GunicornK8sCharm(CharmBase):
207 return ctx243 return ctx
208244
209 def _validate_yaml(self, supposed_yaml: str, expected_type: type) -> None:245 def _validate_yaml(self, supposed_yaml: str, expected_type: type) -> None:
210 """Validate that the supplied YAML is parsed into the supplied type.246 """Validate that the supplied YAML is parsed into the supplied type."""
211
212 :raises GunicornK8sCharmYAMLError: if the YAML is incorrect, or if it's not parsed into the expected type
213 """
214 err = False247 err = False
215 parsed = None248 parsed = None
216249
@@ -230,7 +263,7 @@ class GunicornK8sCharm(CharmBase):
230 )263 )
231264
232 if err:265 if err:
233 raise GunicornK8sCharmYAMLError('YAML parsing failed, please check "juju debug-log -l ERROR"')266 return err
234267
235 def _make_pod_env(self) -> dict:268 def _make_pod_env(self) -> dict:
236 """Return an envConfig with some core configuration.269 """Return an envConfig with some core configuration.
@@ -243,107 +276,27 @@ class GunicornK8sCharm(CharmBase):
243 return {}276 return {}
244277
245 ctx = self._get_context_from_relations()278 ctx = self._get_context_from_relations()
246 rendered_env = self._render_template(env, ctx)
247
248 try:
249 self._validate_yaml(rendered_env, dict)
250 except GunicornK8sCharmYAMLError:
251 raise GunicornK8sCharmJujuConfigError(
252 "Could not parse Juju config 'environment' as a YAML dict - check \"juju debug-log -l ERROR\""
253 )
254
255 env = yaml.safe_load(rendered_env)
256
257 return env
258
259 def _make_pod_spec(self) -> dict:
260 """Return a pod spec with some core configuration.
261
262 :returns: A pod spec
263 """
264
265 try:
266 image_details = self.image.fetch()
267 logging.info("using imageDetails: {}")
268 except OCIImageResourceError:
269 logging.exception('An error occurred while fetching the image info')
270 self.unit.status = BlockedStatus('Error fetching image information')
271 return {}
272
273 pod_env = self._make_pod_env()
274
275 return {
276 'version': 3, # otherwise resources are ignored
277 'containers': [
278 {
279 'name': self.app.name,
280 'imageDetails': image_details,
281 # TODO: debatable. The idea is that if you want to force an update with the same image name, you
282 # don't need to empty kubelet cache on each node to have the right version.
283 # This implies a performance drop upon start.
284 'imagePullPolicy': 'Always',
285 'ports': [{'containerPort': 80, 'protocol': 'TCP'}],
286 'envConfig': pod_env,
287 'kubernetes': {
288 'readinessProbe': {'httpGet': {'path': '/', 'port': 80}},
289 },
290 }
291 ],
292 }
293
294 def _configure_pod(self, event: ops.framework.EventBase) -> None:
295 """Assemble the pod spec and apply it, if possible.
296
297 :param event: Event that triggered the method.
298 """
299
300 env = self.model.config['environment']
301 ctx = self._get_context_from_relations()
302279
303 if env:280 j2env = Environment(loader=BaseLoader)
304 j2env = Environment(loader=BaseLoader)281 j2template = j2env.parse(env)
305 j2template = j2env.parse(env)282 missing_vars = set()
306 missing_vars = set()
307283
308 for req_var in meta.find_undeclared_variables(j2template):284 for req_var in meta.find_undeclared_variables(j2template):
309 if not ctx.get(req_var):285 if not ctx.get(req_var):
310 missing_vars.add(req_var)286 missing_vars.add(req_var)
311287
312 if missing_vars:288 if missing_vars:
313 logger.info(289 return missing_vars
314 "Missing YAML vars to interpolate the 'environment' config option, "
315 "setting status to 'waiting' : %s",
316 ", ".join(sorted(missing_vars)),
317 )
318 self.unit.status = BlockedStatus('Waiting for {} relation(s)'.format(", ".join(sorted(missing_vars))))
319 event.defer()
320 return
321
322 if not self.unit.is_leader():
323 self.unit.status = ActiveStatus()
324 return
325290
326 try:291 rendered_env = self._render_template(env, ctx)
327 self._check_juju_config()
328 except GunicornK8sCharmJujuConfigError as e:
329 self.unit.status = BlockedStatus(str(e))
330 return
331292
332 self.unit.status = MaintenanceStatus('Assembling pod spec')293 yaml_val = self._validate_yaml(rendered_env, dict)
294 if yaml_val:
295 return yaml_val
333296
334 try:297 env = yaml.safe_load(rendered_env)
335 pod_spec = self._make_pod_spec()
336 except GunicornK8sCharmJujuConfigError as e:
337 self.unit.status = BlockedStatus(str(e))
338 return
339
340 resources = pod_spec.get('kubernetesResources', {})
341 resources['ingressResources'] = self._make_k8s_ingress()
342298
343 self.unit.status = MaintenanceStatus('Setting pod spec')299 return env
344 self.model.pod.set_spec(pod_spec, k8s_resources={'kubernetesResources': resources})
345 logger.info("Setting active status")
346 self.unit.status = ActiveStatus()
347300
348301
349if __name__ == "__main__": # pragma: no cover302if __name__ == "__main__": # pragma: no cover
diff --git a/tests/unit/scenario.py b/tests/unit/scenario.py
index 2efb837..15b1e70 100644
--- a/tests/unit/scenario.py
+++ b/tests/unit/scenario.py
@@ -78,86 +78,6 @@ TEST_CONFIGURE_POD = {
78 },78 },
79}79}
8080
81TEST_MAKE_POD_SPEC = {
82 'basic_no_env': {
83 'config': {
84 'external_hostname': 'example.com',
85 },
86 'pod_spec': {
87 'version': 3, # otherwise resources are ignored
88 'containers': [
89 {
90 'name': 'gunicorn-k8s',
91 'imageDetails': {
92 'imagePath': 'registrypath',
93 'password': 'password',
94 'username': 'username',
95 },
96 'imagePullPolicy': 'Always',
97 'ports': [{'containerPort': 80, 'protocol': 'TCP'}],
98 'envConfig': {},
99 'kubernetes': {'readinessProbe': {'httpGet': {'path': '/', 'port': 80}}},
100 }
101 ],
102 },
103 },
104 'basic_with_env': {
105 'config': {
106 'external_hostname': 'example.com',
107 'environment': 'MYENV: foo',
108 },
109 'pod_spec': {
110 'version': 3, # otherwise resources are ignored
111 'containers': [
112 {
113 'name': 'gunicorn-k8s',
114 'imageDetails': {
115 'imagePath': 'registrypath',
116 'password': 'password',
117 'username': 'username',
118 },
119 'imagePullPolicy': 'Always',
120 'ports': [{'containerPort': 80, 'protocol': 'TCP'}],
121 'envConfig': {'MYENV': 'foo'},
122 'kubernetes': {'readinessProbe': {'httpGet': {'path': '/', 'port': 80}}},
123 }
124 ],
125 },
126 },
127}
128
129
130TEST_MAKE_K8S_INGRESS = {
131 'basic': {
132 'config': {
133 'external_hostname': 'example.com',
134 },
135 'expected': [
136 {
137 'name': 'gunicorn-k8s-ingress',
138 'spec': {
139 'rules': [
140 {
141 'host': 'example.com',
142 'http': {
143 'paths': [
144 {
145 'path': '/',
146 'backend': {'serviceName': 'gunicorn-k8s', 'servicePort': 80},
147 },
148 ],
149 },
150 },
151 ],
152 },
153 'annotations': {
154 'nginx.ingress.kubernetes.io/ssl-redirect': 'false',
155 },
156 },
157 ],
158 },
159}
160
161TEST_RENDER_TEMPLATE = {81TEST_RENDER_TEMPLATE = {
162 'working': {82 'working': {
163 'tmpl': "test {{db.x}}",83 'tmpl': "test {{db.x}}",
diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py
index b5e1f87..d698ea9 100755
--- a/tests/unit/test_charm.py
+++ b/tests/unit/test_charm.py
@@ -6,20 +6,13 @@ import unittest
66
7from unittest.mock import MagicMock, patch7from unittest.mock import MagicMock, patch
88
9from charm import GunicornK8sCharm, GunicornK8sCharmJujuConfigError, GunicornK8sCharmYAMLError9from charm import GunicornK8sCharm
1010
11from ops import testing11from ops import testing
12from ops.model import (
13 ActiveStatus,
14 BlockedStatus,
15)
1612
17from scenario import (13from scenario import (
18 JUJU_DEFAULT_CONFIG,14 JUJU_DEFAULT_CONFIG,
19 TEST_JUJU_CONFIG,15 TEST_JUJU_CONFIG,
20 TEST_CONFIGURE_POD,
21 TEST_MAKE_POD_SPEC,
22 TEST_MAKE_K8S_INGRESS,
23 TEST_RENDER_TEMPLATE,16 TEST_RENDER_TEMPLATE,
24 TEST_PG_URI,17 TEST_PG_URI,
25 TEST_PG_CONNSTR,18 TEST_PG_CONNSTR,
@@ -49,14 +42,14 @@ class TestGunicornK8sCharm(unittest.TestCase):
4942
50 with patch('test_charm.GunicornK8sCharm._stored') as mock_stored:43 with patch('test_charm.GunicornK8sCharm._stored') as mock_stored:
51 with patch('pgsql.PostgreSQLClient'):44 with patch('pgsql.PostgreSQLClient'):
52 mock_stored.reldata = {'pg': 'foo'}45 with patch('test_charm.GunicornK8sCharm.on'):
53 c = GunicornK8sCharm(MagicMock())46 mock_stored.reldata = {'pg': 'foo'}
54 self.assertEqual(c._stored.reldata, mock_stored.reldata)47 c = GunicornK8sCharm(MagicMock())
48 self.assertEqual(c._stored.reldata, mock_stored.reldata)
5549
56 def test_on_database_relation_joined(self):50 def test_on_database_relation_joined_unit_is_leader(self):
57 """Test the _on_database_relation_joined function."""51 """Test the _on_database_relation_joined function."""
5852
59 # Unit is leader
60 mock_event = MagicMock()53 mock_event = MagicMock()
61 self.harness.disable_hooks() # we don't want leader-set to fire54 self.harness.disable_hooks() # we don't want leader-set to fire
62 self.harness.set_leader(True)55 self.harness.set_leader(True)
@@ -65,7 +58,7 @@ class TestGunicornK8sCharm(unittest.TestCase):
6558
66 self.assertEqual(mock_event.database, self.harness.charm.app.name)59 self.assertEqual(mock_event.database, self.harness.charm.app.name)
6760
68 # Unit is not leader, DB not ready61 def test_on_database_relation_joined_unit_is_not_leader(self):
69 mock_event = MagicMock()62 mock_event = MagicMock()
70 self.harness.disable_hooks() # we don't want leader-set to fire63 self.harness.disable_hooks() # we don't want leader-set to fire
71 self.harness.set_leader(False)64 self.harness.set_leader(False)
@@ -74,7 +67,6 @@ class TestGunicornK8sCharm(unittest.TestCase):
7467
75 mock_event.defer.assert_called_once()68 mock_event.defer.assert_called_once()
7669
77 # Unit is leader, DB ready
78 mock_event = MagicMock()70 mock_event = MagicMock()
79 self.harness.disable_hooks() # we don't want leader-set to fire71 self.harness.disable_hooks() # we don't want leader-set to fire
80 self.harness.set_leader(False)72 self.harness.set_leader(False)
@@ -109,26 +101,27 @@ class TestGunicornK8sCharm(unittest.TestCase):
109 mock_event.database = self.harness.charm.app.name101 mock_event.database = self.harness.charm.app.name
110 mock_event.master.conn_str = TEST_PG_CONNSTR102 mock_event.master.conn_str = TEST_PG_CONNSTR
111 mock_event.master.uri = TEST_PG_URI103 mock_event.master.uri = TEST_PG_URI
112 with patch('charm.GunicornK8sCharm._configure_pod') as configure_pod:104 with patch('charm.GunicornK8sCharm._on_config_changed') as on_config_changes:
113 r = self.harness.charm._on_master_changed(mock_event)105 r = self.harness.charm._on_master_changed(mock_event)
114106
115 reldata = self.harness.charm._stored.reldata107 reldata = self.harness.charm._stored.reldata
116 self.assertEqual(reldata['pg']['conn_str'], mock_event.master.conn_str)108 self.assertEqual(reldata['pg']['conn_str'], mock_event.master.conn_str)
117 self.assertEqual(reldata['pg']['db_uri'], mock_event.master.uri)109 self.assertEqual(reldata['pg']['db_uri'], mock_event.master.uri)
118 self.assertEqual(r, None)110 self.assertEqual(r, None)
119 configure_pod.assert_called_with(mock_event)111 on_config_changes.assert_called_with(mock_event)
120112
121 def test_on_standby_changed(self):113 def test_on_standby_changed_database_not_ready(self):
122 """Test the _on_standby_changed function."""114 """Test the _on_standby_changed function."""
123115
124 # Database not ready
125 mock_event = MagicMock()116 mock_event = MagicMock()
126 mock_event.database = None117 mock_event.database = None
127118
128 r = self.harness.charm._on_standby_changed(mock_event)119 r = self.harness.charm._on_standby_changed(mock_event)
129 self.assertEqual(r, None)120 self.assertEqual(r, None)
130121
131 # Database ready122 def test_on_standby_changed_database_ready(self):
123 """Test the _on_standby_changed function."""
124
132 mock_event = MagicMock()125 mock_event = MagicMock()
133 mock_event.database = self.harness.charm.app.name126 mock_event.database = self.harness.charm.app.name
134127
@@ -149,10 +142,8 @@ class TestGunicornK8sCharm(unittest.TestCase):
149 self.harness.update_config(values['config'])142 self.harness.update_config(values['config'])
150 if values['expected']:143 if values['expected']:
151 with self.assertLogs(level='ERROR') as logger:144 with self.assertLogs(level='ERROR') as logger:
152 with self.assertRaises(GunicornK8sCharmJujuConfigError) as exc:145 self.harness.charm._check_juju_config()
153 self.harness.charm._check_juju_config()
154 self.assertEqual(sorted(logger.output), sorted(values['logger']))146 self.assertEqual(sorted(logger.output), sorted(values['logger']))
155 self.assertEqual(str(exc.exception), values['expected'])
156 else:147 else:
157 self.assertEqual(self.harness.charm._check_juju_config(), None)148 self.assertEqual(self.harness.charm._check_juju_config(), None)
158149
@@ -161,16 +152,6 @@ class TestGunicornK8sCharm(unittest.TestCase):
161 # The second argument is the list of key to reset152 # The second argument is the list of key to reset
162 self.harness.update_config(JUJU_DEFAULT_CONFIG)153 self.harness.update_config(JUJU_DEFAULT_CONFIG)
163154
164 def test_make_k8s_ingress(self):
165 """Check the crafting of the ingress part of the pod spec."""
166 self.harness.update_config(JUJU_DEFAULT_CONFIG)
167
168 for scenario, values in TEST_MAKE_K8S_INGRESS.items():
169 with self.subTest(scenario=scenario):
170 self.harness.update_config(values['config'])
171 self.assertEqual(self.harness.charm._make_k8s_ingress(), values['expected'])
172 self.harness.update_config(JUJU_DEFAULT_CONFIG) # You need to clean the config after each run
173
174 def test_render_template(self):155 def test_render_template(self):
175 """Test template rendering."""156 """Test template rendering."""
176157
@@ -225,18 +206,18 @@ class TestGunicornK8sCharm(unittest.TestCase):
225 self.assertEqual(sorted(logger.output), sorted(expected_logger))206 self.assertEqual(sorted(logger.output), sorted(expected_logger))
226 self.assertEqual(r, expected_ret)207 self.assertEqual(r, expected_ret)
227208
228 def test_validate_yaml(self):209 def test_validate_yaml_proper_type_proper_yaml(self):
229 """Test the _validate_yaml function."""210 """Test the _validate_yaml function."""
230211
231 # Proper YAML and type
232 test_str = "a: b\n1: 2"212 test_str = "a: b\n1: 2"
233 expected_type = dict213 expected_type = dict
234214
235 r = self.harness.charm._validate_yaml(test_str, expected_type)215 r = self.harness.charm._validate_yaml(test_str, expected_type)
236216
237 self.assertEqual(r, None)217 self.assertEqual(r, None)
218
219 def test_validate_yaml_incorrect_yaml(self):
238220
239 # Incorrect YAML
240 test_str = "a: :"221 test_str = "a: :"
241 expected_type = dict222 expected_type = dict
242 expected_output = [223 expected_output = [
@@ -246,49 +227,45 @@ class TestGunicornK8sCharm(unittest.TestCase):
246 ' a: :\n'227 ' a: :\n'
247 ' ^'228 ' ^'
248 ]229 ]
249 expected_exception = 'YAML parsing failed, please check "juju debug-log -l ERROR"'
250230
251 with self.assertLogs(level='ERROR') as logger:231 with self.assertLogs(level='ERROR') as logger:
252 with self.assertRaises(GunicornK8sCharmYAMLError) as exc:232 self.harness.charm._validate_yaml(test_str, expected_type)
253 self.harness.charm._validate_yaml(test_str, expected_type)
254233
255 self.assertEqual(sorted(logger.output), expected_output)234 self.assertEqual(sorted(logger.output), expected_output)
256 self.assertEqual(str(exc.exception), expected_exception)
257235
258 # Proper YAML, incorrect type236 def test_validate_yaml_incorrect_type_proper_yaml(self):
237
259 test_str = "a: b"238 test_str = "a: b"
260 expected_type = str239 expected_type = str
261 expected_output = [240 expected_output = [
262 "ERROR:charm:Expected type '<class 'str'>' but got '<class 'dict'>' when " 'parsing YAML : a: b'241 "ERROR:charm:Expected type '<class 'str'>' but got '<class 'dict'>' when " 'parsing YAML : a: b'
263 ]242 ]
264243
265 expected_exception = 'YAML parsing failed, please check "juju debug-log -l ERROR"'
266
267 with self.assertLogs(level='ERROR') as logger:244 with self.assertLogs(level='ERROR') as logger:
268 with self.assertRaises(GunicornK8sCharmYAMLError) as exc:245 self.harness.charm._validate_yaml(test_str, expected_type)
269 self.harness.charm._validate_yaml(test_str, expected_type)
270246
271 self.assertEqual(sorted(logger.output), expected_output)247 self.assertEqual(sorted(logger.output), expected_output)
272 self.assertEqual(str(exc.exception), expected_exception)
273248
274 def test_make_pod_env(self):249 def test_make_pod_env_empty_conf(self):
275 """Test the _make_pod_env function."""250 """Test the _make_pod_env function."""
276251
277 # No env
278 self.harness.update_config(JUJU_DEFAULT_CONFIG)252 self.harness.update_config(JUJU_DEFAULT_CONFIG)
279 self.harness.update_config({'environment': ''})253 self.harness.update_config({'environment': ''})
280 expected_ret = {}254 expected_ret = {}
281255
282 r = self.harness.charm._make_pod_env()256 r = self.harness.charm._make_pod_env()
283 self.assertEqual(r, expected_ret)257 self.assertEqual(r, expected_ret, "No env")
258
259 def test_make_pod_env_proper_env_no_temp_rel(self):
284260
285 # Proper env, no templating/relation
286 self.harness.update_config(JUJU_DEFAULT_CONFIG)261 self.harness.update_config(JUJU_DEFAULT_CONFIG)
287 self.harness.update_config({'environment': 'a: b'})262 self.harness.update_config({'environment': 'a: b'})
288 expected_ret = {'a': 'b'}263 expected_ret = {'a': 'b'}
289264
290 r = self.harness.charm._make_pod_env()265 r = self.harness.charm._make_pod_env()
291 self.assertEqual(r, expected_ret)266 self.assertEqual(r, expected_ret)
267
268 def test_make_pod_env_proper_env_temp_rel(self):
292269
293 # Proper env with templating/relations270 # Proper env with templating/relations
294 self.harness.update_config(JUJU_DEFAULT_CONFIG)271 self.harness.update_config(JUJU_DEFAULT_CONFIG)
@@ -310,111 +287,92 @@ class TestGunicornK8sCharm(unittest.TestCase):
310 r = self.harness.charm._make_pod_env()287 r = self.harness.charm._make_pod_env()
311 self.assertEqual(r, expected_ret)288 self.assertEqual(r, expected_ret)
312289
290 def test_make_pod_env_improper_env(self):
291
313 # Improper env292 # Improper env
314 self.harness.update_config(JUJU_DEFAULT_CONFIG)293 self.harness.update_config(JUJU_DEFAULT_CONFIG)
315 self.harness.update_config({'environment': 'a: :'})294 self.harness.update_config({'environment': 'a: :'})
316 expected_ret = None295 expected_ret = None
317 expected_exception = (296 expected_output = [
318 'Could not parse Juju config \'environment\' as a YAML dict - check "juju debug-log -l ERROR"'297 'ERROR:charm:Error when parsing the following YAML : a: : : mapping values '
319 )298 'are not allowed here\n'
320299 ' in "<unicode string>", line 1, column 4:\n'
321 with self.assertRaises(GunicornK8sCharmJujuConfigError) as exc:300 ' a: :\n'
301 ' ^'
302 ]
303 with self.assertLogs(level='ERROR') as logger:
322 self.harness.charm._make_pod_env()304 self.harness.charm._make_pod_env()
305 self.assertEqual(logger.output, expected_output)
323306
324 self.assertEqual(str(exc.exception), expected_exception)307 def test_get_pebble_config(self):
325308 """Test the _get_pebble_config function."""
326 @patch('pgsql.client._leader_get')
327 def test_configure_pod(self, mock_leader_get):
328 """Test the pod configuration."""
329
330 mock_event = MagicMock()309 mock_event = MagicMock()
331 self.harness.update_config(JUJU_DEFAULT_CONFIG)310 expected_ret = {
332311 "summary": "gunicorn layer",
333 self.harness.set_leader(False)312 "description": "gunicorn layer",
334 self.harness.charm.unit.status = BlockedStatus("Testing")313 "services": {
335 self.harness.charm._configure_pod(mock_event)314 "gunicorn": {
336 self.assertEqual(self.harness.charm.unit.status, ActiveStatus())315 "override": "replace",
337 self.harness.update_config(JUJU_DEFAULT_CONFIG) # You need to clean the config after each run316 "summary": "gunicorn service",
338317 "command": "/srv/gunicorn/run",
339 for scenario, values in TEST_CONFIGURE_POD.items():318 "startup": "enabled",
340 with self.subTest(scenario=scenario):319 }
341 mock_leader_get.return_value = values['_leader_get']320 },
342 self.harness.update_config(values['config'])321 "checks": {
343 self.harness.set_leader(True)322 "gunicorn-ready": {
344 self.harness.charm._configure_pod(mock_event)323 "override": "replace",
345 if values['expected']:324 "level": "ready",
346 self.assertEqual(self.harness.charm.unit.status, BlockedStatus(values['expected']))325 "http": {"url": "http://127.0.0.1:80"},
347 else:326 },
348 self.assertEqual(self.harness.charm.unit.status, ActiveStatus())327 },
349328 }
350 self.harness.update_config(JUJU_DEFAULT_CONFIG) # You need to clean the config after each run
351
352 # Test missing vars
353 self.harness.update_config(JUJU_DEFAULT_CONFIG)
354 self.harness.update_config(
355 {
356 'image_path': 'my_gunicorn_app:devel',
357 'external_hostname': 'example.com',
358 'environment': 'DB_URI: {{pg.uri}}',
359 }
360 )
361 self.harness.set_leader(True)
362 expected_status = 'Waiting for pg relation(s)'
363
364 self.harness.charm._configure_pod(mock_event)
365
366 mock_event.defer.assert_called_once()
367 self.assertEqual(self.harness.charm.unit.status, BlockedStatus(expected_status))
368329
369 # Test no missing vars330 r = self.harness.charm._get_pebble_config(mock_event)
370 self.harness.update_config(JUJU_DEFAULT_CONFIG)331 self.assertEqual(r, expected_ret)
371 self.harness.update_config(
372 {
373 'image_path': 'my_gunicorn_app:devel',
374 'external_hostname': 'example.com',
375 'environment': 'DB_URI: {{pg.uri}}',
376 }
377 )
378332
379 reldata = self.harness.charm._stored.reldata333 def test_get_pebble_config_error(self):
380 reldata['pg'] = {'conn_str': TEST_PG_CONNSTR, 'db_uri': TEST_PG_URI}334 """Test the _get_pebble_config function when throwing an error."""
381 self.harness.set_leader(True)335 expected_output = "ERROR:charm:Error getting pod_env_config: Could not parse Juju config 'environment' as a YAML dict - check \"juju debug-log -l ERROR\""
382 # Set up random relation336 expected_ret = {}
383 self.harness.disable_hooks() # no need for hooks to fire for this test337 mock_event = MagicMock()
384 relation_id = self.harness.add_relation('myrel', 'myapp')338 with patch('charm.GunicornK8sCharm._make_pod_env') as make_pod_env:
385 self.harness.add_relation_unit(relation_id, 'myapp/0')339 make_pod_env.return_value = True
386 self.harness.update_relation_data(relation_id, 'myapp/0', {'thing': 'bli'})
387 expected_status = 'Waiting for pg relation(s)'
388340
389 self.harness.charm._configure_pod(mock_event)341 with self.assertLogs(level='ERROR') as logger:
342 r = self.harness.charm._get_pebble_config(mock_event)
343 self.assertEqual(r, expected_ret)
344 self.assertEqual(expected_output, logger.output[0])
390345
391 self.assertEqual(self.harness.charm.unit.status, ActiveStatus())346 def test_on_gunicorn_pebble_ready_no_problem(self):
347 """Test the _on_gunicorn_pebble_ready function."""
392348
393 # Test incorrect YAML349 mock_event = MagicMock()
394 self.harness.update_config(JUJU_DEFAULT_CONFIG)350 expected_ret = None
395 self.harness.update_config(
396 {
397 'image_path': 'my_gunicorn_app:devel',
398 'external_hostname': 'example.com',
399 'environment': 'a: :',
400 }
401 )
402 self.harness.set_leader(True)
403 expected_status = 'Could not parse Juju config \'environment\' as a YAML dict - check "juju debug-log -l ERROR"'
404351
405 self.harness.charm._configure_pod(mock_event)352 r = self.harness.charm._on_gunicorn_pebble_ready(mock_event)
353 self.assertEqual(r, expected_ret)
406354
407 self.assertEqual(self.harness.charm.unit.status, BlockedStatus(expected_status))355 def test_configure_workload_no_problem(self):
356 """Test the _configure_workload function."""
408357
409 def test_make_pod_spec(self):358 mock_event = MagicMock()
410 """Check the crafting of the pod spec."""359 expected_ret = None
411 self.harness.update_config(JUJU_DEFAULT_CONFIG)
412360
413 for scenario, values in TEST_MAKE_POD_SPEC.items():361 r = self.harness.charm._configure_workload(mock_event)
414 with self.subTest(scenario=scenario):362 self.assertEqual(r, expected_ret)
415 self.harness.update_config(values['config'])363
416 self.assertEqual(self.harness.charm._make_pod_spec(), values['pod_spec'])364 def test_configure_workload_pebble_not_ready(self):
417 self.harness.update_config(JUJU_DEFAULT_CONFIG) # You need to clean the config after each run365
366 mock_event = MagicMock()
367 expected_ret = None
368 expected_output = 'waiting for pebble to start'
369 with patch('ops.model.Container.can_connect') as can_connect:
370 can_connect.return_value = False
371
372 with self.assertLogs(level='DEBUG') as logger:
373 r = self.harness.charm._configure_workload(mock_event)
374 self.assertEqual(r, expected_ret)
375 self.assertTrue(expected_output in logger.output[0])
418376
419377
420if __name__ == '__main__':378if __name__ == '__main__':
diff --git a/tox.ini b/tox.ini
index 8dedaea..09db789 100644
--- a/tox.ini
+++ b/tox.ini
@@ -13,7 +13,7 @@ commands =
13deps = -r{toxinidir}/tests/unit/requirements.txt13deps = -r{toxinidir}/tests/unit/requirements.txt
14 -r{toxinidir}/requirements.txt14 -r{toxinidir}/requirements.txt
15setenv =15setenv =
16 PYTHONPATH={toxinidir}/src:{toxinidir}/build/lib:{toxinidir}/build/venv16 PYTHONPATH={toxinidir}/src:{toxinidir}/lib
17 TZ=UTC17 TZ=UTC
1818
19[testenv:black]19[testenv:black]

Subscribers

People subscribed via source and target branches