Merge ~mthaddon/charm-k8s-ingress/+git/charm-k8s-ingress:initial-k8s-api into charm-k8s-ingress:master
- Git
- lp:~mthaddon/charm-k8s-ingress/+git/charm-k8s-ingress
- initial-k8s-api
- Merge into master
Proposed by
Tom Haddon
Status: | Merged |
---|---|
Approved by: | Tom Haddon |
Approved revision: | e70b2a62f2e4c3e1ac5d65fc318e683535754a88 |
Merged at revision: | 20535875cd9ac90ff7a7fa6ef41367e2272b597d |
Proposed branch: | ~mthaddon/charm-k8s-ingress/+git/charm-k8s-ingress:initial-k8s-api |
Merge into: | charm-k8s-ingress:master |
Diff against target: |
553 lines (+387/-49) 9 files modified
Makefile (+23/-0) README.md (+122/-11) config.yaml (+20/-0) dev/null (+0/-17) requirements.txt (+1/-0) src/charm.py (+164/-2) tests/unit/requirements.txt (+4/-0) tests/unit/test_charm.py (+4/-19) tox.ini (+49/-0) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Stuart Bishop (community) | Approve | ||
Review via email: mp+399876@code.launchpad.net |
Commit message
Configure an ingress defined by juju config options, switch to tox following existing charm patterns.
Description of the change
To post a comment you must log in.
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
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
Stuart Bishop (stub) wrote : | # |
Yup. This is of course still in a state of flux (per the documented failure), but it is good to land this as we go.
review:
Approve
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
Change successfully merged at revision 20535875cd9ac90
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/Makefile b/Makefile |
2 | new file mode 100644 |
3 | index 0000000..a3166a1 |
4 | --- /dev/null |
5 | +++ b/Makefile |
6 | @@ -0,0 +1,23 @@ |
7 | +blacken: |
8 | + @echo "Normalising python layout with black." |
9 | + @tox -e black |
10 | + |
11 | +lint: blacken |
12 | + @echo "Running flake8" |
13 | + @tox -e lint |
14 | + |
15 | +# We actually use the build directory created by charmcraft, |
16 | +# but the .charm file makes a much more convenient sentinel. |
17 | +unittest: k8s-ingress.charm |
18 | + @tox -e unit |
19 | + |
20 | +test: lint unittest |
21 | + |
22 | +clean: |
23 | + @echo "Cleaning files" |
24 | + @git clean -fXd |
25 | + |
26 | +mattermost.charm: src/*.py requirements.txt |
27 | + charmcraft build |
28 | + |
29 | +.PHONY: lint test unittest clean |
30 | diff --git a/README.md b/README.md |
31 | index 9d215b0..7458fdc 100644 |
32 | --- a/README.md |
33 | +++ b/README.md |
34 | @@ -1,25 +1,136 @@ |
35 | -# charm-k8s-ingress |
36 | +# Ingress Operator |
37 | |
38 | ## Description |
39 | |
40 | -TODO: Describe your charm in a few paragraphs of Markdown |
41 | +This charm is intended to provide an ingress for sidecar charms using the |
42 | +Operator Framework until such time as Juju can expose the relevant primitives |
43 | +to enable charms to configure an ingress natively via Juju (e.g. with TLS as |
44 | +required, with session affinity as required, allowing for upload of a given |
45 | +size, etc.). |
46 | |
47 | ## Usage |
48 | |
49 | TODO: Provide high-level usage, such as required config or relations |
50 | |
51 | +## Running the charm locally |
52 | |
53 | -## Developing |
54 | +To build the charm, run `charmcraft build`. |
55 | |
56 | -Create and activate a virtualenv with the development requirements: |
57 | +You'll need version 1.14 or later of Go (`go version` will confirm your current version), and a custom version of the Juju 2.9 branch, as below: |
58 | |
59 | - virtualenv -p python3 venv |
60 | - source venv/bin/activate |
61 | - pip install -r requirements-dev.txt |
62 | +``` |
63 | +git clone -b demo-pebble https://github.com/benhoyt/juju |
64 | +cd juju |
65 | +make install |
66 | +make microk8s-operator-update # to make the microk8s image and push to Docker |
67 | +export PATH="/home/${USER}/go/bin:$PATH" |
68 | +juju bootstrap microk8s --no-gui |
69 | +juju add-model ingress-test |
70 | +``` |
71 | +Now from the directory where you've run `charmcraft build` as above: |
72 | +``` |
73 | +juju deploy ./k8s-ingress.charm --resource placeholder-image='google/pause' |
74 | +``` |
75 | +This is currently failing with the following error in `juju debug-log`. |
76 | +``` |
77 | +unit-k8s-ingress-0: 05:12:02 INFO juju.worker.uniter awaiting error resolution for "config-changed" hook |
78 | +unit-k8s-ingress-0: 05:12:03 ERROR unit.k8s-ingress/0.juju-log Uncaught exception while in charm code: |
79 | +Traceback (most recent call last): |
80 | + File "./src/charm.py", line 80, in <module> |
81 | + main(CharmK8SIngressCharm) |
82 | + File "/var/lib/juju/agents/unit-k8s-ingress-0/charm/venv/ops/main.py", line 402, in main |
83 | + _emit_charm_event(charm, dispatcher.event_name) |
84 | + File "/var/lib/juju/agents/unit-k8s-ingress-0/charm/venv/ops/main.py", line 140, in _emit_charm_event |
85 | + event_to_emit.emit(*args, **kwargs) |
86 | + File "/var/lib/juju/agents/unit-k8s-ingress-0/charm/venv/ops/framework.py", line 278, in emit |
87 | + framework._emit(event) |
88 | + File "/var/lib/juju/agents/unit-k8s-ingress-0/charm/venv/ops/framework.py", line 722, in _emit |
89 | + self._reemit(event_path) |
90 | + File "/var/lib/juju/agents/unit-k8s-ingress-0/charm/venv/ops/framework.py", line 767, in _reemit |
91 | + custom_handler(event) |
92 | + File "./src/charm.py", line 75, in _on_config_changed |
93 | + self._get_pods() |
94 | + File "./src/charm.py", line 63, in _get_pods |
95 | + self.k8s_auth() |
96 | + File "./src/charm.py", line 59, in k8s_auth |
97 | + kubernetes.config.load_incluster_config() |
98 | + File "/var/lib/juju/agents/unit-k8s-ingress-0/charm/venv/kubernetes/config/incluster_config.py", line 118, in load_incluster_config |
99 | + InClusterConfigLoader( |
100 | + File "/var/lib/juju/agents/unit-k8s-ingress-0/charm/venv/kubernetes/config/incluster_config.py", line 54, in load_and_set |
101 | + self._load_config() |
102 | + File "/var/lib/juju/agents/unit-k8s-ingress-0/charm/venv/kubernetes/config/incluster_config.py", line 73, in _load_config |
103 | + raise ConfigException("Service token file does not exists.") |
104 | +kubernetes.config.config_exception.ConfigException: Service token file does not exists. |
105 | +unit-k8s-ingress-0: 05:12:03 ERROR juju.worker.uniter.operation hook "config-changed" (via hook dispatching script: dispatch) failed: exit status 1 |
106 | +``` |
107 | +Per https://github.com/kubernetes-client/python/issues/1331 the file it's looking for is |
108 | +/var/run/secrets/kubernetes.io/serviceaccount/token, but this file isn't present in the |
109 | +'charm' container for the deployed instance. The file does exist in the model operator, however. |
110 | |
111 | -## Testing |
112 | +This has been filed as https://bugs.launchpad.net/juju/+bug/1920102. |
113 | + |
114 | +To work around this, we've added a `kube-config` config option. This should be the contents of your |
115 | +kubernetes client configuration. If you're using microk8s you can get this via `microk8s config`, |
116 | +and you'd then deploy the charm as follows: |
117 | +``` |
118 | +juju deploy ./k8s-ingress.charm --resource placeholder-image='google/pause' --config kube-config="$(microk8s config)" |
119 | +``` |
120 | +We've also added some initial config options to configure the ingress. As an |
121 | +example: |
122 | +``` |
123 | +juju config k8s-ingress service-namespace=ingress-test service-name=gunicorn service-port=80 service-hostname=foo.internal |
124 | +``` |
125 | +This will give you something like this (deployed alongside the non-sidecar |
126 | +version of the charm for comparison): |
127 | +``` |
128 | +mthaddon@tenaya:~/repos/charm-k8s-ingress/charm-k8s-ingress$ microk8s.kubectl get ingress --all-namespaces |
129 | +NAMESPACE NAME CLASS HOSTS ADDRESS PORTS AGE |
130 | +ingress-test gunicorn-old-ingress <none> foo.internal 80 27m |
131 | +ingress-test gunicorn-ingress <none> foo.internal 80 91s |
132 | +mthaddon@tenaya:~/repos/charm-k8s-ingress/charm-k8s-ingress$ juju status |
133 | +Model Controller Cloud/Region Version SLA Timestamp |
134 | +ingress-test microk8s-localhost microk8s/localhost 2.9-rc7 unsupported 18:12:17+01:00 |
135 | + |
136 | +App Version Status Scale Charm Store Channel Rev OS Address Message |
137 | +gunicorn active 1 gunicorn local 3 ubuntu |
138 | +gunicorn-old gunicorn-app:edge active 1 gunicorn local 1 kubernetes 10.152.183.69 |
139 | +k8s-ingress active 1 k8s-ingress local 3 ubuntu |
140 | |
141 | -The Python operator framework includes a very nice harness for testing |
142 | -operator behaviour without full deployment. Just `run_tests`: |
143 | +Unit Workload Agent Address Ports Message |
144 | +gunicorn-old/1* active idle 10.1.234.25 80/TCP |
145 | +gunicorn/0* active idle 10.1.234.21 |
146 | +k8s-ingress/0* active idle 10.1.234.28 |
147 | + |
148 | +mthaddon@tenaya:~/repos/charm-k8s-ingress/charm-k8s-ingress$ microk8s.kubectl describe ingress -n ingress-test gunicorn-ingress |
149 | +Name: gunicorn-ingress |
150 | +Namespace: ingress-test |
151 | +Address: |
152 | +Default backend: default-http-backend:80 (<error: endpoints "default-http-backend" not found>) |
153 | +Rules: |
154 | + Host Path Backends |
155 | + ---- ---- -------- |
156 | + foo.internal |
157 | + / gunicorn:80 () |
158 | +Annotations: nginx.ingress.kubernetes.io/rewrite-target: / |
159 | +Events: <none> |
160 | +mthaddon@tenaya:~/repos/charm-k8s-ingress/charm-k8s-ingress$ microk8s.kubectl describe ingress -n ingress-test gunicorn-old-ingress |
161 | +Name: gunicorn-old-ingress |
162 | +Namespace: ingress-test |
163 | +Address: |
164 | +Default backend: default-http-backend:80 (<error: endpoints "default-http-backend" not found>) |
165 | +Rules: |
166 | + Host Path Backends |
167 | + ---- ---- -------- |
168 | + foo.internal |
169 | + / gunicorn-old:80 (10.1.234.25:80) |
170 | +Annotations: controller.juju.is/id: ac6cacf4-3ed5-4313-88d2-89eef6ae5822 |
171 | + model.juju.is/id: 5cbdb63d-0847-4b92-8c4b-f4af185b60b0 |
172 | + nginx.ingress.kubernetes.io/ssl-redirect: false |
173 | +Events: <none> |
174 | +mthaddon@tenaya:~/repos/charm-k8s-ingress/charm-k8s-ingress$ |
175 | +``` |
176 | +This needs further work and testing, but produces an initial ingress. |
177 | + |
178 | +## Testing |
179 | |
180 | - ./run_tests |
181 | +Simply run `make test`. |
182 | diff --git a/config.yaml b/config.yaml |
183 | index 5323e7e..b2bd3c8 100644 |
184 | --- a/config.yaml |
185 | +++ b/config.yaml |
186 | @@ -1,6 +1,26 @@ |
187 | # Copyright 2021 Tom Haddon |
188 | # See LICENSE file for licensing details. |
189 | options: |
190 | + kube-config: |
191 | + default: "" |
192 | + description: k8s config. If you're using microk8s, this should be the output of `microk8s config`. |
193 | + type: string |
194 | + service-hostname: |
195 | + default: "" |
196 | + description: The hostname of the service to create an ingress for. |
197 | + type: string |
198 | + service-name: |
199 | + default: "" |
200 | + description: The name of the service to create an ingress for. |
201 | + type: string |
202 | + service-namespace: |
203 | + default: "" |
204 | + description: The namespace of the service to create an ingress for. |
205 | + type: string |
206 | + service-port: |
207 | + default: 0 |
208 | + description: The port of the service to create an ingress for. |
209 | + type: int |
210 | thing: |
211 | default: 🎁 |
212 | description: A thing used by the charm. |
213 | diff --git a/requirements.txt b/requirements.txt |
214 | index 2d81d3b..cd914f9 100644 |
215 | --- a/requirements.txt |
216 | +++ b/requirements.txt |
217 | @@ -1 +1,2 @@ |
218 | +kubernetes |
219 | ops |
220 | diff --git a/run_tests b/run_tests |
221 | deleted file mode 100755 |
222 | index 697472e..0000000 |
223 | --- a/run_tests |
224 | +++ /dev/null |
225 | @@ -1,17 +0,0 @@ |
226 | -#!/bin/sh -e |
227 | -# Copyright 2021 Tom Haddon |
228 | -# See LICENSE file for licensing details. |
229 | - |
230 | -if [ -z "$VIRTUAL_ENV" -a -d venv/ ]; then |
231 | - . venv/bin/activate |
232 | -fi |
233 | - |
234 | -if [ -z "$PYTHONPATH" ]; then |
235 | - export PYTHONPATH=src |
236 | -else |
237 | - export PYTHONPATH="src:$PYTHONPATH" |
238 | -fi |
239 | - |
240 | -flake8 |
241 | -coverage run --source=src -m unittest -v "$@" |
242 | -coverage report -m |
243 | diff --git a/src/charm.py b/src/charm.py |
244 | index ced295f..4b85b26 100755 |
245 | --- a/src/charm.py |
246 | +++ b/src/charm.py |
247 | @@ -11,6 +11,12 @@ develop a new k8s charm using the Operator Framework: |
248 | """ |
249 | |
250 | import logging |
251 | +import os |
252 | +from pathlib import Path |
253 | + |
254 | +import kubernetes |
255 | + |
256 | +# from kubernetes.client.rest import ApiException as K8sApiException |
257 | |
258 | from ops.charm import CharmBase |
259 | from ops.main import main |
260 | @@ -20,9 +26,29 @@ from ops.framework import StoredState |
261 | logger = logging.getLogger(__name__) |
262 | |
263 | |
264 | +def _core_v1_api(): |
265 | + """Use the v1 k8s API.""" |
266 | + cl = kubernetes.client.ApiClient() |
267 | + return kubernetes.client.CoreV1Api(cl) |
268 | + |
269 | + |
270 | +def _networking_v1_beta1_api(): |
271 | + """Use the v1 beta1 networking API.""" |
272 | + return kubernetes.client.NetworkingV1beta1Api() |
273 | + |
274 | + |
275 | +def _fix_lp_1892255(): |
276 | + """Workaround for lp:1892255.""" |
277 | + # Remove os.environ.update when lp:1892255 is FIX_RELEASED. |
278 | + os.environ.update( |
279 | + dict(e.split("=") for e in Path("/proc/1/environ").read_text().split("\x00") if "KUBERNETES_SERVICE" in e) |
280 | + ) |
281 | + |
282 | + |
283 | class CharmK8SIngressCharm(CharmBase): |
284 | """Charm the service.""" |
285 | |
286 | + _authed = False |
287 | _stored = StoredState() |
288 | |
289 | def __init__(self, *args): |
290 | @@ -30,13 +56,149 @@ class CharmK8SIngressCharm(CharmBase): |
291 | self.framework.observe(self.on.config_changed, self._on_config_changed) |
292 | self._stored.set_default(things=[]) |
293 | |
294 | + def k8s_auth(self): |
295 | + """Authenticate to kubernetes.""" |
296 | + if self._authed: |
297 | + return |
298 | + |
299 | + _fix_lp_1892255() |
300 | + |
301 | + # Work around for lp#1920102 - allow the user to pass in k8s config manually. |
302 | + if self.config["kube-config"]: |
303 | + with open('/kube-config', 'w') as kube_config: |
304 | + kube_config.write(self.config["kube-config"]) |
305 | + kubernetes.config.load_kube_config(config_file='/kube-config') |
306 | + else: |
307 | + kubernetes.config.load_incluster_config() |
308 | + |
309 | + self._authed = True |
310 | + |
311 | + def _define_service(self): |
312 | + """Create or update a service in kubernetes.""" |
313 | + self.k8s_auth() |
314 | + api = _core_v1_api() |
315 | + |
316 | + # Juju will create a service named the same as the application. We |
317 | + # need to use a different name to avoid collision. |
318 | + service_name = "{}-service".format(self.config["service-name"]) |
319 | + |
320 | + body = kubernetes.client.V1Service( |
321 | + api_version="v1", |
322 | + kind="Service", |
323 | + metadata=kubernetes.client.V1ObjectMeta(name=service_name), |
324 | + spec=kubernetes.client.V1ServiceSpec( |
325 | + selector={"app.kubernetes.io/name": self.config["service-name"]}, |
326 | + ports=[ |
327 | + kubernetes.client.V1ServicePort( |
328 | + name="tcp-{}".format(self.config["service-port"]), |
329 | + port=self.config["service-port"], |
330 | + target_port=self.config["service-port"], |
331 | + ) |
332 | + ], |
333 | + ), |
334 | + ) |
335 | + services = api.list_namespaced_service(namespace=self.config["service-namespace"]) |
336 | + if service_name in [x.metadata.name for x in services.items]: |
337 | + # Currently failing with port[1].name required but we're only |
338 | + # defining one port above... |
339 | + # api.patch_namespaced_service( |
340 | + # name=service_name, |
341 | + # namespace=self.config["service-namespace"], |
342 | + # body=body, |
343 | + # ) |
344 | + api.delete_namespaced_service( |
345 | + name=service_name, |
346 | + namespace=self.config["service-namespace"], |
347 | + ) |
348 | + api.create_namespaced_service( |
349 | + namespace=self.config["service-namespace"], |
350 | + body=body, |
351 | + ) |
352 | + logger.info( |
353 | + "Service updated in namespace %s with name %s", |
354 | + self.config["service-namespace"], |
355 | + self.config["service-name"], |
356 | + ) |
357 | + else: |
358 | + api.create_namespaced_service( |
359 | + namespace=self.config["service-namespace"], |
360 | + body=body, |
361 | + ) |
362 | + logger.info( |
363 | + "Service created in namespace %s with name %s", |
364 | + self.config["service-namespace"], |
365 | + self.config["service-name"], |
366 | + ) |
367 | + |
368 | + def _define_ingress(self): |
369 | + """Create or update an ingress in kubernetes.""" |
370 | + self.k8s_auth() |
371 | + api = _networking_v1_beta1_api() |
372 | + |
373 | + # Follow the same naming convention as Juju. |
374 | + ingress_name = "{}-ingress".format(self.config["service-name"]) |
375 | + |
376 | + body = kubernetes.client.NetworkingV1beta1Ingress( |
377 | + api_version="networking.k8s.io/v1beta1", |
378 | + kind="Ingress", |
379 | + metadata=kubernetes.client.V1ObjectMeta( |
380 | + name=ingress_name, |
381 | + annotations={ |
382 | + "nginx.ingress.kubernetes.io/rewrite-target": "/", |
383 | + "nginx.ingress.kubernetes.io/ssl-redirect": "false", |
384 | + }, |
385 | + ), |
386 | + spec=kubernetes.client.NetworkingV1beta1IngressSpec( |
387 | + rules=[ |
388 | + kubernetes.client.NetworkingV1beta1IngressRule( |
389 | + host=self.config["service-hostname"], |
390 | + http=kubernetes.client.NetworkingV1beta1HTTPIngressRuleValue( |
391 | + paths=[ |
392 | + kubernetes.client.NetworkingV1beta1HTTPIngressPath( |
393 | + path="/", |
394 | + backend=kubernetes.client.NetworkingV1beta1IngressBackend( |
395 | + service_port=self.config["service-port"], |
396 | + service_name="{}-service".format(self.config["service-name"]), |
397 | + ), |
398 | + ) |
399 | + ] |
400 | + ), |
401 | + ) |
402 | + ] |
403 | + ), |
404 | + ) |
405 | + ingresses = api.list_namespaced_ingress(namespace=self.config["service-namespace"]) |
406 | + if ingress_name in [x.metadata.name for x in ingresses.items]: |
407 | + api.patch_namespaced_ingress( |
408 | + name=ingress_name, |
409 | + namespace=self.config["service-namespace"], |
410 | + body=body, |
411 | + ) |
412 | + logger.info( |
413 | + "Ingress updated in namespace %s with name %s", |
414 | + self.config["service-namespace"], |
415 | + self.config["service-name"], |
416 | + ) |
417 | + else: |
418 | + api.create_namespaced_ingress( |
419 | + namespace=self.config["service-namespace"], |
420 | + body=body, |
421 | + ) |
422 | + logger.info( |
423 | + "Ingress created in namespace %s with name %s", |
424 | + self.config["service-namespace"], |
425 | + self.config["service-name"], |
426 | + ) |
427 | + |
428 | def _on_config_changed(self, _): |
429 | - # Note: you need to uncomment the example in the config.yaml file for this to work (ensure |
430 | - # to not just leave the example, but adapt to your configuration needs) |
431 | + """Handle the config changed event.""" |
432 | current = self.config["thing"] |
433 | if current not in self._stored.things: |
434 | logger.debug("found a new thing: %r", current) |
435 | self._stored.things.append(current) |
436 | + if self.config["service-name"]: |
437 | + self._define_service() |
438 | + self._define_ingress() |
439 | self.unit.status = ActiveStatus() |
440 | |
441 | |
442 | diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt |
443 | new file mode 100644 |
444 | index 0000000..65431fc |
445 | --- /dev/null |
446 | +++ b/tests/unit/requirements.txt |
447 | @@ -0,0 +1,4 @@ |
448 | +mock |
449 | +pytest |
450 | +pytest-cov |
451 | +pyyaml |
452 | diff --git a/tests/test_charm.py b/tests/unit/test_charm.py |
453 | similarity index 55% |
454 | rename from tests/test_charm.py |
455 | rename to tests/unit/test_charm.py |
456 | index eb459e0..861051d 100644 |
457 | --- a/tests/test_charm.py |
458 | +++ b/tests/unit/test_charm.py |
459 | @@ -1,35 +1,20 @@ |
460 | # Copyright 2021 Tom Haddon |
461 | # See LICENSE file for licensing details. |
462 | |
463 | +import mock |
464 | import unittest |
465 | -from unittest.mock import Mock |
466 | |
467 | from ops.testing import Harness |
468 | from charm import CharmK8SIngressCharm |
469 | |
470 | |
471 | class TestCharm(unittest.TestCase): |
472 | - def test_config_changed(self): |
473 | + @mock.patch('charm.CharmK8SIngressCharm._define_ingress') |
474 | + @mock.patch('charm.CharmK8SIngressCharm._define_service') |
475 | + def test_config_changed(self, _define_service, _define_ingress): |
476 | harness = Harness(CharmK8SIngressCharm) |
477 | self.addCleanup(harness.cleanup) |
478 | harness.begin() |
479 | self.assertEqual(list(harness.charm._stored.things), []) |
480 | harness.update_config({"thing": "foo"}) |
481 | self.assertEqual(list(harness.charm._stored.things), ["foo"]) |
482 | - |
483 | - def test_action(self): |
484 | - harness = Harness(CharmK8SIngressCharm) |
485 | - harness.begin() |
486 | - # the harness doesn't (yet!) help much with actions themselves |
487 | - action_event = Mock(params={"fail": ""}) |
488 | - harness.charm._on_fortune_action(action_event) |
489 | - |
490 | - self.assertTrue(action_event.set_results.called) |
491 | - |
492 | - def test_action_fail(self): |
493 | - harness = Harness(CharmK8SIngressCharm) |
494 | - harness.begin() |
495 | - action_event = Mock(params={"fail": "fail this"}) |
496 | - harness.charm._on_fortune_action(action_event) |
497 | - |
498 | - self.assertEqual(action_event.fail.call_args, [("fail this",)]) |
499 | diff --git a/tox.ini b/tox.ini |
500 | new file mode 100644 |
501 | index 0000000..066c5a8 |
502 | --- /dev/null |
503 | +++ b/tox.ini |
504 | @@ -0,0 +1,49 @@ |
505 | +[tox] |
506 | +skipsdist=True |
507 | +envlist = unit, functional |
508 | +skip_missing_interpreters = True |
509 | + |
510 | +[testenv] |
511 | +basepython = python3 |
512 | +setenv = |
513 | + PYTHONPATH = {toxinidir}/build/lib:{toxinidir}/build/venv |
514 | + |
515 | +[testenv:unit] |
516 | +commands = |
517 | + pytest --ignore mod --ignore {toxinidir}/tests/functional \ |
518 | + {posargs:-v --cov=src --cov-report=term-missing --cov-branch} |
519 | +deps = -r{toxinidir}/tests/unit/requirements.txt |
520 | + -r{toxinidir}/requirements.txt |
521 | +setenv = |
522 | + PYTHONPATH={toxinidir}/src:{toxinidir}/build/lib:{toxinidir}/build/venv |
523 | + TZ=UTC |
524 | + |
525 | +[testenv:functional] |
526 | +passenv = |
527 | + HOME |
528 | + JUJU_REPOSITORY |
529 | + PATH |
530 | +commands = |
531 | + pytest -v --ignore {toxinidir}/tests/unit {posargs} |
532 | +deps = -r{toxinidir}/tests/functional/requirements.txt |
533 | + -r{toxinidir}/requirements.txt |
534 | + |
535 | +[testenv:black] |
536 | +commands = black --skip-string-normalization --line-length=120 src/ tests/ |
537 | +deps = black |
538 | + |
539 | +[testenv:lint] |
540 | +commands = flake8 src/ tests/ |
541 | +# Pin flake8 to 3.7.9 to match focal |
542 | +deps = |
543 | + flake8==3.7.9 |
544 | + |
545 | +[flake8] |
546 | +exclude = |
547 | + .git, |
548 | + __pycache__, |
549 | + .tox, |
550 | +# Ignore E231 because using black creates errors with this |
551 | +ignore = E231 |
552 | +max-line-length = 120 |
553 | +max-complexity = 10 |
This merge proposal is being monitored by mergebot. Change the status to Approved to merge.