wordpress-charmers team mailing list archive
-
wordpress-charmers team
-
Mailing list archive
-
Message #00000
[Merge] ~barryprice/charm-k8s-wordpress/+git/charm-k8s-wordpress:master into charm-k8s-wordpress:master
Barry Price has proposed merging ~barryprice/charm-k8s-wordpress/+git/charm-k8s-wordpress:master into charm-k8s-wordpress:master.
Commit message:
Import code from lp:generik8s-charm
Requested reviews:
Canonical IS Reviewers (canonical-is-reviewers)
Wordpress Charmers (wordpress-charmers)
For more details, see:
https://code.launchpad.net/~barryprice/charm-k8s-wordpress/+git/charm-k8s-wordpress/+merge/375596
--
Your team Wordpress Charmers is requested to review the proposed merge of ~barryprice/charm-k8s-wordpress/+git/charm-k8s-wordpress:master into charm-k8s-wordpress:master.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..6dc2159
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,6 @@
+lint:
+ black -l 120 -t py37 reactive/
+ flake8 reactive/
+
+build: lint
+ charm build
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..31e9edc
--- /dev/null
+++ b/README.md
@@ -0,0 +1,54 @@
+= Wordpress k8s charm =
+
+A Juju charm for a Kubernetes deployment of Wordpress, using the
+official Dockerhub Wordpress image or image built from this base.
+
+== Overview ==
+
+This is a k8s charm and can only be deployed to to a Juju k8s cloud,
+attached to a controller using 'juju add-k8s'.
+
+The image to spin up is specified in the 'image' charm configuration
+option using standard docker notation (eg. 'localhost:32000/mywork-rev42').
+Images must be publicly accessible. Default is the Dockerhub
+`wordpress:php7.3` image.
+
+Standard configuration for the Wordpress image is in standard Juju config.
+In particular:
+
+* `db_host`, `db_user` & `db_password`. This charm may in future be relatable
+ to a MySQL deployment, when the MySQL charm is updated to support cross
+ model relations.
+* `ports`. Custom images may require additional ports to be opened, such
+ as those providing monitoring or metrics endpoints.
+
+Additional runtine configuration is specified as YAML snippets in the charm config.
+Both 'container_config' and 'container_secrets' items are provided,
+and they are combined together. 'container_config' gets logged,
+'container_secrets' does not. This allows you to configure customized
+Wordpress images.
+
+== Details ==
+
+See config option descriptions in config.yaml.
+
+== Quickstart ==
+
+Notes for deploying a test setup locally using microk8s:
+
+ sudo snap install juju --classic
+ sudo snap install juju-wait --classic
+ sudo snap install microk8s --classic
+ sudo snap alias microk8s.kubectl kubectl
+
+ microk8s.reset # Warning! Clean slate!
+ microk8s.enable dns dashboard registry storage
+ microk8s.status --wait-ready
+ microk8s.config | juju add-k8s myk8s
+ juju bootstrap myk8s
+ juju add-model wordpress-test
+ juju create-storage-pool operator-storage kubernetes storage-class=microk8s-hostpath
+ juju deploy cs:~stub/wordpress-k8s --channel=edge wordpress
+ juju config wordpress db_host=10.1.1.1 db_user=wp db_password=secret
+ juju wait
+ juju status # Shows IP address, and port is 80
diff --git a/config.yaml b/config.yaml
new file mode 100644
index 0000000..842b2e2
--- /dev/null
+++ b/config.yaml
@@ -0,0 +1,68 @@
+options:
+ image:
+ type: string
+ description: "The docker image to install. Required. Defaults to Dockerhub wordpress:php7.3"
+ default: "wordpress:php7.3"
+ ports:
+ type: string
+ description: >
+ Ports to expose, space separated list in name:8000 format. Names are alphanumeric + hyphen.
+ eg. "http:80 metrics:7127"
+ default: "http:80"
+ db_host:
+ type: string
+ description: "MySQL database host"
+ default: ""
+ db_name:
+ type: string
+ description: "MySQL database name"
+ default: "wordpress"
+ db_user:
+ type: string
+ description: "MySQL database user"
+ default: "wordpress"
+ db_password:
+ type: string
+ description: "MySQL database user's password"
+ default: ""
+ container_config:
+ type: string
+ description: >
+ YAML formatted map of container config keys & values. These are
+ generally accessed from inside the image as environment variables.
+ Use to configure customized Wordpress images. This configuration
+ gets logged; use container_secrets for secrets.
+ default: ""
+ container_secrets:
+ type: string
+ description: >
+ YAML formatted map of secrets. Works just like container_config,
+ except that values should not be logged.
+ default: ""
+ initial_settings:
+ type: string
+ description: >
+ Optional, YAML formatted, wordpress configuration. It is used only
+ during initial deployment. Changing it at later stage has no effect.
+ If set to non empty string required keys are:
+
+ user_name: admin_username
+ admin_email: name@xxxxxxxxxxx
+
+ Optionally you can also provide
+
+ weblog_title: Blog title # empty by default
+ admin_password: <secret> # autogenerated if not set
+ blog_public: False # by default blogs are public
+
+ If admin_password is not provided it will be automatically generated
+ and stored on the operator pod in the /root directory
+ default: ""
+ blog_hostname:
+ type: string
+ description: Blog hostname
+ default: "myblog.example.com"
+ akismet_key:
+ type: string
+ description: Akismet key. If empty akismet will not be automatically enabled
+ default: ""
diff --git a/layer.yaml b/layer.yaml
new file mode 100644
index 0000000..c74c77c
--- /dev/null
+++ b/layer.yaml
@@ -0,0 +1,5 @@
+includes:
+ - 'layer:caas-base'
+ - 'layer:osm-common'
+ - 'layer:status'
+repo: git+ssh://git.launchpad.net/charm-k8s-wordpress
diff --git a/metadata.yaml b/metadata.yaml
new file mode 100644
index 0000000..5add52e
--- /dev/null
+++ b/metadata.yaml
@@ -0,0 +1,13 @@
+name: "wordpress-k8s"
+summary: "Wordpress, uses official Docker Wordpress image by default"
+description: "Wordpress, uses official Docker Wordpress image by default"
+maintainers:
+ - https://launchpad.net/~wordpress-charmers <wordpress-charmers@xxxxxxxxxxxxxxxxxxx>
+tags:
+ - applications
+ - blog
+series:
+ - kubernetes
+provides:
+ website:
+ interface: http
diff --git a/reactive/wordpress.py b/reactive/wordpress.py
new file mode 100644
index 0000000..7a84e48
--- /dev/null
+++ b/reactive/wordpress.py
@@ -0,0 +1,228 @@
+import io
+import os
+import random
+import re
+import requests
+import string
+from pprint import pprint
+from urllib.parse import urlunparse
+from yaml import safe_load
+
+from charmhelpers.core import host, hookenv
+from charms import reactive
+from charms.layer import caas_base, status
+from charms.osm.k8s import is_pod_up, get_service_ip
+from charms.reactive import hook, when, when_not
+
+
+@hook("upgrade-charm")
+def upgrade_charm():
+ reactive.clear_flag("wordpress.configured")
+
+
+@when("config.changed")
+def reconfig():
+ status.maintenance("charm configuration changed")
+ reactive.clear_flag("wordpress.configured")
+
+ # Validate config
+ valid = True
+ config = hookenv.config()
+ # Ensure required strings
+ for k in ["image", "db_host", "db_name", "db_user", "db_password"]:
+ if config[k].strip() == "":
+ status.blocked("{!r} config is required".format(k))
+ valid = False
+
+ reactive.toggle_flag("wordpress.config.valid", valid)
+
+
+@when("wordpress.config.valid")
+@when_not("wordpress.configured")
+def deploy_container():
+ spec = make_pod_spec()
+ if spec is None:
+ return # status already set
+ if reactive.data_changed("wordpress.spec", spec):
+ status.maintenance("configuring container")
+ try:
+ caas_base.pod_spec_set(spec)
+ except Exception:
+ status.blocked("pod_spec_set failed! Check logs and k8s dashboard.")
+ return
+ else:
+ hookenv.log("No changes to pod spec")
+ if first_install():
+ reactive.set_flag("wordpress.configured")
+
+
+@when("wordpress.configured")
+def ready():
+ status.active("Ready")
+
+
+def sanitized_container_config():
+ """Uninterpolated container config without secrets"""
+ config = hookenv.config()
+ if config["container_config"].strip() == "":
+ container_config = {}
+ else:
+ container_config = safe_load(config["container_config"])
+ if not isinstance(container_config, dict):
+ status.blocked("container_config is not a YAML mapping")
+ return None
+ container_config["WORDPRESS_DB_HOST"] = config["db_host"]
+ container_config["WORDPRESS_DB_NAME"] = config["db_name"]
+ container_config["WORDPRESS_DB_USER"] = config["db_user"]
+ return container_config
+
+
+def full_container_config():
+ """Uninterpolated container config with secrets"""
+ config = hookenv.config()
+ container_config = sanitized_container_config()
+ if container_config is None:
+ return None
+ if config["container_secrets"].strip() == "":
+ container_secrets = {}
+ else:
+ container_secrets = safe_load(config["container_secrets"])
+ if not isinstance(container_secrets, dict):
+ status.blocked("container_secrets is not a YAML mapping")
+ return None
+ container_config.update(container_secrets)
+ container_config["WORDPRESS_DB_PASSWORD"] = config["db_password"]
+ return container_config
+
+
+def make_pod_spec():
+ config = hookenv.config()
+ container_config = sanitized_container_config()
+ if container_config is None:
+ return # status already set
+
+ ports = [
+ {"name": name, "containerPort": int(port), "protocol": "TCP"}
+ for name, port in [addr.split(":", 1) for addr in config["ports"].split()]
+ ]
+
+ # PodSpec v1? https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.13/#podspec-v1-core
+ spec = {
+ "containers": [
+ {"name": hookenv.charm_name(), "image": config["image"], "ports": ports, "config": container_config}
+ ]
+ }
+ out = io.StringIO()
+ pprint(spec, out)
+ hookenv.log("Container spec (sans secrets) <<EOM\n{}\nEOM".format(out.getvalue()))
+
+ # Add the secrets after logging
+ config_with_secrets = full_container_config()
+ if config_with_secrets is None:
+ return None # status already set
+ container_config.update(config_with_secrets)
+
+ return spec
+
+
+def first_install():
+ """Perform initial configuration of wordpress if needed."""
+ config = hookenv.config()
+ if not is_pod_up("website"):
+ hookenv.log("Pod not yet ready - retrying")
+ return False
+ elif wordpress_configured() or not config["initial_settings"]:
+ hookenv.log("No initial_setting provided or wordpress already configured. Skipping first install.")
+ return True
+ elif not vhost_ready():
+ hookenv.log("Wordpress vhost is not yet listening - retrying")
+ return False
+ hookenv.log("Starting wordpress initial configuration")
+ # TODO: more of the below ought to be configurable
+ payload = {"admin_password": mkpasswd(24), "blog_public": "checked", "Submit": "submit"}
+ payload.update(safe_load(config["initial_settings"]))
+ payload["admin_password2"] = payload["admin_password"]
+ if not payload["blog_public"]:
+ payload["blog_public"] = "unchecked"
+ required_config = set(("user_name", "admin_email"))
+ missing = required_config.difference(payload.keys())
+ if missing:
+ hookenv.log("Error: missing wordpress settings: {}".format(missing))
+ return False
+ call_wordpress("/wp-admin/install.php?step=2", payload=payload)
+ host.write_file(os.path.join("/root/", "initial.passwd"), payload["admin_password"], perms=0o400)
+ return True
+
+
+def mkpasswd(length=64):
+ return "".join(random.choice(string.ascii_letters + string.digits) for _ in range(length))
+
+
+def call_wordpress(uri, redirects=True, payload={}):
+ config = hookenv.config()
+ service_ip = get_service_ip("website")
+ if service_ip:
+ headers = {"Host": config["blog_hostname"]}
+ url = urlunparse((
+ 'http',
+ service_ip,
+ uri,
+ '',
+ '',
+ ''
+ ))
+ if payload:
+ return requests.post(url, allow_redirects=redirects, headers=headers, data=payload)
+ else:
+ return requests.get(url, allow_redirects=redirects, headers=headers)
+ else:
+ hookenv.log("Error getting service IP")
+ return False
+
+
+class wordpress_configured(dict):
+ """Check whether first install has been completed."""
+
+ def __bool__(self):
+ # Check whether pod is deployed
+ if not is_pod_up("website"):
+ return False
+ # Check if we have WP code deployed at all
+ if not vhost_ready():
+ return False
+ # We have code on disk, check if configured
+ try:
+ r = call_wordpress("/", redirects=False)
+ except requests.exceptions.ConnectionError:
+ return False
+ if r.status_code == 302 and re.match("^.*/wp-admin/install.php", r.headers.get("location", "")):
+ return False
+ else:
+ return True
+
+ def __nonzero__(self):
+ return self.__bool__()
+
+ def is_ready(self):
+ return self.__bool__()
+
+
+class vhost_ready(dict):
+ """Check whether wordpress is available using http."""
+
+ def __bool__(self):
+ # Check if we have WP code deployed at all
+ try:
+ r = call_wordpress("/wp-login.php", redirects=False)
+ except requests.exceptions.ConnectionError:
+ return False
+ if r.status_code in (403, 404):
+ return False
+ else:
+ return True
+
+ def __nonzero__(self):
+ return self.__bool__()
+
+ def is_ready(self):
+ return self.__bool__()
diff --git a/wheelhouse.txt b/wheelhouse.txt
new file mode 100644
index 0000000..f229360
--- /dev/null
+++ b/wheelhouse.txt
@@ -0,0 +1 @@
+requests
Follow ups