Merge ~mthaddon/charm-k8s-ingress/+git/charm-k8s-ingress:ingress-library into charm-k8s-ingress:master

Proposed by Tom Haddon
Status: Merged
Approved by: Tom Haddon
Approved revision: 2127ca8c1738d2bd88ad00e97951f33833dd92b6
Merged at revision: 39fcba8217e5fb34c862e31a8ef12e772a83594f
Proposed branch: ~mthaddon/charm-k8s-ingress/+git/charm-k8s-ingress:ingress-library
Merge into: charm-k8s-ingress:master
Diff against target: 335 lines (+194/-53)
6 files modified
README.md (+6/-9)
lib/charms/ingress/v0/ingress.py (+163/-0)
metadata.yaml (+1/-1)
src/charm.py (+18/-40)
tests/unit/test_charm.py (+4/-1)
tox.ini (+2/-2)
Reviewer Review Type Date Requested Status
Stuart Bishop (community) Approve
🤖 prod-jenkaas-is (community) continuous-integration Approve
Review via email: mp+400564@code.launchpad.net

Commit message

Create a library for handling the ingress relation

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
🤖 prod-jenkaas-is (prod-jenkaas-is) wrote :

A CI job is currently in progress. A follow up comment will be added when it completes.

Revision history for this message
🤖 prod-jenkaas-is (prod-jenkaas-is) wrote :

PASSED: Continuous integration, rev:8837b9753918e265d6a36a8d5ca83242aa19ccc2
https://jenkins.canonical.com/is/job/lp-charm-k8s-ingress-ci/27/
Executed test runs:
    SUCCESS: https://jenkins.canonical.com/is/job/lp-charm-test/59/
    None: https://jenkins.canonical.com/is/job/lp-update-mp/65332/

Click here to trigger a rebuild:
https://jenkins.canonical.com/is/job/lp-charm-k8s-ingress-ci/27//rebuild

review: Approve (continuous-integration)
Revision history for this message
🤖 prod-jenkaas-is (prod-jenkaas-is) wrote :

A CI job is currently in progress. A follow up comment will be added when it completes.

Revision history for this message
🤖 prod-jenkaas-is (prod-jenkaas-is) wrote :

PASSED: Continuous integration, rev:2127ca8c1738d2bd88ad00e97951f33833dd92b6
https://jenkins.canonical.com/is/job/lp-charm-k8s-ingress-ci/28/
Executed test runs:
    SUCCESS: https://jenkins.canonical.com/is/job/lp-charm-test/60/
    None: https://jenkins.canonical.com/is/job/lp-update-mp/73324/

Click here to trigger a rebuild:
https://jenkins.canonical.com/is/job/lp-charm-k8s-ingress-ci/28//rebuild

review: Approve (continuous-integration)
Revision history for this message
Stuart Bishop (stub) wrote :

It would be possible to avoid needing to pass in any parameters to the IngressRequires.update method. The charm would pass in the correct parameters to the IngressRequires constructor, and update() would simply apply them to the relation. As it stands, the charm could pass in different settings and we end up with the object state not matching what is on the relation (but the charm shouldn't be doing that).

Now I think of it, you might be able to avoid the update method and needing a config-changed handler entirely. The IngressRequires.__init__ method could detect that its settings have changed (using state), and do the update there. However, I'm not entirely sure you can get away with it, since at this point the charm object is still being constructed and the operator framework might not be initialized enough.

The code is all looking great, and this can be landed. If you agree with the above, they can be addressed in a followup branch since this is not yet a published stable API.

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

Change successfully merged at revision 39fcba8217e5fb34c862e31a8ef12e772a83594f

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/README.md b/README.md
2index f98aad4..55b9e33 100644
3--- a/README.md
4+++ b/README.md
5@@ -40,18 +40,15 @@ juju relate ingress gunicorn
6 This will create an K8s ingress called `gunicorn-ingress` and a K8s service
7 called `gunicorn-service`. The gunicorn charm in question, which can be found
8 https://code.launchpad.net/~mthaddon/charm-k8s-gunicorn/+git/charm-k8s-gunicorn/+ref/pebble
9-implements the relation as follows, as a trivial example:
10+implements the relation using the ingress library, as a trivial example:
11 ```
12+from charms.ingress.v0.ingress import IngressRequires
13+
14 # In __init__:
15-self.framework.observe(self.on['ingress'].relation_changed, self._on_ingress_changed)
16+self.ingress = IngressRequires(self, self.config["external_hostname"], self.app.name, 80)
17
18-# And the _on_ingress_changed method.
19-def _on_ingress_changed(self, event: ops.framework.EventBase) -> None:
20- """Handle the ingress relation changed event."""
21- if self.unit.is_leader():
22- event.relation.data[self.app]["service-hostname"] = self.config["external_hostname"]
23- event.relation.data[self.app]["service-name"] = self.app.name
24- event.relation.data[self.app]["service-port"] = "80"
25+# In config-changed handler
26+self.ingress.update_config({"service_hostname": self.config["external_hostname"]})
27 ```
28 All of the config items in `config.yaml` with the exception of `kube-config` can
29 be set via the relation, e.g. `tls-secret-name` or `max-body-size`.
30diff --git a/lib/charms/ingress/v0/ingress.py b/lib/charms/ingress/v0/ingress.py
31new file mode 100644
32index 0000000..f4f2f27
33--- /dev/null
34+++ b/lib/charms/ingress/v0/ingress.py
35@@ -0,0 +1,163 @@
36+"""Library for the ingress relation.
37+
38+This library contains the Requires and Provides classes for handling
39+the ingress interface.
40+
41+Import `IngressRequires` in your charm, with required options:
42+ - "self" (the charm itself)
43+ - service_hostname
44+ - service_name
45+ - service_port
46+Optionally you can also pass:
47+ - max_body_size
48+ - service_namespace
49+ - session_cookie_max_age
50+ - tls_secret_name
51+
52+As an example:
53+```
54+from charms.ingress.v0.ingress import IngressRequires
55+
56+# In your charm's `__init__` method.
57+self.ingress = IngressRequires(self, self.config["external_hostname"], self.app.name, 80)
58+
59+# In your charm's `config-changed` handler.
60+self.ingress.update_config({"service_hostname": self.config["external_hostname"]})
61+```
62+"""
63+
64+import logging
65+
66+from ops.framework import EventBase, Object
67+from ops.model import BlockedStatus
68+
69+# The unique Charmhub library identifier, never change it
70+LIBID = "2d35a009b0d64fe186c99a8c9e53c6ab"
71+
72+# Increment this major API version when introducing breaking changes
73+LIBAPI = 0
74+
75+# Increment this PATCH version before using `charmcraft push-lib` or reset
76+# to 0 if you are raising the major API version
77+LIBPATCH = 4
78+
79+logger = logging.getLogger(__name__)
80+
81+REQUIRED_INGRESS_RELATION_FIELDS = {
82+ "service-hostname",
83+ "service-name",
84+ "service-port",
85+}
86+
87+OPTIONAL_INGRESS_RELATION_FIELDS = {
88+ "max-body-size",
89+ "service-namespace",
90+ "session-cookie-max-age",
91+ "tls-secret-name",
92+}
93+
94+
95+class IngressAvailableEvent(EventBase):
96+ pass
97+
98+
99+class IngressRequires(Object):
100+ """This class defines the functionality for the 'requires' side of the 'ingress' relation.
101+
102+ Hook events observed:
103+ - relation-changed
104+ """
105+
106+ def __init__(
107+ self,
108+ charm,
109+ service_hostname,
110+ service_name,
111+ service_port,
112+ *,
113+ max_body_size=0,
114+ service_namespace="",
115+ session_cookie_max_age=0,
116+ tls_secret_name=""
117+ ):
118+ super().__init__(charm, "ingress")
119+
120+ self.framework.observe(charm.on["ingress"].relation_changed, self._on_relation_changed)
121+ self.charm = charm
122+
123+ # Ingress properties - Required.
124+ self.service_hostname = service_hostname
125+ self.service_name = service_name
126+ self.service_port = service_port
127+
128+ # Ingress properties - Optional.
129+ self.max_body_size = max_body_size
130+ self.service_namespace = service_namespace
131+ self.session_cookie_max_age = session_cookie_max_age
132+ self.tls_secret_name = tls_secret_name
133+
134+ def _on_relation_changed(self, event):
135+ """Handle the relation-changed event."""
136+ # `self.unit` isn't available here, so use `self.model.unit`.
137+ if self.model.unit.is_leader():
138+ # Required.
139+ event.relation.data[self.model.app]["service-hostname"] = self.service_hostname
140+ event.relation.data[self.model.app]["service-name"] = self.service_name
141+ event.relation.data[self.model.app]["service-port"] = str(self.service_port)
142+ # Optional.
143+ if self.max_body_size:
144+ event.relation.data[self.model.app]["max-body-size"] = str(self.max_body_size)
145+ if self.service_namespace:
146+ event.relation.data[self.model.app]["service-namespace"] = self.service_namespace
147+ if self.session_cookie_max_age:
148+ event.relation.data[self.model.app]["session-cookie-max-age"] = self.session_cookie_max_age
149+ if self.tls_secret_name:
150+ event.relation.data[self.model.app]["tls-secret-name"] = self.tls_secret_name
151+
152+ def update_config(self, config_dict):
153+ """Allow for updates to relation."""
154+ if self.model.unit.is_leader():
155+ relation = self.model.get_relation("ingress")
156+ if relation:
157+ for key in config_dict:
158+ relation.data[self.model.app][key.replace("_", "-")] = config_dict[key]
159+
160+
161+class IngressProvides(Object):
162+ """This class defines the functionality for the 'provides' side of the 'ingress' relation.
163+
164+ Hook events observed:
165+ - relation-changed
166+ """
167+
168+ def __init__(self, charm):
169+ super().__init__(charm, "ingress")
170+ # Observe the relation-changed hook event and bind
171+ # self.on_relation_changed() to handle the event.
172+ self.framework.observe(charm.on["ingress"].relation_changed, self._on_relation_changed)
173+ self.charm = charm
174+
175+ def _on_relation_changed(self, event):
176+ """Handle a change to the ingress relation.
177+
178+ Confirm we have the fields we expect to receive."""
179+ # `self.unit` isn't available here, so use `self.model.unit`.
180+ if not self.model.unit.is_leader():
181+ return
182+
183+ ingress_data = {
184+ field: event.relation.data[event.app].get(field)
185+ for field in REQUIRED_INGRESS_RELATION_FIELDS | OPTIONAL_INGRESS_RELATION_FIELDS
186+ }
187+
188+ missing_fields = sorted(
189+ [field for field in REQUIRED_INGRESS_RELATION_FIELDS if ingress_data.get(field) is None]
190+ )
191+
192+ if missing_fields:
193+ logger.error("Missing required data fields for ingress relation: {}".format(", ".join(missing_fields)))
194+ self.model.unit.status = BlockedStatus("Missing fields for ingress: {}".format(", ".join(missing_fields)))
195+
196+ # Create an event that our charm can use to decide it's okay to
197+ # configure the ingress.
198+ self.charm.on.ingress_available.emit()
199diff --git a/metadata.yaml b/metadata.yaml
200index b6d24d1..34313d9 100644
201--- a/metadata.yaml
202+++ b/metadata.yaml
203@@ -18,7 +18,7 @@ resources:
204 type: oci-image
205 description: A placeholder image, operator code won't do anything with it.
206
207-requires:
208+provides:
209 ingress:
210 interface: ingress
211 limit: 1
212diff --git a/src/charm.py b/src/charm.py
213index f2aaaea..c7cf6be 100755
214--- a/src/charm.py
215+++ b/src/charm.py
216@@ -8,29 +8,18 @@ from pathlib import Path
217
218 import kubernetes
219
220-from ops.charm import CharmBase
221-from ops.main import main
222-from ops.model import (
223- ActiveStatus,
224- BlockedStatus,
225+from charms.ingress.v0.ingress import (
226+ IngressAvailableEvent,
227+ IngressProvides,
228 )
229+from ops.charm import CharmBase, CharmEvents
230+from ops.framework import EventSource
231+from ops.main import main
232+from ops.model import ActiveStatus
233
234
235 logger = logging.getLogger(__name__)
236
237-REQUIRED_INGRESS_RELATION_FIELDS = {
238- "service-hostname",
239- "service-name",
240- "service-port",
241-}
242-
243-OPTIONAL_INGRESS_RELATION_FIELDS = {
244- "max-body-size",
245- "service-namespace",
246- "session-cookie-max-age",
247- "tls-secret-name",
248-}
249-
250
251 def _core_v1_api():
252 """Use the v1 k8s API."""
253@@ -51,37 +40,26 @@ def _fix_lp_1892255():
254 )
255
256
257+class IngressCharmEvents(CharmEvents):
258+ """Custom charm events."""
259+
260+ ingress_available = EventSource(IngressAvailableEvent)
261+
262+
263 class IngressCharm(CharmBase):
264 """Charm the service."""
265
266 _authed = False
267+ on = IngressCharmEvents()
268
269 def __init__(self, *args):
270 super().__init__(*args)
271 self.framework.observe(self.on.config_changed, self._on_config_changed)
272
273- # ingress relation handling.
274- self.framework.observe(self.on["ingress"].relation_changed, self._on_ingress_relation_changed)
275-
276- def _on_ingress_relation_changed(self, event):
277- """Handle a change to the ingress relation.
278-
279- Confirm we have the fields we expect to receive."""
280- if not self.unit.is_leader():
281- return
282-
283- ingress_data = {
284- field: event.relation.data[event.app].get(field)
285- for field in REQUIRED_INGRESS_RELATION_FIELDS | OPTIONAL_INGRESS_RELATION_FIELDS
286- }
287-
288- missing_fields = sorted(
289- [field for field in REQUIRED_INGRESS_RELATION_FIELDS if ingress_data.get(field) is None]
290- )
291-
292- if missing_fields:
293- logger.error("Missing required data fields for ingress relation: {}".format(", ".join(missing_fields)))
294- self.unit.status = BlockedStatus("Missing fields for ingress: {}".format(", ".join(missing_fields)))
295+ # 'ingress' relation handling.
296+ self.ingress = IngressProvides(self)
297+ # When the 'ingress' is ready to configure, do so.
298+ self.framework.observe(self.on.ingress_available, self._on_config_changed)
299
300 def _get_config_or_relation_data(self, field, fallback):
301 """Helper method to get data from config or the ingress relation."""
302diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py
303index 45a9b09..4075546 100644
304--- a/tests/unit/test_charm.py
305+++ b/tests/unit/test_charm.py
306@@ -231,7 +231,10 @@ class TestCharm(unittest.TestCase):
307 }
308 with self.assertLogs(level="ERROR") as logger:
309 self.harness.update_relation_data(relation_id, 'gunicorn', relations_data)
310- msg = "ERROR:charm:Missing required data fields for ingress relation: service-hostname, service-port"
311+ msg = (
312+ "ERROR:charms.ingress.v0.ingress:Missing required data fields for "
313+ "ingress relation: service-hostname, service-port"
314+ )
315 self.assertEqual(sorted(logger.output), [msg])
316 # Confirm blocked status.
317 self.assertEqual(
318diff --git a/tox.ini b/tox.ini
319index b4c85dc..7a39457 100644
320--- a/tox.ini
321+++ b/tox.ini
322@@ -29,11 +29,11 @@ deps = -r{toxinidir}/tests/functional/requirements.txt
323 -r{toxinidir}/requirements.txt
324
325 [testenv:black]
326-commands = black --skip-string-normalization --line-length=120 src/ tests/
327+commands = black --skip-string-normalization --line-length=120 src/ tests/ lib/
328 deps = black
329
330 [testenv:lint]
331-commands = flake8 src/ tests/
332+commands = flake8 src/ tests/ lib/
333 # Pin flake8 to 3.7.9 to match focal
334 deps =
335 flake8==3.7.9

Subscribers

People subscribed via source and target branches

to all changes: