Merge ~mthaddon/charm-k8s-ingress/+git/charm-k8s-ingress:ingress-library into charm-k8s-ingress:master
- Git
- lp:~mthaddon/charm-k8s-ingress/+git/charm-k8s-ingress
- ingress-library
- Merge into master
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) |
Related bugs: |
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
Description of the change
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
🤖 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.
🤖 prod-jenkaas-is (prod-jenkaas-is) wrote : | # |
A CI job is currently in progress. A follow up comment will be added when it completes.
🤖 prod-jenkaas-is (prod-jenkaas-is) wrote : | # |
PASSED: Continuous integration, rev:8837b975391
https:/
Executed test runs:
SUCCESS: https:/
None: https:/
Click here to trigger a rebuild:
https:/
🤖 prod-jenkaas-is (prod-jenkaas-is) wrote : | # |
A CI job is currently in progress. A follow up comment will be added when it completes.
🤖 prod-jenkaas-is (prod-jenkaas-is) wrote : | # |
PASSED: Continuous integration, rev:2127ca8c173
https:/
Executed test runs:
SUCCESS: https:/
None: https:/
Click here to trigger a rebuild:
https:/
Stuart Bishop (stub) wrote : | # |
It would be possible to avoid needing to pass in any parameters to the IngressRequires
Now I think of it, you might be able to avoid the update method and needing a config-changed handler entirely. The IngressRequires
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.
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
Change successfully merged at revision 39fcba8217e5fb3
Preview Diff
1 | diff --git a/README.md b/README.md |
2 | index 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`. |
30 | diff --git a/lib/charms/ingress/v0/ingress.py b/lib/charms/ingress/v0/ingress.py |
31 | new file mode 100644 |
32 | index 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() |
199 | diff --git a/metadata.yaml b/metadata.yaml |
200 | index 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 |
212 | diff --git a/src/charm.py b/src/charm.py |
213 | index 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.""" |
302 | diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py |
303 | index 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( |
318 | diff --git a/tox.ini b/tox.ini |
319 | index 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 |
This merge proposal is being monitored by mergebot. Change the status to Approved to merge.