Merge ~mthaddon/charm-k8s-ingress/+git/charm-k8s-ingress:initial-k8s-api into charm-k8s-ingress: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)
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.

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
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 20535875cd9ac90ff7a7fa6ef41367e2272b597d

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/Makefile b/Makefile
2new file mode 100644
3index 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
30diff --git a/README.md b/README.md
31index 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`.
182diff --git a/config.yaml b/config.yaml
183index 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.
213diff --git a/requirements.txt b/requirements.txt
214index 2d81d3b..cd914f9 100644
215--- a/requirements.txt
216+++ b/requirements.txt
217@@ -1 +1,2 @@
218+kubernetes
219 ops
220diff --git a/run_tests b/run_tests
221deleted file mode 100755
222index 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
243diff --git a/src/charm.py b/src/charm.py
244index 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
442diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt
443new file mode 100644
444index 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
452diff --git a/tests/test_charm.py b/tests/unit/test_charm.py
453similarity index 55%
454rename from tests/test_charm.py
455rename to tests/unit/test_charm.py
456index 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",)])
499diff --git a/tox.ini b/tox.ini
500new file mode 100644
501index 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

Subscribers

People subscribed via source and target branches

to all changes: