← Back to team overview

bind-charmers team mailing list archive

[Merge] ~barryprice/charm-k8s-bind/+git/charm-k8s-bind:master into charm-k8s-bind:master

 

Barry Price has proposed merging ~barryprice/charm-k8s-bind/+git/charm-k8s-bind:master into charm-k8s-bind:master.

Commit message:
Very basic first pass at an Operator charm to deploy Bind onto Kubernetes.

Tests and further functionality to follow.

Requested reviews:
  Bind Charmers (bind-charmers)

For more details, see:
https://code.launchpad.net/~barryprice/charm-k8s-bind/+git/charm-k8s-bind/+merge/387828
-- 
Your team Bind Charmers is requested to review the proposed merge of ~barryprice/charm-k8s-bind/+git/charm-k8s-bind:master into charm-k8s-bind:master.
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..490cc43
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+*~
+*.charm
+.tox
+.coverage
+__pycache__
+build
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..d403fd0
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,24 @@
+blacken:
+	@echo "Normalising python layout with black."
+	@tox -e black
+
+
+lint: blacken
+	@echo "Running flake8"
+	@tox -e lint
+
+# We actually use the build directory created by charmcraft,
+# but the .charm file makes a much more convenient sentinel.
+unittest: bind.charm
+	@tox -e unit
+
+test: lint unittest
+
+clean:
+	@echo "Cleaning files"
+	@git clean -fXd
+
+bind.charm: src/*.py requirements.txt
+	charmcraft build
+
+.PHONY: blacken lint unittest test clean
diff --git a/config.yaml b/config.yaml
new file mode 100644
index 0000000..408ae9d
--- /dev/null
+++ b/config.yaml
@@ -0,0 +1,46 @@
+options:
+  bind_image_path:
+    type: string
+    description: |
+        The location of the image to use, e.g. "registry.example.com/bind:v1".
+
+        This setting is required.
+    default: ""
+  bind_image_username:
+    type: string
+    description: "Username to use for the configured image registry, if required"
+    default: ""
+  bind_image_password:
+    type: string
+    description: "Password to use for the configured image registry, if required"
+    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: ""
+  custom_config_repo:
+    type: string
+    description: |
+      Repository from which to populate /etc/bind/.
+      If unset, bind will be deployed with the package defaults.
+      e.g. http://github.com/foo/my-custom-bind-config
+    default: ""
+  https_proxy:
+    type: string
+    description: |
+      Proxy address to set in the environment, e.g. http://192.168.1.1:8080
+      Used to clone the configuration files from custom_config_repo, if set.
+      If a username/password is required, they can be embedded in the proxy 
+      address e.g. http://username:password@192.168.1.1:8080
+      Traffic is expected to be HTTPS, but this will also work for HTTP.
+    default: ""
diff --git a/metadata.yaml b/metadata.yaml
new file mode 100644
index 0000000..12075b3
--- /dev/null
+++ b/metadata.yaml
@@ -0,0 +1,11 @@
+name: "bind"
+summary: "Bind"
+description: "The original, complete open source DNS implementation"
+min-juju-version: 2.8.0
+maintainers:
+  - https://launchpad.net/~bind-charmers <bind-charmers@xxxxxxxxxxxxxxxxxxx>
+tags:
+  - network
+  - ops
+series:
+  - kubernetes
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..d2f23b9
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,3 @@
+[tool.black]
+skip-string-normalization = true
+line-length = 120
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..2d81d3b
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1 @@
+ops
diff --git a/src/charm.py b/src/charm.py
new file mode 100755
index 0000000..efb18f5
--- /dev/null
+++ b/src/charm.py
@@ -0,0 +1,139 @@
+#!/usr/bin/env python3
+
+import io
+import logging
+from ops.charm import CharmBase
+from ops.main import main
+from ops.model import ActiveStatus, MaintenanceStatus
+from pprint import pprint
+from yaml import safe_load
+
+logger = logging.getLogger()
+
+REQUIRED_SETTINGS = ['bind_image_path']
+
+
+def generate_pod_config(config, secured=True):
+    """Kubernetes pod config generator.
+
+    generate_pod_config generates Kubernetes deployment config.
+    If the secured keyword is set then it will return a sanitised copy
+    without exposing secrets.
+    """
+    pod_config = {}
+    if config["container_config"].strip():
+        pod_config = safe_load(config["container_config"])
+
+    if config["custom_config_repo"].strip():
+        pod_config["CUSTOM_CONFIG_REPO"] = config["custom_config_repo"]
+
+    if config["https_proxy"].strip():
+        pod_config["http_proxy"] = config["https_proxy"]
+        pod_config["HTTP_PROXY"] = config["https_proxy"]
+        pod_config["https_proxy"] = config["https_proxy"]
+        pod_config["HTTPS_PROXY"] = config["https_proxy"]
+
+    if secured:
+        return pod_config
+
+    # Add secrets from charm config.
+    pass
+
+    return pod_config
+
+
+class Bind(CharmBase):
+    def __init__(self, *args):
+        super().__init__(*args)
+        self.framework.observe(self.on.start, self.on_config_changed)
+        self.framework.observe(self.on.config_changed, self.on_config_changed)
+
+    def _check_for_config_problems(self):
+        """Check for some simple configuration problems and return a
+        string describing them, otherwise return an empty string."""
+        problems = []
+
+        missing = self._missing_charm_settings()
+        if missing:
+            problems.append('required setting(s) empty: {}'.format(', '.join(sorted(missing))))
+
+        return '; '.join(filter(None, problems))
+
+    def _missing_charm_settings(self):
+        """Check configuration setting dependencies and return a list of
+        missing settings; otherwise return an empty list."""
+        config = self.model.config
+        missing = []
+
+        missing.extend([setting for setting in REQUIRED_SETTINGS if not config[setting]])
+
+        if config['bind_image_username'] and not config['bind_image_password']:
+            missing.append('bind_image_password')
+
+        return sorted(list(set(missing)))
+
+    def on_config_changed(self, event):
+        self.configure_pod()
+
+    def configure_pod(self):
+        # Only the leader can set_spec().
+        if self.model.unit.is_leader():
+            resources = self.make_pod_resources()
+            spec = self.make_pod_spec()
+            spec.update(resources)
+
+            msg = "Configuring pod"
+            logger.info(msg)
+            self.model.unit.status = MaintenanceStatus(msg)
+            self.model.pod.set_spec(spec)
+
+            msg = "Pod configured"
+            logger.info(msg)
+            self.model.unit.status = ActiveStatus(msg)
+        else:
+            logger.info("Spec changes ignored by non-leader")
+
+    def make_pod_resources(self):
+        resources = {}
+        out = io.StringIO()
+        pprint(resources, out)
+        logger.info("This is the Kubernetes Pod resources <<EOM\n{}\nEOM".format(out.getvalue()))
+        return resources
+
+    def make_pod_spec(self):
+        config = self.model.config
+        full_pod_config = generate_pod_config(config, secured=False)
+        secure_pod_config = generate_pod_config(config, secured=True)
+
+        ports = [
+            {"name": "domain-tcp", "containerPort": 53, "protocol": "TCP"},
+            {"name": "domain-udp", "containerPort": 53, "protocol": "UDP"},
+        ]
+
+        spec = {
+            "version": 2,
+            "containers": [
+                {
+                    "name": self.app.name,
+                    "imageDetails": {"imagePath": config["bind_image_path"]},
+                    "ports": ports,
+                    "config": secure_pod_config,
+                }
+            ],
+        }
+
+        out = io.StringIO()
+        pprint(spec, out)
+        logger.info("This is the Kubernetes Pod spec config (sans secrets) <<EOM\n{}\nEOM".format(out.getvalue()))
+
+        if config.get("bind_image_username") and config.get("bind_image_password"):
+            spec.get("containers")[0].get("imageDetails")["username"] = config["bind_image_username"]
+            spec.get("containers")[0].get("imageDetails")["password"] = config["bind_image_password"]
+
+        secure_pod_config.update(full_pod_config)
+
+        return spec
+
+
+if __name__ == "__main__":
+    main(Bind)
diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt
new file mode 100644
index 0000000..65431fc
--- /dev/null
+++ b/tests/unit/requirements.txt
@@ -0,0 +1,4 @@
+mock
+pytest
+pytest-cov
+pyyaml
diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py
new file mode 100644
index 0000000..9d69e47
--- /dev/null
+++ b/tests/unit/test_charm.py
@@ -0,0 +1,27 @@
+import unittest
+
+from charm import Bind
+
+from ops import testing
+
+EMPTY_CONFIG = {
+    'bind_image_path': '',
+    'bind_image_username': '',
+    'bind_image_password': '',
+    'container_config': '',
+    'container_secrets': '',
+    'custom_config_repo': '',
+    'https_proxy': '',
+}
+
+
+class TestBindK8sCharmHooksDisabled(unittest.TestCase):
+    def setUp(self):
+        self.harness = testing.Harness(Bind)
+        self.harness.begin()
+        self.harness.disable_hooks()
+
+    def test_check_for_config_problems(self):
+        self.harness.update_config(EMPTY_CONFIG)
+        expected = 'required setting(s) empty: bind_image_path'
+        self.assertEqual(self.harness.charm._check_for_config_problems(), expected)
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..91adecf
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,48 @@
+[tox]
+skipsdist=True
+envlist = unit, functional
+
+[testenv]
+basepython = python3
+setenv =
+  PYTHONPATH = {toxinidir}/build/lib:{toxinidir}/build/venv
+
+[testenv:unit]
+commands =
+    pytest --ignore mod --ignore {toxinidir}/tests/functional \
+      {posargs:-v  --cov=src --cov-report=term-missing --cov-branch}
+deps = -r{toxinidir}/tests/unit/requirements.txt
+       -r{toxinidir}/requirements.txt
+setenv =
+  PYTHONPATH={toxinidir}/src:{toxinidir}/build/lib:{toxinidir}/build/venv
+  TZ=UTC
+
+[testenv:functional]
+passenv =
+  HOME
+  JUJU_REPOSITORY
+  PATH
+commands =
+	pytest -v --ignore mod --ignore {toxinidir}/tests/unit {posargs}
+deps = -r{toxinidir}/tests/functional/requirements.txt
+       -r{toxinidir}/requirements.txt
+
+[testenv:black]
+commands = black --skip-string-normalization --line-length=120 src/ tests/
+deps = black
+
+[testenv:lint]
+commands = flake8 src/ tests/
+# Pin flake8 to 3.7.9 to match focal
+deps =
+    flake8==3.7.9
+
+[flake8]
+exclude =
+    .git,
+    __pycache__,
+    .tox,
+# Ignore E231 because using black creates errors with this
+ignore = E231
+max-line-length = 120
+max-complexity = 10

Follow ups