← Back to team overview

wordpress-charmers team mailing list archive

[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