Merge ~barryprice/charm-k8s-wordpress/+git/charm-k8s-wordpress:operator into charm-k8s-wordpress:master
- Git
- lp:~barryprice/charm-k8s-wordpress/+git/charm-k8s-wordpress
- operator
- Merge into master
Proposed by
Barry Price
Status: | Merged |
---|---|
Merged at revision: | 5d73401594539e7abca5cfebbd38a1e9e2883c4a |
Proposed branch: | ~barryprice/charm-k8s-wordpress/+git/charm-k8s-wordpress:operator |
Merge into: | charm-k8s-wordpress:master |
Diff against target: |
778 lines (+307/-78) 10 files modified
.gitignore (+1/-0) .gitmodules (+3/-0) Makefile (+2/-9) dev/null (+0/-1) hooks/start (+1/-0) lib/ops (+1/-0) mod/operator (+1/-0) src/charm.py (+294/-0) tests/unit/test_wordpress.py (+0/-64) tox.ini (+4/-4) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Wordpress Charmers | Pending | ||
Review via email: mp+381231@code.launchpad.net |
Commit message
Porting to Operator (WIP)
Description of the change
To post a comment you must log in.
Revision history for this message
Tom Haddon (mthaddon) wrote : | # |
Revision history for this message
Barry Price (barryprice) wrote : | # |
Thanks, left some responses and will push fixes for these as well as others.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/.gitignore b/.gitignore | |||
2 | 0 | new file mode 100644 | 0 | new file mode 100644 |
3 | index 0000000..172bf57 | |||
4 | --- /dev/null | |||
5 | +++ b/.gitignore | |||
6 | @@ -0,0 +1 @@ | |||
7 | 1 | .tox | ||
8 | diff --git a/.gitmodules b/.gitmodules | |||
9 | 0 | new file mode 100644 | 2 | new file mode 100644 |
10 | index 0000000..8c05fa9 | |||
11 | --- /dev/null | |||
12 | +++ b/.gitmodules | |||
13 | @@ -0,0 +1,3 @@ | |||
14 | 1 | [submodule "mod/operator"] | ||
15 | 2 | path = mod/operator | ||
16 | 3 | url = https://github.com/canonical/operator | ||
17 | diff --git a/Makefile b/Makefile | |||
18 | index a46c2e2..cf18918 100644 | |||
19 | --- a/Makefile | |||
20 | +++ b/Makefile | |||
21 | @@ -9,15 +9,8 @@ unittest: | |||
22 | 9 | 9 | ||
23 | 10 | test: lint unittest | 10 | test: lint unittest |
24 | 11 | 11 | ||
25 | 12 | build: lint | ||
26 | 13 | charm build | ||
27 | 14 | |||
28 | 15 | clean: | 12 | clean: |
29 | 16 | @echo "Cleaning files" | 13 | @echo "Cleaning files" |
35 | 17 | @rm -rf ./.tox | 14 | @git clean -fXd |
31 | 18 | @rm -rf ./.pytest_cache | ||
32 | 19 | @rm -rf ./tests/unit/__pycache__ ./reactive/__pycache__ ./lib/__pycache__ | ||
33 | 20 | @rm -rf ./.coverage ./.unit-state.db | ||
34 | 21 | |||
36 | 22 | 15 | ||
38 | 23 | .PHONY: lint test unittest build clean | 16 | .PHONY: lint test unittest clean |
39 | diff --git a/hooks/start b/hooks/start | |||
40 | 24 | new file mode 120000 | 17 | new file mode 120000 |
41 | index 0000000..25b1f68 | |||
42 | --- /dev/null | |||
43 | +++ b/hooks/start | |||
44 | @@ -0,0 +1 @@ | |||
45 | 1 | ../src/charm.py | ||
46 | 0 | \ No newline at end of file | 2 | \ No newline at end of file |
47 | diff --git a/layer.yaml b/layer.yaml | |||
48 | 1 | deleted file mode 100644 | 3 | deleted file mode 100644 |
49 | index d7700f7..0000000 | |||
50 | --- a/layer.yaml | |||
51 | +++ /dev/null | |||
52 | @@ -1,4 +0,0 @@ | |||
53 | 1 | includes: | ||
54 | 2 | - 'layer:caas-base' | ||
55 | 3 | - 'layer:status' | ||
56 | 4 | repo: git+ssh://git.launchpad.net/charm-k8s-wordpress | ||
57 | diff --git a/lib/ops b/lib/ops | |||
58 | 5 | new file mode 120000 | 0 | new file mode 120000 |
59 | index 0000000..d934193 | |||
60 | --- /dev/null | |||
61 | +++ b/lib/ops | |||
62 | @@ -0,0 +1 @@ | |||
63 | 1 | ../mod/operator/ops | ||
64 | 0 | \ No newline at end of file | 2 | \ No newline at end of file |
65 | diff --git a/mod/operator b/mod/operator | |||
66 | 1 | new file mode 160000 | 3 | new file mode 160000 |
67 | index 0000000..44dff93 | |||
68 | --- /dev/null | |||
69 | +++ b/mod/operator | |||
70 | @@ -0,0 +1 @@ | |||
71 | 1 | Subproject commit 44dff930667aa8e9b179c11fa87ceb8c9b85ec5a | ||
72 | diff --git a/reactive/wordpress.py b/reactive/wordpress.py | |||
73 | 0 | deleted file mode 100644 | 2 | deleted file mode 100644 |
74 | index 9a1b013..0000000 | |||
75 | --- a/reactive/wordpress.py | |||
76 | +++ /dev/null | |||
77 | @@ -1,292 +0,0 @@ | |||
78 | 1 | import io | ||
79 | 2 | import os | ||
80 | 3 | import re | ||
81 | 4 | import requests | ||
82 | 5 | from pprint import pprint | ||
83 | 6 | from urllib.parse import urlparse, urlunparse | ||
84 | 7 | from yaml import safe_load | ||
85 | 8 | |||
86 | 9 | from charmhelpers.core import host, hookenv | ||
87 | 10 | from charms import reactive | ||
88 | 11 | from charms.layer import caas_base, status | ||
89 | 12 | from charms.reactive import hook, when, when_not | ||
90 | 13 | |||
91 | 14 | |||
92 | 15 | @hook("upgrade-charm") | ||
93 | 16 | def upgrade_charm(): | ||
94 | 17 | status.maintenance("Upgrading charm") | ||
95 | 18 | reactive.clear_flag("wordpress.configured") | ||
96 | 19 | |||
97 | 20 | |||
98 | 21 | @when("config.changed") | ||
99 | 22 | def reconfig(): | ||
100 | 23 | status.maintenance("charm configuration changed") | ||
101 | 24 | reactive.clear_flag("wordpress.configured") | ||
102 | 25 | |||
103 | 26 | # Validate config | ||
104 | 27 | valid = True | ||
105 | 28 | config = hookenv.config() | ||
106 | 29 | # Ensure required strings | ||
107 | 30 | for k in ["image", "db_host", "db_name", "db_user", "db_password"]: | ||
108 | 31 | if config[k].strip() == "": | ||
109 | 32 | status.blocked("{!r} config is required".format(k)) | ||
110 | 33 | valid = False | ||
111 | 34 | |||
112 | 35 | reactive.toggle_flag("wordpress.config.valid", valid) | ||
113 | 36 | |||
114 | 37 | |||
115 | 38 | @when("wordpress.config.valid") | ||
116 | 39 | @when_not("wordpress.configured") | ||
117 | 40 | def deploy_container(): | ||
118 | 41 | spec = make_pod_spec() | ||
119 | 42 | if spec is None: | ||
120 | 43 | return # Status already set | ||
121 | 44 | if reactive.data_changed("wordpress.spec", spec): | ||
122 | 45 | status.maintenance("configuring container") | ||
123 | 46 | try: | ||
124 | 47 | caas_base.pod_spec_set(spec) | ||
125 | 48 | except Exception as e: | ||
126 | 49 | hookenv.log("pod_spec_set failed: {}".format(e), hookenv.DEBUG) | ||
127 | 50 | status.blocked("pod_spec_set failed! Check logs and k8s dashboard.") | ||
128 | 51 | return | ||
129 | 52 | else: | ||
130 | 53 | hookenv.log("No changes to pod spec") | ||
131 | 54 | if first_install(): | ||
132 | 55 | reactive.set_flag("wordpress.configured") | ||
133 | 56 | |||
134 | 57 | |||
135 | 58 | @when("wordpress.configured") | ||
136 | 59 | def ready(): | ||
137 | 60 | status.active("Ready") | ||
138 | 61 | |||
139 | 62 | |||
140 | 63 | def sanitized_container_config(): | ||
141 | 64 | """Container config without secrets""" | ||
142 | 65 | config = hookenv.config() | ||
143 | 66 | if config["container_config"].strip() == "": | ||
144 | 67 | container_config = {} | ||
145 | 68 | else: | ||
146 | 69 | container_config = safe_load(config["container_config"]) | ||
147 | 70 | if not isinstance(container_config, dict): | ||
148 | 71 | status.blocked("container_config is not a YAML mapping") | ||
149 | 72 | return None | ||
150 | 73 | container_config["WORDPRESS_DB_HOST"] = config["db_host"] | ||
151 | 74 | container_config["WORDPRESS_DB_NAME"] = config["db_name"] | ||
152 | 75 | container_config["WORDPRESS_DB_USER"] = config["db_user"] | ||
153 | 76 | if config.get("wp_plugin_openid_team_map"): | ||
154 | 77 | container_config["WP_PLUGIN_OPENID_TEAM_MAP"] = config["wp_plugin_openid_team_map"] | ||
155 | 78 | return container_config | ||
156 | 79 | |||
157 | 80 | |||
158 | 81 | def full_container_config(): | ||
159 | 82 | """Container config with secrets""" | ||
160 | 83 | config = hookenv.config() | ||
161 | 84 | container_config = sanitized_container_config() | ||
162 | 85 | if container_config is None: | ||
163 | 86 | return None | ||
164 | 87 | if config["container_secrets"].strip() == "": | ||
165 | 88 | container_secrets = {} | ||
166 | 89 | else: | ||
167 | 90 | container_secrets = safe_load(config["container_secrets"]) | ||
168 | 91 | if not isinstance(container_secrets, dict): | ||
169 | 92 | status.blocked("container_secrets is not a YAML mapping") | ||
170 | 93 | return None | ||
171 | 94 | container_config.update(container_secrets) | ||
172 | 95 | # Add secrets from charm config | ||
173 | 96 | container_config["WORDPRESS_DB_PASSWORD"] = config["db_password"] | ||
174 | 97 | if config.get("wp_plugin_akismet_key"): | ||
175 | 98 | container_config["WP_PLUGIN_AKISMET_KEY"] = config["wp_plugin_akismet_key"] | ||
176 | 99 | return container_config | ||
177 | 100 | |||
178 | 101 | |||
179 | 102 | def make_pod_spec(): | ||
180 | 103 | config = hookenv.config() | ||
181 | 104 | container_config = sanitized_container_config() | ||
182 | 105 | if container_config is None: | ||
183 | 106 | return # Status already set | ||
184 | 107 | |||
185 | 108 | ports = [ | ||
186 | 109 | {"name": name, "containerPort": int(port), "protocol": "TCP"} | ||
187 | 110 | for name, port in [addr.split(":", 1) for addr in config["ports"].split()] | ||
188 | 111 | ] | ||
189 | 112 | |||
190 | 113 | # PodSpec v1? https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.13/#podspec-v1-core | ||
191 | 114 | spec = { | ||
192 | 115 | "containers": [ | ||
193 | 116 | { | ||
194 | 117 | "name": hookenv.charm_name(), | ||
195 | 118 | "imageDetails": {"imagePath": config["image"]}, | ||
196 | 119 | "ports": ports, | ||
197 | 120 | "config": container_config, | ||
198 | 121 | } | ||
199 | 122 | ] | ||
200 | 123 | } | ||
201 | 124 | out = io.StringIO() | ||
202 | 125 | pprint(spec, out) | ||
203 | 126 | hookenv.log("Container environment config (sans secrets) <<EOM\n{}\nEOM".format(out.getvalue())) | ||
204 | 127 | |||
205 | 128 | # If we need credentials (secrets) for our image, add them to the spec after logging | ||
206 | 129 | if config.get("image_user") and config.get("image_pass"): | ||
207 | 130 | spec.get("containers")[0].get("imageDetails")["username"] = config["image_user"] | ||
208 | 131 | spec.get("containers")[0].get("imageDetails")["password"] = config["image_pass"] | ||
209 | 132 | |||
210 | 133 | config_with_secrets = full_container_config() | ||
211 | 134 | if config_with_secrets is None: | ||
212 | 135 | return None # Status already set | ||
213 | 136 | container_config.update(config_with_secrets) | ||
214 | 137 | |||
215 | 138 | return spec | ||
216 | 139 | |||
217 | 140 | |||
218 | 141 | def first_install(): | ||
219 | 142 | """Perform initial configuration of wordpress if needed.""" | ||
220 | 143 | config = hookenv.config() | ||
221 | 144 | if not is_pod_up("website"): | ||
222 | 145 | hookenv.log("Pod not yet ready - retrying") | ||
223 | 146 | return False | ||
224 | 147 | elif not is_vhost_ready(): | ||
225 | 148 | hookenv.log("Wordpress vhost is not yet listening - retrying") | ||
226 | 149 | return False | ||
227 | 150 | elif wordpress_configured() or not config["initial_settings"]: | ||
228 | 151 | hookenv.log("No initial_setting provided or wordpress already configured. Skipping first install.") | ||
229 | 152 | return True | ||
230 | 153 | hookenv.log("Starting wordpress initial configuration") | ||
231 | 154 | payload = { | ||
232 | 155 | "admin_password": host.pwgen(24), | ||
233 | 156 | "blog_public": "checked", | ||
234 | 157 | "Submit": "submit", | ||
235 | 158 | } | ||
236 | 159 | payload.update(safe_load(config["initial_settings"])) | ||
237 | 160 | payload["admin_password2"] = payload["admin_password"] | ||
238 | 161 | if not payload["blog_public"]: | ||
239 | 162 | payload["blog_public"] = "unchecked" | ||
240 | 163 | required_config = set(("user_name", "admin_email")) | ||
241 | 164 | missing = required_config.difference(payload.keys()) | ||
242 | 165 | if missing: | ||
243 | 166 | hookenv.log("Error: missing wordpress settings: {}".format(missing)) | ||
244 | 167 | return False | ||
245 | 168 | call_wordpress("/wp-admin/install.php?step=2", redirects=True, payload=payload) | ||
246 | 169 | host.write_file(os.path.join("/root/", "initial.passwd"), payload["admin_password"], perms=0o400) | ||
247 | 170 | return True | ||
248 | 171 | |||
249 | 172 | |||
250 | 173 | def call_wordpress(uri, redirects=True, payload={}, _depth=1): | ||
251 | 174 | max_depth = 10 | ||
252 | 175 | if _depth > max_depth: | ||
253 | 176 | hookenv.log("Redirect loop detected in call_worpress()") | ||
254 | 177 | raise RuntimeError("Redirect loop detected in call_worpress()") | ||
255 | 178 | config = hookenv.config() | ||
256 | 179 | service_ip = get_service_ip("website") | ||
257 | 180 | if service_ip: | ||
258 | 181 | headers = {"Host": config["blog_hostname"]} | ||
259 | 182 | url = urlunparse(("http", service_ip, uri, "", "", "")) | ||
260 | 183 | if payload: | ||
261 | 184 | r = requests.post(url, allow_redirects=False, headers=headers, data=payload, timeout=30) | ||
262 | 185 | else: | ||
263 | 186 | r = requests.get(url, allow_redirects=False, headers=headers, timeout=30) | ||
264 | 187 | if redirects and r.is_redirect: | ||
265 | 188 | # Recurse, but strip the scheme and host first, we need to connect over HTTP by bare IP | ||
266 | 189 | o = urlparse(r.headers.get("Location")) | ||
267 | 190 | return call_wordpress(o.path, redirects=redirects, payload=payload, _depth=_depth + 1) | ||
268 | 191 | else: | ||
269 | 192 | return r | ||
270 | 193 | else: | ||
271 | 194 | hookenv.log("Error getting service IP") | ||
272 | 195 | return False | ||
273 | 196 | |||
274 | 197 | |||
275 | 198 | def wordpress_configured(): | ||
276 | 199 | """Check whether first install has been completed.""" | ||
277 | 200 | # Check whether pod is deployed | ||
278 | 201 | if not is_pod_up("website"): | ||
279 | 202 | return False | ||
280 | 203 | # Check if we have WP code deployed at all | ||
281 | 204 | if not is_vhost_ready(): | ||
282 | 205 | return False | ||
283 | 206 | # We have code on disk, check if configured | ||
284 | 207 | try: | ||
285 | 208 | r = call_wordpress("/", redirects=False) | ||
286 | 209 | except requests.exceptions.ConnectionError: | ||
287 | 210 | return False | ||
288 | 211 | if r.status_code == 302 and re.match("^.*/wp-admin/install.php", r.headers.get("location", "")): | ||
289 | 212 | return False | ||
290 | 213 | elif r.status_code == 302 and re.match("^.*/wp-admin/setup-config.php", r.headers.get("location", "")): | ||
291 | 214 | hookenv.log("MySQL database setup failed, we likely have no wp-config.php") | ||
292 | 215 | status.blocked("MySQL database setup failed, we likely have no wp-config.php") | ||
293 | 216 | return False | ||
294 | 217 | else: | ||
295 | 218 | return True | ||
296 | 219 | |||
297 | 220 | |||
298 | 221 | def is_vhost_ready(): | ||
299 | 222 | """Check whether wordpress is available using http.""" | ||
300 | 223 | # Check if we have WP code deployed at all | ||
301 | 224 | try: | ||
302 | 225 | r = call_wordpress("/wp-login.php", redirects=False) | ||
303 | 226 | except requests.exceptions.ConnectionError: | ||
304 | 227 | hookenv.log("call_wordpress() returned requests.exceptions.ConnectionError") | ||
305 | 228 | return False | ||
306 | 229 | if r is None: | ||
307 | 230 | hookenv.log("call_wordpress() returned None") | ||
308 | 231 | return False | ||
309 | 232 | if hasattr(r, "status_code") and r.status_code in (403, 404): | ||
310 | 233 | hookenv.log("call_wordpress() returned status {}".format(r.status_code)) | ||
311 | 234 | return False | ||
312 | 235 | else: | ||
313 | 236 | return True | ||
314 | 237 | |||
315 | 238 | |||
316 | 239 | def get_service_ip(endpoint): | ||
317 | 240 | try: | ||
318 | 241 | info = hookenv.network_get(endpoint, hookenv.relation_id()) | ||
319 | 242 | if "ingress-addresses" in info: | ||
320 | 243 | addr = info["ingress-addresses"][0] | ||
321 | 244 | if len(addr): | ||
322 | 245 | return addr | ||
323 | 246 | else: | ||
324 | 247 | hookenv.log("No ingress-addresses: {}".format(info)) | ||
325 | 248 | except Exception as e: | ||
326 | 249 | hookenv.log("Caught exception checking for service IP: {}".format(e)) | ||
327 | 250 | |||
328 | 251 | return None | ||
329 | 252 | |||
330 | 253 | |||
331 | 254 | def is_pod_up(endpoint): | ||
332 | 255 | """Check to see if the pod of a relation is up. | ||
333 | 256 | |||
334 | 257 | application-vimdb: 19:29:10 INFO unit.vimdb/0.juju-log network info | ||
335 | 258 | |||
336 | 259 | In the example below: | ||
337 | 260 | - 10.1.1.105 is the address of the application pod. | ||
338 | 261 | - 10.152.183.199 is the service cluster ip | ||
339 | 262 | |||
340 | 263 | { | ||
341 | 264 | 'bind-addresses': [{ | ||
342 | 265 | 'macaddress': '', | ||
343 | 266 | 'interfacename': '', | ||
344 | 267 | 'addresses': [{ | ||
345 | 268 | 'hostname': '', | ||
346 | 269 | 'address': '10.1.1.105', | ||
347 | 270 | 'cidr': '' | ||
348 | 271 | }] | ||
349 | 272 | }], | ||
350 | 273 | 'egress-subnets': [ | ||
351 | 274 | '10.152.183.199/32' | ||
352 | 275 | ], | ||
353 | 276 | 'ingress-addresses': [ | ||
354 | 277 | '10.152.183.199', | ||
355 | 278 | '10.1.1.105' | ||
356 | 279 | ] | ||
357 | 280 | } | ||
358 | 281 | """ | ||
359 | 282 | try: | ||
360 | 283 | info = hookenv.network_get(endpoint, hookenv.relation_id()) | ||
361 | 284 | |||
362 | 285 | # Check to see if the pod has been assigned its internal and external ips | ||
363 | 286 | for ingress in info["ingress-addresses"]: | ||
364 | 287 | if len(ingress) == 0: | ||
365 | 288 | return False | ||
366 | 289 | except Exception: | ||
367 | 290 | return False | ||
368 | 291 | |||
369 | 292 | return True | ||
370 | diff --git a/src/charm.py b/src/charm.py | |||
371 | 293 | new file mode 100755 | 0 | new file mode 100755 |
372 | index 0000000..a19bc28 | |||
373 | --- /dev/null | |||
374 | +++ b/src/charm.py | |||
375 | @@ -0,0 +1,294 @@ | |||
376 | 1 | #!/usr/bin/env python3 | ||
377 | 2 | |||
378 | 3 | import io | ||
379 | 4 | import re | ||
380 | 5 | import subprocess | ||
381 | 6 | import sys | ||
382 | 7 | from pprint import pprint | ||
383 | 8 | from time import sleep | ||
384 | 9 | from urllib.parse import urlparse, urlunparse | ||
385 | 10 | from yaml import safe_load | ||
386 | 11 | |||
387 | 12 | sys.path.append("lib") | ||
388 | 13 | |||
389 | 14 | from ops.charm import CharmBase # NoQA: E402 | ||
390 | 15 | from ops.framework import StoredState # NoQA: E402 | ||
391 | 16 | from ops.main import main # NoQA: E402 | ||
392 | 17 | from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus # NoQA: E402 | ||
393 | 18 | |||
394 | 19 | import logging # NoQA: E402 | ||
395 | 20 | |||
396 | 21 | logger = logging.getLogger() | ||
397 | 22 | |||
398 | 23 | |||
399 | 24 | class Wordpress(CharmBase): | ||
400 | 25 | state = StoredState() | ||
401 | 26 | |||
402 | 27 | def __init__(self, *args): | ||
403 | 28 | super().__init__(*args) | ||
404 | 29 | for event in ( | ||
405 | 30 | self.on.start, | ||
406 | 31 | self.on.config_changed, | ||
407 | 32 | ): | ||
408 | 33 | self.framework.observe(event, self) | ||
409 | 34 | |||
410 | 35 | def on_start(self, event): | ||
411 | 36 | logger.info("Here we go...") | ||
412 | 37 | # There may be a nicer way to do this! | ||
413 | 38 | # https://github.com/canonical/operator/issues/156 | ||
414 | 39 | subprocess.check_call(['apt-get', 'update']) | ||
415 | 40 | subprocess.check_call(['apt-get', '-y', 'install', 'python3-requests']) | ||
416 | 41 | # Initialise states | ||
417 | 42 | self.state._started = True | ||
418 | 43 | self.state._configured = False | ||
419 | 44 | self.state._valid = False | ||
420 | 45 | |||
421 | 46 | def on_config_changed(self, event): | ||
422 | 47 | valid = True | ||
423 | 48 | config = self.model.config | ||
424 | 49 | for k in ["image", "db_host", "db_name", "db_user", "db_password"]: | ||
425 | 50 | if config[k].strip() == "": | ||
426 | 51 | self.model.unit.status = BlockedStatus("{!r} config is required".format(k)) | ||
427 | 52 | valid = False | ||
428 | 53 | self.state._valid = valid | ||
429 | 54 | self.configure_pod(event) | ||
430 | 55 | |||
431 | 56 | def sanitized_container_config(self): | ||
432 | 57 | """Container config without secrets""" | ||
433 | 58 | config = self.model.config | ||
434 | 59 | if config["container_config"].strip() == "": | ||
435 | 60 | container_config = {} | ||
436 | 61 | else: | ||
437 | 62 | container_config = safe_load(config["container_config"]) | ||
438 | 63 | if not isinstance(container_config, dict): | ||
439 | 64 | self.model.unit.status = BlockedStatus("container_config is not a YAML mapping") | ||
440 | 65 | return None | ||
441 | 66 | container_config["WORDPRESS_DB_HOST"] = config["db_host"] | ||
442 | 67 | container_config["WORDPRESS_DB_NAME"] = config["db_name"] | ||
443 | 68 | container_config["WORDPRESS_DB_USER"] = config["db_user"] | ||
444 | 69 | if config.get("wp_plugin_openid_team_map"): | ||
445 | 70 | container_config["WP_PLUGIN_OPENID_TEAM_MAP"] = config["wp_plugin_openid_team_map"] | ||
446 | 71 | return container_config | ||
447 | 72 | |||
448 | 73 | def full_container_config(self): | ||
449 | 74 | """Container config with secrets""" | ||
450 | 75 | config = self.model.config | ||
451 | 76 | container_config = self.sanitized_container_config() | ||
452 | 77 | if container_config is None: | ||
453 | 78 | return None | ||
454 | 79 | if config["container_secrets"].strip() == "": | ||
455 | 80 | container_secrets = {} | ||
456 | 81 | else: | ||
457 | 82 | container_secrets = safe_load(config["container_secrets"]) | ||
458 | 83 | if not isinstance(container_secrets, dict): | ||
459 | 84 | self.model.unit.status = BlockedStatus("container_secrets is not a YAML mapping") | ||
460 | 85 | return None | ||
461 | 86 | container_config.update(container_secrets) | ||
462 | 87 | # Add secrets from charm config | ||
463 | 88 | container_config["WORDPRESS_DB_PASSWORD"] = config["db_password"] | ||
464 | 89 | if config.get("wp_plugin_akismet_key"): | ||
465 | 90 | container_config["WP_PLUGIN_AKISMET_KEY"] = config["wp_plugin_akismet_key"] | ||
466 | 91 | return container_config | ||
467 | 92 | |||
468 | 93 | def make_pod_spec(self): | ||
469 | 94 | config = self.model.config | ||
470 | 95 | container_config = self.sanitized_container_config() | ||
471 | 96 | if container_config is None: | ||
472 | 97 | return # Status already set | ||
473 | 98 | |||
474 | 99 | ports = [ | ||
475 | 100 | {"name": name, "containerPort": int(port), "protocol": "TCP"} | ||
476 | 101 | for name, port in [addr.split(":", 1) for addr in config["ports"].split()] | ||
477 | 102 | ] | ||
478 | 103 | |||
479 | 104 | # PodSpec v1? https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.13/#podspec-v1-core | ||
480 | 105 | spec = { | ||
481 | 106 | "containers": [ | ||
482 | 107 | { | ||
483 | 108 | "name": self.app.name, | ||
484 | 109 | "imageDetails": {"imagePath": config["image"]}, | ||
485 | 110 | "ports": ports, | ||
486 | 111 | "config": container_config, | ||
487 | 112 | "readinessProbe": {"exec": {"command": ["cat", "/srv/wordpress-helpers/.ready"]}}, | ||
488 | 113 | } | ||
489 | 114 | ] | ||
490 | 115 | } | ||
491 | 116 | out = io.StringIO() | ||
492 | 117 | pprint(spec, out) | ||
493 | 118 | logger.info("Container environment config (sans secrets) <<EOM\n{}\nEOM".format(out.getvalue())) | ||
494 | 119 | |||
495 | 120 | # If we need credentials (secrets) for our image, add them to the spec after logging | ||
496 | 121 | if config.get("image_user") and config.get("image_pass"): | ||
497 | 122 | spec.get("containers")[0].get("imageDetails")["username"] = config["image_user"] | ||
498 | 123 | spec.get("containers")[0].get("imageDetails")["password"] = config["image_pass"] | ||
499 | 124 | |||
500 | 125 | config_with_secrets = self.full_container_config() | ||
501 | 126 | if config_with_secrets is None: | ||
502 | 127 | return None # Status already set | ||
503 | 128 | container_config.update(config_with_secrets) | ||
504 | 129 | |||
505 | 130 | return spec | ||
506 | 131 | |||
507 | 132 | def first_install(self): | ||
508 | 133 | """Perform initial configuration of wordpress if needed.""" | ||
509 | 134 | config = self.model.config | ||
510 | 135 | if not self.is_pod_up("website"): | ||
511 | 136 | logger.info("Pod not yet ready - retrying") | ||
512 | 137 | return False | ||
513 | 138 | elif not self.is_vhost_ready(): | ||
514 | 139 | logger.info("Wordpress vhost is not yet listening - retrying") | ||
515 | 140 | return False | ||
516 | 141 | elif self.state._configured or not config["initial_settings"]: | ||
517 | 142 | logger.info("No initial_setting provided or wordpress already configured. Skipping first install.") | ||
518 | 143 | return True | ||
519 | 144 | logger.info("Starting wordpress initial configuration") | ||
520 | 145 | payload = { | ||
521 | 146 | # "admin_password": host.pwgen(24), ## TODO: pwgen | ||
522 | 147 | "admin_password": "letmein123", | ||
523 | 148 | "blog_public": "checked", | ||
524 | 149 | "Submit": "submit", | ||
525 | 150 | } | ||
526 | 151 | payload.update(safe_load(config["initial_settings"])) | ||
527 | 152 | payload["admin_password2"] = payload["admin_password"] | ||
528 | 153 | if not payload["blog_public"]: | ||
529 | 154 | payload["blog_public"] = "unchecked" | ||
530 | 155 | required_config = set(("user_name", "admin_email")) | ||
531 | 156 | missing = required_config.difference(payload.keys()) | ||
532 | 157 | if missing: | ||
533 | 158 | logger.info("Error: missing wordpress settings: {}".format(missing)) | ||
534 | 159 | return False | ||
535 | 160 | self.call_wordpress("/wp-admin/install.php?step=2", redirects=True, payload=payload) | ||
536 | 161 | # TODO: write_file | ||
537 | 162 | # host.write_file(os.path.join("/root/", "initial.passwd"), payload["admin_password"], perms=0o400) | ||
538 | 163 | return True | ||
539 | 164 | |||
540 | 165 | def call_wordpress(self, uri, redirects=True, payload={}, _depth=1): | ||
541 | 166 | import requests | ||
542 | 167 | |||
543 | 168 | max_depth = 10 | ||
544 | 169 | if _depth > max_depth: | ||
545 | 170 | logger.info("Redirect loop detected in call_worpress()") | ||
546 | 171 | raise RuntimeError("Redirect loop detected in call_worpress()") | ||
547 | 172 | config = self.model.config | ||
548 | 173 | service_ip = self.get_service_ip("website") | ||
549 | 174 | if service_ip: | ||
550 | 175 | headers = {"Host": config["blog_hostname"]} | ||
551 | 176 | url = urlunparse(("http", service_ip, uri, "", "", "")) | ||
552 | 177 | if payload: | ||
553 | 178 | r = requests.post(url, allow_redirects=False, headers=headers, data=payload, timeout=30) | ||
554 | 179 | else: | ||
555 | 180 | r = requests.get(url, allow_redirects=False, headers=headers, timeout=30) | ||
556 | 181 | if redirects and r.is_redirect: | ||
557 | 182 | # Recurse, but strip the scheme and host first, we need to connect over HTTP by bare IP | ||
558 | 183 | o = urlparse(r.headers.get("Location")) | ||
559 | 184 | return self.call_wordpress(o.path, redirects=redirects, payload=payload, _depth=_depth + 1) | ||
560 | 185 | else: | ||
561 | 186 | return r | ||
562 | 187 | else: | ||
563 | 188 | logger.info("Error getting service IP") | ||
564 | 189 | return False | ||
565 | 190 | |||
566 | 191 | def wordpress_configured(self): | ||
567 | 192 | """Check whether first install has been completed.""" | ||
568 | 193 | import requests | ||
569 | 194 | |||
570 | 195 | # Check whether pod is deployed | ||
571 | 196 | if not self.is_pod_up("website"): | ||
572 | 197 | return False | ||
573 | 198 | # Check if we have WP code deployed at all | ||
574 | 199 | if not self.is_vhost_ready(): | ||
575 | 200 | return False | ||
576 | 201 | # We have code on disk, check if configured | ||
577 | 202 | try: | ||
578 | 203 | r = self.call_wordpress("/", redirects=False) | ||
579 | 204 | except requests.exceptions.ConnectionError: | ||
580 | 205 | return False | ||
581 | 206 | if r.status_code == 302 and re.match("^.*/wp-admin/install.php", r.headers.get("location", "")): | ||
582 | 207 | return False | ||
583 | 208 | elif r.status_code == 302 and re.match("^.*/wp-admin/setup-config.php", r.headers.get("location", "")): | ||
584 | 209 | logger.info("MySQL database setup failed, we likely have no wp-config.php") | ||
585 | 210 | self.model.unit.status = BlockedStatus("MySQL database setup failed, we likely have no wp-config.php") | ||
586 | 211 | return False | ||
587 | 212 | else: | ||
588 | 213 | return True | ||
589 | 214 | |||
590 | 215 | def is_vhost_ready(self): | ||
591 | 216 | """Check whether wordpress is available using http.""" | ||
592 | 217 | import requests | ||
593 | 218 | |||
594 | 219 | # Check if we have WP code deployed at all | ||
595 | 220 | try: | ||
596 | 221 | r = self.call_wordpress("/wp-login.php", redirects=False) | ||
597 | 222 | except requests.exceptions.ConnectionError: | ||
598 | 223 | logger.info("call_wordpress() returned requests.exceptions.ConnectionError") | ||
599 | 224 | return False | ||
600 | 225 | if r is None: | ||
601 | 226 | logger.info("call_wordpress() returned None") | ||
602 | 227 | return False | ||
603 | 228 | if hasattr(r, "status_code") and r.status_code in (403, 404): | ||
604 | 229 | logger.info("call_wordpress() returned status {}".format(r.status_code)) | ||
605 | 230 | return False | ||
606 | 231 | else: | ||
607 | 232 | return True | ||
608 | 233 | |||
609 | 234 | def get_service_ip(self, endpoint): | ||
610 | 235 | try: | ||
611 | 236 | self.model.get_binding(endpoint).network is not False | ||
612 | 237 | except Exception: | ||
613 | 238 | logger.info("We don't have networking yet") | ||
614 | 239 | return '' | ||
615 | 240 | try: | ||
616 | 241 | return str(self.model.get_binding(endpoint).network.ingress_addresses[0]) | ||
617 | 242 | except Exception: | ||
618 | 243 | logger.info("We don't have any ingress addresses yet") | ||
619 | 244 | return '' | ||
620 | 245 | |||
621 | 246 | def is_pod_up(self, endpoint): | ||
622 | 247 | """Check to see if the pod of a relation is up""" | ||
623 | 248 | ingress = self.get_service_ip(endpoint) | ||
624 | 249 | if len(ingress) == 0: | ||
625 | 250 | return False | ||
626 | 251 | return True | ||
627 | 252 | |||
628 | 253 | def configure_pod(self, event): # NoQA: C901 | ||
629 | 254 | if not self.state._started: | ||
630 | 255 | return | ||
631 | 256 | if not self.state._valid: | ||
632 | 257 | return | ||
633 | 258 | elif self.model.unit.is_leader(): | ||
634 | 259 | # only the leader can set_spec() | ||
635 | 260 | spec = self.make_pod_spec() | ||
636 | 261 | if spec is None: | ||
637 | 262 | return # Status already set | ||
638 | 263 | # TODO: Figure out operator equivalent of reactive.data_changed() | ||
639 | 264 | # https://github.com/canonical/operator/issues/189 | ||
640 | 265 | # if reactive.data_changed("wordpress.spec", spec): | ||
641 | 266 | # "if True" works around it, but we'll respwan on every hook run, | ||
642 | 267 | # which is decidedly sub-optimal | ||
643 | 268 | if True: | ||
644 | 269 | self.model.unit.status = MaintenanceStatus("Configuring container") | ||
645 | 270 | try: | ||
646 | 271 | self.model.pod.set_spec(spec) | ||
647 | 272 | except Exception as e: | ||
648 | 273 | logger.info("pod_spec_set failed: {}".format(e)) | ||
649 | 274 | self.model.unit.status = BlockedStatus("pod_spec_set failed! Check logs and k8s dashboard.") | ||
650 | 275 | return | ||
651 | 276 | else: | ||
652 | 277 | logger.info("No changes to pod spec") | ||
653 | 278 | if not self.state._configured: | ||
654 | 279 | tries = 0 | ||
655 | 280 | max_tries = 20 | ||
656 | 281 | while tries < max_tries: | ||
657 | 282 | if self.first_install(): | ||
658 | 283 | self.state._configured = True | ||
659 | 284 | self.model.unit.status = ActiveStatus() | ||
660 | 285 | else: | ||
661 | 286 | tries = tries + 1 | ||
662 | 287 | logger.info("Sleeping 30s after attempt {}/{}, will try again".format(tries, max_tries)) | ||
663 | 288 | sleep(30) | ||
664 | 289 | logger.error("Giving up on configuration after attempt {}/{}".format(tries, max_tries)) | ||
665 | 290 | self.model.unit.status = BlockedStatus("first_install failed! Check logs") | ||
666 | 291 | |||
667 | 292 | |||
668 | 293 | if __name__ == "__main__": | ||
669 | 294 | main(Wordpress) | ||
670 | diff --git a/tests/unit/test_wordpress.py b/tests/unit/test_wordpress.py | |||
671 | index 5ade069..e69de29 100644 | |||
672 | --- a/tests/unit/test_wordpress.py | |||
673 | +++ b/tests/unit/test_wordpress.py | |||
674 | @@ -1,64 +0,0 @@ | |||
675 | 1 | import os | ||
676 | 2 | import shutil | ||
677 | 3 | import sys | ||
678 | 4 | import tempfile | ||
679 | 5 | import unittest | ||
680 | 6 | from unittest import mock | ||
681 | 7 | |||
682 | 8 | # We also need to mock up charms.layer so we can run unit tests without having | ||
683 | 9 | # to build the charm and pull in layers such as layer-status. | ||
684 | 10 | sys.modules['charms.layer'] = mock.MagicMock() | ||
685 | 11 | |||
686 | 12 | from charms.layer import status # NOQA: E402 | ||
687 | 13 | |||
688 | 14 | # Add path to where our reactive layer lives and import. | ||
689 | 15 | sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))) | ||
690 | 16 | from reactive import wordpress # NOQA: E402 | ||
691 | 17 | |||
692 | 18 | |||
693 | 19 | class TestCharm(unittest.TestCase): | ||
694 | 20 | def setUp(self): | ||
695 | 21 | self.maxDiff = None | ||
696 | 22 | self.tmpdir = tempfile.mkdtemp(prefix='charm-unittests-') | ||
697 | 23 | self.addCleanup(shutil.rmtree, self.tmpdir) | ||
698 | 24 | |||
699 | 25 | self.charm_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) | ||
700 | 26 | |||
701 | 27 | patcher = mock.patch('charmhelpers.core.hookenv.log') | ||
702 | 28 | self.mock_log = patcher.start() | ||
703 | 29 | self.addCleanup(patcher.stop) | ||
704 | 30 | self.mock_log.return_value = '' | ||
705 | 31 | |||
706 | 32 | patcher = mock.patch('charmhelpers.core.hookenv.charm_dir') | ||
707 | 33 | self.mock_charm_dir = patcher.start() | ||
708 | 34 | self.addCleanup(patcher.stop) | ||
709 | 35 | self.mock_charm_dir.return_value = self.charm_dir | ||
710 | 36 | |||
711 | 37 | patcher = mock.patch('charmhelpers.core.hookenv.local_unit') | ||
712 | 38 | self.mock_local_unit = patcher.start() | ||
713 | 39 | self.addCleanup(patcher.stop) | ||
714 | 40 | self.mock_local_unit.return_value = 'mock-wordpress/0' | ||
715 | 41 | |||
716 | 42 | patcher = mock.patch('charmhelpers.core.hookenv.config') | ||
717 | 43 | self.mock_config = patcher.start() | ||
718 | 44 | self.addCleanup(patcher.stop) | ||
719 | 45 | self.mock_config.return_value = {'blog_hostname': 'myblog.example.com'} | ||
720 | 46 | |||
721 | 47 | patcher = mock.patch('charmhelpers.core.host.log') | ||
722 | 48 | self.mock_log = patcher.start() | ||
723 | 49 | self.addCleanup(patcher.stop) | ||
724 | 50 | self.mock_log.return_value = '' | ||
725 | 51 | |||
726 | 52 | status.active.reset_mock() | ||
727 | 53 | status.blocked.reset_mock() | ||
728 | 54 | status.maintenance.reset_mock() | ||
729 | 55 | |||
730 | 56 | @mock.patch('charms.reactive.clear_flag') | ||
731 | 57 | def test_hook_upgrade_charm_flags(self, clear_flag): | ||
732 | 58 | '''Test correct flags set via upgrade-charm hook''' | ||
733 | 59 | wordpress.upgrade_charm() | ||
734 | 60 | self.assertFalse(status.maintenance.assert_called()) | ||
735 | 61 | want = [ | ||
736 | 62 | mock.call('wordpress.configured'), | ||
737 | 63 | ] | ||
738 | 64 | self.assertFalse(clear_flag.assert_has_calls(want, any_order=True)) | ||
739 | diff --git a/tox.ini b/tox.ini | |||
740 | index 7b45934..3f4c39f 100644 | |||
741 | --- a/tox.ini | |||
742 | +++ b/tox.ini | |||
743 | @@ -10,7 +10,7 @@ setenv = | |||
744 | 10 | 10 | ||
745 | 11 | [testenv:unit] | 11 | [testenv:unit] |
746 | 12 | commands = | 12 | commands = |
748 | 13 | pytest --ignore {toxinidir}/tests/functional \ | 13 | pytest --ignore mod --ignore {toxinidir}/tests/functional \ |
749 | 14 | {posargs:-v --cov=reactive --cov-report=term-missing --cov-branch} | 14 | {posargs:-v --cov=reactive --cov-report=term-missing --cov-branch} |
750 | 15 | deps = -r{toxinidir}/tests/unit/requirements.txt | 15 | deps = -r{toxinidir}/tests/unit/requirements.txt |
751 | 16 | -r{toxinidir}/requirements.txt | 16 | -r{toxinidir}/requirements.txt |
752 | @@ -24,16 +24,16 @@ passenv = | |||
753 | 24 | JUJU_REPOSITORY | 24 | JUJU_REPOSITORY |
754 | 25 | PATH | 25 | PATH |
755 | 26 | commands = | 26 | commands = |
757 | 27 | pytest -v --ignore {toxinidir}/tests/unit {posargs} | 27 | pytest -v --ignore mod --ignore {toxinidir}/tests/unit {posargs} |
758 | 28 | deps = -r{toxinidir}/tests/functional/requirements.txt | 28 | deps = -r{toxinidir}/tests/functional/requirements.txt |
759 | 29 | -r{toxinidir}/requirements.txt | 29 | -r{toxinidir}/requirements.txt |
760 | 30 | 30 | ||
761 | 31 | [testenv:black] | 31 | [testenv:black] |
763 | 32 | commands = black --skip-string-normalization --line-length=120 . | 32 | commands = black --skip-string-normalization --line-length=120 src/ tests/ |
764 | 33 | deps = black | 33 | deps = black |
765 | 34 | 34 | ||
766 | 35 | [testenv:lint] | 35 | [testenv:lint] |
768 | 36 | commands = flake8 | 36 | commands = flake8 src/ tests/ |
769 | 37 | deps = flake8 | 37 | deps = flake8 |
770 | 38 | 38 | ||
771 | 39 | [flake8] | 39 | [flake8] |
772 | diff --git a/wheelhouse.txt b/wheelhouse.txt | |||
773 | 40 | deleted file mode 100644 | 40 | deleted file mode 100644 |
774 | index f229360..0000000 | |||
775 | --- a/wheelhouse.txt | |||
776 | +++ /dev/null | |||
777 | @@ -1 +0,0 @@ | |||
778 | 1 | requests |
Some comments inline