From 501873e9b7f9bb3a522b8c566d71ae0731131591 Mon Sep 17 00:00:00 2001 From: Dominik Fleischmann Date: Thu, 2 Jul 2020 13:05:05 +0200 Subject: [PATCH] Add K8s Proxy Charm Packages This is are the packages used to test the k8s proxy charm feature. Signed-off-by: Dominik Fleischmann --- .../hackfest_k8sproxycharm_nsd.yaml | 39 + hackfest_k8sproxycharm_ns/icons/osm.png | Bin 0 -> 55888 bytes hackfest_k8sproxycharm_vnf/README | 0 .../charms/charm-simple-k8s/.gitignore | 1 + .../charms/charm-simple-k8s/.gitmodules | 9 + .../charms/charm-simple-k8s/README.md | 52 + .../charms/charm-simple-k8s/actions.yaml | 38 + .../charms/charm-simple-k8s/config.yaml | 29 + .../charms/charm-simple-k8s/hooks/start | 1 + .../lib/charms/osm/sshproxy.py | 419 ++++++ .../lib/charms/requirementstxt.py | 58 + .../charm-simple-k8s/lib/ops/__init__.py | 0 .../charms/charm-simple-k8s/lib/ops/charm.py | 306 +++++ .../charm-simple-k8s/lib/ops/framework.py | 941 +++++++++++++ .../charm-simple-k8s/lib/ops/jujuversion.py | 77 ++ .../charms/charm-simple-k8s/lib/ops/main.py | 191 +++ .../charms/charm-simple-k8s/lib/ops/model.py | 679 ++++++++++ .../charms/charm-simple-k8s/metadata.yaml | 8 + .../charm-simple-k8s/mod/charms.osm/LICENSE | 201 +++ .../charm-simple-k8s/mod/charms.osm/README.md | 67 + .../mod/charms.osm/charms/osm/libansible.py | 108 ++ .../mod/charms.osm/charms/osm/ns.py | 301 +++++ .../mod/charms.osm/charms/osm/sshproxy.py | 250 ++++ .../charm-simple-k8s/mod/charms/LICENSE | 201 +++ .../charm-simple-k8s/mod/charms/README.md | 29 + .../mod/charms/charms/requirementstxt.py | 58 + .../charm-simple-k8s/mod/operator/.flake8 | 3 + .../charm-simple-k8s/mod/operator/.gitignore | 3 + .../charm-simple-k8s/mod/operator/.travis.yml | 15 + .../charm-simple-k8s/mod/operator/LICENSE.txt | 202 +++ .../charm-simple-k8s/mod/operator/Makefile | 41 + .../charm-simple-k8s/mod/operator/README.md | 120 ++ .../mod/operator/ops/__init__.py | 0 .../mod/operator/ops/charm.py | 306 +++++ .../mod/operator/ops/framework.py | 941 +++++++++++++ .../mod/operator/ops/jujuversion.py | 77 ++ .../charm-simple-k8s/mod/operator/ops/main.py | 191 +++ .../mod/operator/ops/model.py | 679 ++++++++++ .../charm-simple-k8s/mod/operator/setup.py | 38 + .../mod/operator/test/__init__.py | 0 .../mod/operator/test/bin/relation-ids | 11 + .../mod/operator/test/bin/relation-list | 16 + .../test/charms/test_main/config.yaml | 1 + .../test/charms/test_main/lib/__init__.py | 0 .../test/charms/test_main/lib/ops/__init__.py | 0 .../test/charms/test_main/lib/ops/charm.py | 306 +++++ .../charms/test_main/lib/ops/framework.py | 941 +++++++++++++ .../charms/test_main/lib/ops/jujuversion.py | 77 ++ .../test/charms/test_main/lib/ops/main.py | 191 +++ .../test/charms/test_main/lib/ops/model.py | 679 ++++++++++ .../test/charms/test_main/metadata.yaml | 26 + .../test/charms/test_main/src/charm.py | 156 +++ .../mod/operator/test/test_charm.py | 311 +++++ .../mod/operator/test/test_framework.py | 1200 +++++++++++++++++ .../mod/operator/test/test_helpers.py | 76 ++ .../mod/operator/test/test_jujuversion.py | 130 ++ .../mod/operator/test/test_main.py | 362 +++++ .../mod/operator/test/test_model.py | 868 ++++++++++++ .../charms/charm-simple-k8s/requirements.txt | 1 + .../charms/charm-simple-k8s/src/charm.py | 216 +++ .../cloud_init/cloud-config.txt | 12 + .../hackfest_k8sproxycharm_vnfd.yaml | 69 + hackfest_k8sproxycharm_vnf/icons/osm.png | Bin 0 -> 55888 bytes 63 files changed, 12328 insertions(+) create mode 100644 hackfest_k8sproxycharm_ns/hackfest_k8sproxycharm_nsd.yaml create mode 100644 hackfest_k8sproxycharm_ns/icons/osm.png create mode 100644 hackfest_k8sproxycharm_vnf/README create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/.gitignore create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/.gitmodules create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/README.md create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/actions.yaml create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/config.yaml create mode 120000 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/hooks/start create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/charms/osm/sshproxy.py create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/charms/requirementstxt.py create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/__init__.py create mode 100755 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/charm.py create mode 100755 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/framework.py create mode 100755 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/jujuversion.py create mode 100755 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/main.py create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/model.py create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/metadata.yaml create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms.osm/LICENSE create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms.osm/README.md create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms.osm/charms/osm/libansible.py create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms.osm/charms/osm/ns.py create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms.osm/charms/osm/sshproxy.py create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms/LICENSE create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms/README.md create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms/charms/requirementstxt.py create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/.flake8 create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/.gitignore create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/.travis.yml create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/LICENSE.txt create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/Makefile create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/README.md create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/__init__.py create mode 100755 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/charm.py create mode 100755 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/framework.py create mode 100755 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/jujuversion.py create mode 100755 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/main.py create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/model.py create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/setup.py create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/__init__.py create mode 100755 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/bin/relation-ids create mode 100755 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/bin/relation-list create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/config.yaml create mode 100755 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/__init__.py create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/__init__.py create mode 100755 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/charm.py create mode 100755 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/framework.py create mode 100755 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/jujuversion.py create mode 100755 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/main.py create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/model.py create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/metadata.yaml create mode 100755 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/src/charm.py create mode 100755 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_charm.py create mode 100755 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_framework.py create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_helpers.py create mode 100755 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_jujuversion.py create mode 100755 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_main.py create mode 100755 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_model.py create mode 100644 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/requirements.txt create mode 100755 hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/src/charm.py create mode 100755 hackfest_k8sproxycharm_vnf/cloud_init/cloud-config.txt create mode 100644 hackfest_k8sproxycharm_vnf/hackfest_k8sproxycharm_vnfd.yaml create mode 100644 hackfest_k8sproxycharm_vnf/icons/osm.png diff --git a/hackfest_k8sproxycharm_ns/hackfest_k8sproxycharm_nsd.yaml b/hackfest_k8sproxycharm_ns/hackfest_k8sproxycharm_nsd.yaml new file mode 100644 index 00000000..e5e51a6c --- /dev/null +++ b/hackfest_k8sproxycharm_ns/hackfest_k8sproxycharm_nsd.yaml @@ -0,0 +1,39 @@ +nsd:nsd-catalog: + nsd: + - id: hackfest_k8sproxycharm-ns + name: hackfest_k8sproxycharm-ns + short-name: hackfest_k8sproxycharm-ns + description: NS with 2 VNFs with cloudinit connected by datanet and mgmtnet VLs + version: '1.0' + logo: osm.png + constituent-vnfd: + - vnfd-id-ref: hackfest_k8sproxycharm-vnf + member-vnf-index: '1' + - vnfd-id-ref: hackfest_k8sproxycharm-vnf + member-vnf-index: '2' + vld: + - id: mgmtnet + name: mgmtnet + short-name: mgmtnet + type: ELAN + mgmt-network: 'true' + vim-network-name: PUBLIC + vnfd-connection-point-ref: + - vnfd-id-ref: hackfest_k8sproxycharm-vnf + member-vnf-index-ref: '1' + vnfd-connection-point-ref: vnf-mgmt + - vnfd-id-ref: hackfest_k8sproxycharm-vnf + member-vnf-index-ref: '2' + vnfd-connection-point-ref: vnf-mgmt + - id: datanet + name: datanet + short-name: datanet + type: ELAN + vnfd-connection-point-ref: + - vnfd-id-ref: hackfest_k8sproxycharm-vnf + member-vnf-index-ref: '1' + vnfd-connection-point-ref: vnf-data + - vnfd-id-ref: hackfest_k8sproxycharm-vnf + member-vnf-index-ref: '2' + vnfd-connection-point-ref: vnf-data + diff --git a/hackfest_k8sproxycharm_ns/icons/osm.png b/hackfest_k8sproxycharm_ns/icons/osm.png new file mode 100644 index 0000000000000000000000000000000000000000..62012d2a2b491bdcd536d62c3c3c863c0d8c1b33 GIT binary patch literal 55888 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
    duScHe0;pydzZXYcj6S9VzJG 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 Mr>mdKI;Vst09ar5X8-^I literal 0 HcmV?d00001 diff --git a/hackfest_k8sproxycharm_vnf/README b/hackfest_k8sproxycharm_vnf/README new file mode 100644 index 00000000..e69de29b diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/.gitignore b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/.gitignore new file mode 100644 index 00000000..722d5e71 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/.gitignore @@ -0,0 +1 @@ +.vscode diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/.gitmodules b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/.gitmodules new file mode 100644 index 00000000..fce0171f --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/.gitmodules @@ -0,0 +1,9 @@ +[submodule "mod/operator"] + path = mod/operator + url = https://github.com/canonical/operator +[submodule "mod/charms.osm"] + path = mod/charms.osm + url = https://github.com/charmed-osm/charms.osm +[submodule "mod/charms"] + path = mod/charms + url = https://github.com/AdamIsrael/charms.requirementstxt diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/README.md b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/README.md new file mode 100644 index 00000000..21d1a44c --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/README.md @@ -0,0 +1,52 @@ +# charm-simple-k8s + +This is a WORK IN PROGRESS example of a simple proxy charm used by Open Source Mano (OSM), written in the [Python Operator Framwork](https://github.com/canonical/operator) + + +## Usage + +To get the charm: +```bash +git clone https://github.com/charmed-osm/charm-simple-k8s +cd charm-simple-k8s +# Install the submodules +git submodule update --init +``` + +To configure the charm, you'll need to have an SSH-accessible machine. You'll need the hostname, and the username and password to login to. Password authentication is useful for testing but key-based authentication is preferred when deploying through OSM. + +To deploy to juju: +``` +juju deploy . --config ssh-hostname=10.135.22.x --config ssh-username=ubuntu --config ssh-password=ubuntu --resource ubuntu_image=ubuntu/ubuntu:latest +``` + +``` +# Make sure the charm is in an Active state +juju status +``` + +To test the SSH credentials, run the `verify-ssh-credentials` action and inspect it's output: +``` +$ juju run-action simple-k8s/0 verify-ssh-credentials +Action queued with id: "9" + +$ juju show-action-output 9 +UnitId: simple-k8s/0 +results: + Stdout: | + Verified! + verified: "True" +status: completed +timing: + completed: 2020-02-14 19:30:38 +0000 UTC + enqueued: 2020-02-14 19:30:33 +0000 UTC + started: 2020-02-14 19:30:36 +0000 UTC +``` + +To exercise the charm, run the `touch` function + +``` +juju run-action simple-k8s/0 touch filename=/home/ubuntu/firsttouch +``` + +Then ssh to the remote machine and verify that the file has been created. diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/actions.yaml b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/actions.yaml new file mode 100644 index 00000000..956e1f19 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/actions.yaml @@ -0,0 +1,38 @@ +touch: + description: "Touch a file on the VNF." + params: + filename: + description: "The name of the file to touch." + type: string + default: "" + required: + - filename + +# Standard OSM functions +start: + description: "Stop the service on the VNF." +stop: + description: "Stop the service on the VNF." +restart: + description: "Stop the service on the VNF." +reboot: + description: "Reboot the VNF virtual machine." +upgrade: + description: "Upgrade the software on the VNF." + +# Required by charms.osm.sshproxy +run: + description: "Run an arbitrary command" + params: + command: + description: "The command to execute." + type: string + default: "" + required: + - command +generate-ssh-key: + description: "Generate a new SSH keypair for this unit. This will replace any existing previously generated keypair." +verify-ssh-credentials: + description: "Verify that this unit can authenticate with server specified by ssh-hostname and ssh-username." +get-ssh-public-key: + description: "Get the public SSH key for this unit." diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/config.yaml b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/config.yaml new file mode 100644 index 00000000..5b908ae0 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/config.yaml @@ -0,0 +1,29 @@ +options: + ssh-hostname: + type: string + default: "" + description: "The hostname or IP address of the machine to" + ssh-username: + type: string + default: "" + description: "The username to login as." + ssh-password: + type: string + default: "" + description: "The password used to authenticate." + # ssh-private-key: + # type: string + # default: "" + # description: "DEPRECATED. The private ssh key to be used to authenticate." + ssh-public-key: + type: string + default: "" + description: "The public key of this unit." + ssh-key-type: + type: string + default: "rsa" + description: "The type of encryption to use for the SSH key." + ssh-key-bits: + type: int + default: 4096 + description: "The number of bits to use for the SSH key." diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/hooks/start b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/hooks/start new file mode 120000 index 00000000..25b1f68f --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/hooks/start @@ -0,0 +1 @@ +../src/charm.py \ No newline at end of file diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/charms/osm/sshproxy.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/charms/osm/sshproxy.py new file mode 100644 index 00000000..60057c92 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/charms/osm/sshproxy.py @@ -0,0 +1,419 @@ +"""Module to help with executing commands over SSH.""" +## +# Copyright 2016 Canonical Ltd. +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +## + +# from charmhelpers.core import unitdata +# from charmhelpers.core.hookenv import log + +import io +import ipaddress +import paramiko +import os +import socket +import shlex +import traceback + +from subprocess import ( + check_call, + Popen, + CalledProcessError, + PIPE, +) + + +class SSHProxy: + private_key_path = "/root/.ssh/id_sshproxy" + public_key_path = "/root/.ssh/id_sshproxy.pub" + key_type = "rsa" + key_bits = 4096 + + def __init__(self, hostname: str, username: str, password: str = ""): + self.hostname = hostname + self.username = username + self.password = password + + @staticmethod + def generate_ssh_key(): + """Generate a 4096-bit rsa keypair.""" + if not os.path.exists(SSHProxy.private_key_path): + cmd = "ssh-keygen -t {} -b {} -N '' -f {}".format( + SSHProxy.key_type, SSHProxy.key_bits, SSHProxy.private_key_path, + ) + + try: + check_call(cmd, shell=True) + except CalledProcessError: + return False + + return True + + @staticmethod + def get_ssh_public_key(): + publickey = "" + if os.path.exists(SSHProxy.private_key_path): + with open(SSHProxy.public_key_path, "r") as f: + publickey = f.read() + return publickey + + @staticmethod + def has_ssh_key(): + if os.path.exists(SSHProxy.private_key_path): + return True + else: + return False + + def run(self, cmd: str) -> (str, str): + """Run a command remotely via SSH. + + Note: The previous behavior was to run the command locally if SSH wasn't + configured, but that can lead to cases where execution succeeds when you'd + expect it not to. + """ + if isinstance(cmd, str): + cmd = shlex.split(cmd) + + host = self._get_hostname() + user = self.username + passwd = self.password + key = self.private_key_path + + # Make sure we have everything we need to connect + if host and user: + return self._ssh(cmd) + + raise Exception("Invalid SSH credentials.") + + def sftp(self, local, remote): + client = self._get_ssh_client() + + # Create an sftp connection from the underlying transport + sftp = paramiko.SFTPClient.from_transport(client.get_transport()) + sftp.put(local_file, remote_file) + client.close() + pass + + def verify_credentials(self): + """Verify the SSH credentials.""" + try: + (stdout, stderr) = self.run("hostname") + except CalledProcessError as e: + stderr = "Command failed: {} ({})".format(" ".join(e.cmd), str(e.output)) + except paramiko.ssh_exception.AuthenticationException as e: + stderr = "{}.".format(e) + except paramiko.ssh_exception.BadAuthenticationType as e: + stderr = "{}".format(e.explanation) + except paramiko.ssh_exception.BadHostKeyException as e: + stderr = "Host key mismatch: expected {} but got {}.".format( + e.expected_key, e.got_key, + ) + except (TimeoutError, socket.timeout): + stderr = "Timeout attempting to reach {}".format(cfg["ssh-hostname"]) + except Exception as error: + tb = traceback.format_exc() + stderr = "Unhandled exception: {}".format(tb) + + if len(stderr) == 0: + return True, stderr + return False, stderr + + ################### + # Private methods # + ################### + def _get_hostname(self): + """Get the hostname for the ssh target. + + HACK: This function was added to work around an issue where the + ssh-hostname was passed in the format of a.b.c.d;a.b.c.d, where the first + is the floating ip, and the second the non-floating ip, for an Openstack + instance. + """ + return self.hostname.split(";")[0] + + def _get_ssh_client(self): + """Return a connected Paramiko ssh object.""" + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + pkey = None + + # Otherwise, check for the auto-generated private key + if os.path.exists(self.private_key_path): + with open(self.private_key_path) as f: + pkey = paramiko.RSAKey.from_private_key(f) + + ########################################################################### + # There is a bug in some versions of OpenSSH 4.3 (CentOS/RHEL 5) where # + # the server may not send the SSH_MSG_USERAUTH_BANNER message except when # + # responding to an auth_none request. For example, paramiko will attempt # + # to use password authentication when a password is set, but the server # + # could deny that, instead requesting keyboard-interactive. The hack to # + # workaround this is to attempt a reconnect, which will receive the right # + # banner, and authentication can proceed. See the following for more info # + # https://github.com/paramiko/paramiko/issues/432 # + # https://github.com/paramiko/paramiko/pull/438 # + ########################################################################### + + try: + client.connect( + self.hostname, + port=22, + username=self.username, + password=self.password, + pkey=pkey + ) + except paramiko.ssh_exception.SSHException as e: + if "Error reading SSH protocol banner" == str(e): + # Once more, with feeling + client.connect( + host, port=22, username=user, password=password, pkey=pkey + ) + else: + # Reraise the original exception + raise e + + return client + + def _ssh(self, cmd): + """Run an arbitrary command over SSH. + + Returns a tuple of (stdout, stderr) + """ + client = self._get_ssh_client() + + cmds = " ".join(cmd) + stdin, stdout, stderr = client.exec_command(cmds, get_pty=True) + retcode = stdout.channel.recv_exit_status() + client.close() # @TODO re-use connections + if retcode > 0: + output = stderr.read().strip() + raise CalledProcessError(returncode=retcode, cmd=cmd, output=output) + return ( + stdout.read().decode("utf-8").strip(), + stderr.read().decode("utf-8").strip(), + ) + + +## OLD ## + +# def get_config(): +# """Get the current charm configuration. + +# Get the "live" kv store every time we need to access the charm config, in +# case it has recently been changed by a config-changed event. +# """ +# db = unitdata.kv() +# return db.get('config') + + +# def get_host_ip(): +# """Get the IP address for the ssh host. + +# HACK: This function was added to work around an issue where the +# ssh-hostname was passed in the format of a.b.c.d;a.b.c.d, where the first +# is the floating ip, and the second the non-floating ip, for an Openstack +# instance. +# """ +# cfg = get_config() +# return cfg['ssh-hostname'].split(';')[0] + + +# def is_valid_hostname(hostname): +# """Validate the ssh-hostname.""" +# print("Hostname: {}".format(hostname)) +# if hostname == "0.0.0.0": +# return False + +# try: +# ipaddress.ip_address(hostname) +# except ValueError: +# return False + +# return True + + +# def verify_ssh_credentials(): +# """Verify the ssh credentials have been installed to the VNF. + +# Attempts to run a stock command - `hostname` on the remote host. +# """ +# verified = False +# status = '' +# cfg = get_config() + +# try: +# host = get_host_ip() +# if is_valid_hostname(host): +# if len(cfg['ssh-hostname']) and len(cfg['ssh-username']): +# cmd = 'hostname' +# status, err = _run(cmd) + +# if len(err) == 0: +# verified = True +# else: +# status = "Invalid IP address." +# except CalledProcessError as e: +# status = 'Command failed: {} ({})'.format( +# ' '.join(e.cmd), +# str(e.output) +# ) +# except paramiko.ssh_exception.AuthenticationException as e: +# status = '{}.'.format(e) +# except paramiko.ssh_exception.BadAuthenticationType as e: +# status = '{}'.format(e.explanation) +# except paramiko.ssh_exception.BadHostKeyException as e: +# status = 'Host key mismatch: expected {} but got {}.'.format( +# e.expected_key, +# e.got_key, +# ) +# except (TimeoutError, socket.timeout): +# status = "Timeout attempting to reach {}".format(cfg['ssh-hostname']) +# except Exception as error: +# tb = traceback.format_exc() +# status = 'Unhandled exception: {}'.format(tb) + +# return (verified, status) + + +# def charm_dir(): +# """Return the root directory of the current charm.""" +# d = os.environ.get('JUJU_CHARM_DIR') +# if d is not None: +# return d +# return os.environ.get('CHARM_DIR') + + +# def run_local(cmd, env=None): +# """Run a command locally.""" +# if isinstance(cmd, str): +# cmd = shlex.split(cmd) if ' ' in cmd else [cmd] + +# if type(cmd) is not list: +# cmd = [cmd] + +# p = Popen(cmd, +# env=env, +# shell=True, +# stdout=PIPE, +# stderr=PIPE) +# stdout, stderr = p.communicate() +# retcode = p.poll() +# if retcode > 0: +# raise CalledProcessError(returncode=retcode, +# cmd=cmd, +# output=stderr.decode("utf-8").strip()) +# return (stdout.decode('utf-8').strip(), stderr.decode('utf-8').strip()) + + +# def _run(cmd, env=None): +# """Run a command remotely via SSH. + +# Note: The previous behavior was to run the command locally if SSH wasn't +# configured, but that can lead to cases where execution succeeds when you'd +# expect it not to. +# """ +# if isinstance(cmd, str): +# cmd = shlex.split(cmd) + +# if type(cmd) is not list: +# cmd = [cmd] + +# cfg = get_config() + +# if cfg: +# if all(k in cfg for k in ['ssh-hostname', 'ssh-username', +# 'ssh-password', 'ssh-private-key']): +# host = get_host_ip() +# user = cfg['ssh-username'] +# passwd = cfg['ssh-password'] +# key = cfg['ssh-private-key'] # DEPRECATED + +# if host and user: +# return ssh(cmd, host, user, passwd, key) + +# raise Exception("Invalid SSH credentials.") + + +# def get_ssh_client(host, user, password=None, key=None): +# """Return a connected Paramiko ssh object.""" +# client = paramiko.SSHClient() +# client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + +# pkey = None + +# # Check for the DEPRECATED private-key +# if key: +# f = io.StringIO(key) +# pkey = paramiko.RSAKey.from_private_key(f) +# else: +# # Otherwise, check for the auto-generated private key +# if os.path.exists('/root/.ssh/id_juju_sshproxy'): +# with open('/root/.ssh/id_juju_sshproxy', 'r') as f: +# pkey = paramiko.RSAKey.from_private_key(f) + +# ########################################################################### +# # There is a bug in some versions of OpenSSH 4.3 (CentOS/RHEL 5) where # +# # the server may not send the SSH_MSG_USERAUTH_BANNER message except when # +# # responding to an auth_none request. For example, paramiko will attempt # +# # to use password authentication when a password is set, but the server # +# # could deny that, instead requesting keyboard-interactive. The hack to # +# # workaround this is to attempt a reconnect, which will receive the right # +# # banner, and authentication can proceed. See the following for more info # +# # https://github.com/paramiko/paramiko/issues/432 # +# # https://github.com/paramiko/paramiko/pull/438 # +# ########################################################################### + +# try: +# client.connect(host, port=22, username=user, +# password=password, pkey=pkey) +# except paramiko.ssh_exception.SSHException as e: +# if 'Error reading SSH protocol banner' == str(e): +# # Once more, with feeling +# client.connect(host, port=22, username=user, +# password=password, pkey=pkey) +# else: +# # Reraise the original exception +# raise e + +# return client + + +# def sftp(local_file, remote_file, host, user, password=None, key=None): +# """Copy a local file to a remote host.""" +# client = get_ssh_client(host, user, password, key) + +# # Create an sftp connection from the underlying transport +# sftp = paramiko.SFTPClient.from_transport(client.get_transport()) +# sftp.put(local_file, remote_file) +# client.close() + + +# def ssh(cmd, host, user, password=None, key=None): +# """Run an arbitrary command over SSH.""" +# client = get_ssh_client(host, user, password, key) + +# cmds = ' '.join(cmd) +# stdin, stdout, stderr = client.exec_command(cmds, get_pty=True) +# retcode = stdout.channel.recv_exit_status() +# client.close() # @TODO re-use connections +# if retcode > 0: +# output = stderr.read().strip() +# raise CalledProcessError(returncode=retcode, cmd=cmd, +# output=output) +# return ( +# stdout.read().decode('utf-8').strip(), +# stderr.read().decode('utf-8').strip() +# ) diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/charms/requirementstxt.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/charms/requirementstxt.py new file mode 100644 index 00000000..298d5845 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/charms/requirementstxt.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +# Requirements.txt support + +import sys + +sys.path.append("lib") + +from ops.framework import StoredState + +import os +import subprocess +import sys +from remote_pdb import RemotePdb + +REQUIREMENTS_TXT = "{}/requirements.txt".format(os.environ["JUJU_CHARM_DIR"]) + + +def install_requirements(): + if os.path.exists(REQUIREMENTS_TXT): + + # First, make sure python3 and python3-pip are installed + if not os.path.exists("/usr/bin/python3") or not os.path.exists("/usr/bin/pip3"): + # Update the apt cache + subprocess.check_call(["apt-get", "update"]) + # Install the Python3 package + subprocess.check_call( + ["apt-get", "install", "-y", "python3", "python3-pip", "python3-paramiko"], + # Eat stdout so it's not returned in an action's stdout + # TODO: redirect to a file handle and log to juju log + # stdout=subprocess.DEVNULL, + ) + + # Lastly, install the python requirements + cmd = [sys.executable, "-m", "pip", "install", "-r", REQUIREMENTS_TXT] + # stdout = subprocess.check_output(cmd) + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) + + stdout, stderr = p.communicate() + + print(stdout) + print(stderr) + # subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", REQUIREMENTS_TXT], + # # Eat stdout so it's not returned in an action's stdout + # # TODO: redirect to a file handle and log to juju log + # # stdout=subprocess.DEVNULL, + # ) + + +# Use StoredState to make sure we're run exactly once automatically +# RemotePdb('127.0.0.1', 4444).set_trace() + +state = StoredState() + +installed = getattr(state, "requirements_txt_installed", None) +if not installed: + install_requirements() + state.requirements_txt_installed = True + diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/__init__.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/charm.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/charm.py new file mode 100755 index 00000000..71472f96 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/charm.py @@ -0,0 +1,306 @@ +# Copyright 2019 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os + +import yaml + +from ops.framework import Object, EventSource, EventBase, EventsBase + + +class HookEvent(EventBase): + pass + + +class ActionEvent(EventBase): + + def defer(self): + raise RuntimeError('cannot defer action events') + + def restore(self, snapshot): + env_action_name = os.environ.get('JUJU_ACTION_NAME') + event_action_name = self.handle.kind[:-len('_action')].replace('_', '-') + if event_action_name != env_action_name: + # This could only happen if the dev manually emits the action, or from a bug. + raise RuntimeError('action event kind does not match current action') + # Params are loaded at restore rather than __init__ because the model is not available in __init__. + self.params = self.framework.model._backend.action_get() + + def set_results(self, results): + self.framework.model._backend.action_set(results) + + def log(self, message): + self.framework.model._backend.action_log(message) + + def fail(self, message=''): + self.framework.model._backend.action_fail(message) + + +class InstallEvent(HookEvent): + pass + + +class StartEvent(HookEvent): + pass + + +class StopEvent(HookEvent): + pass + + +class ConfigChangedEvent(HookEvent): + pass + + +class UpdateStatusEvent(HookEvent): + pass + + +class UpgradeCharmEvent(HookEvent): + pass + + +class PreSeriesUpgradeEvent(HookEvent): + pass + + +class PostSeriesUpgradeEvent(HookEvent): + pass + + +class LeaderElectedEvent(HookEvent): + pass + + +class LeaderSettingsChangedEvent(HookEvent): + pass + + +class RelationEvent(HookEvent): + def __init__(self, handle, relation, app=None, unit=None): + super().__init__(handle) + + if unit and unit.app != app: + raise RuntimeError(f'cannot create RelationEvent with application {app} and unit {unit}') + + self.relation = relation + self.app = app + self.unit = unit + + def snapshot(self): + snapshot = { + 'relation_name': self.relation.name, + 'relation_id': self.relation.id, + } + if self.app: + snapshot['app_name'] = self.app.name + if self.unit: + snapshot['unit_name'] = self.unit.name + return snapshot + + def restore(self, snapshot): + self.relation = self.framework.model.get_relation(snapshot['relation_name'], snapshot['relation_id']) + + app_name = snapshot.get('app_name') + if app_name: + self.app = self.framework.model.get_app(app_name) + else: + self.app = None + + unit_name = snapshot.get('unit_name') + if unit_name: + self.unit = self.framework.model.get_unit(unit_name) + else: + self.unit = None + + +class RelationJoinedEvent(RelationEvent): + pass + + +class RelationChangedEvent(RelationEvent): + pass + + +class RelationDepartedEvent(RelationEvent): + pass + + +class RelationBrokenEvent(RelationEvent): + pass + + +class StorageEvent(HookEvent): + pass + + +class StorageAttachedEvent(StorageEvent): + pass + + +class StorageDetachingEvent(StorageEvent): + pass + + +class CharmEvents(EventsBase): + + install = EventSource(InstallEvent) + start = EventSource(StartEvent) + stop = EventSource(StopEvent) + update_status = EventSource(UpdateStatusEvent) + config_changed = EventSource(ConfigChangedEvent) + upgrade_charm = EventSource(UpgradeCharmEvent) + pre_series_upgrade = EventSource(PreSeriesUpgradeEvent) + post_series_upgrade = EventSource(PostSeriesUpgradeEvent) + leader_elected = EventSource(LeaderElectedEvent) + leader_settings_changed = EventSource(LeaderSettingsChangedEvent) + + +class CharmBase(Object): + + on = CharmEvents() + + def __init__(self, framework, key): + super().__init__(framework, key) + + for relation_name in self.framework.meta.relations: + relation_name = relation_name.replace('-', '_') + self.on.define_event(f'{relation_name}_relation_joined', RelationJoinedEvent) + self.on.define_event(f'{relation_name}_relation_changed', RelationChangedEvent) + self.on.define_event(f'{relation_name}_relation_departed', RelationDepartedEvent) + self.on.define_event(f'{relation_name}_relation_broken', RelationBrokenEvent) + + for storage_name in self.framework.meta.storages: + storage_name = storage_name.replace('-', '_') + self.on.define_event(f'{storage_name}_storage_attached', StorageAttachedEvent) + self.on.define_event(f'{storage_name}_storage_detaching', StorageDetachingEvent) + + for action_name in self.framework.meta.actions: + action_name = action_name.replace('-', '_') + self.on.define_event(f'{action_name}_action', ActionEvent) + + +class CharmMeta: + """Object containing the metadata for the charm. + + The maintainers, tags, terms, series, and extra_bindings attributes are all + lists of strings. The requires, provides, peers, relations, storage, + resources, and payloads attributes are all mappings of names to instances + of the respective RelationMeta, StorageMeta, ResourceMeta, or PayloadMeta. + + The relations attribute is a convenience accessor which includes all of the + requires, provides, and peers RelationMeta items. If needed, the role of + the relation definition can be obtained from its role attribute. + """ + + def __init__(self, raw={}, actions_raw={}): + self.name = raw.get('name', '') + self.summary = raw.get('summary', '') + self.description = raw.get('description', '') + self.maintainers = [] + if 'maintainer' in raw: + self.maintainers.append(raw['maintainer']) + if 'maintainers' in raw: + self.maintainers.extend(raw['maintainers']) + self.tags = raw.get('tags', []) + self.terms = raw.get('terms', []) + self.series = raw.get('series', []) + self.subordinate = raw.get('subordinate', False) + self.min_juju_version = raw.get('min-juju-version') + self.requires = {name: RelationMeta('requires', name, rel) + for name, rel in raw.get('requires', {}).items()} + self.provides = {name: RelationMeta('provides', name, rel) + for name, rel in raw.get('provides', {}).items()} + self.peers = {name: RelationMeta('peers', name, rel) + for name, rel in raw.get('peers', {}).items()} + self.relations = {} + self.relations.update(self.requires) + self.relations.update(self.provides) + self.relations.update(self.peers) + self.storages = {name: StorageMeta(name, storage) + for name, storage in raw.get('storage', {}).items()} + self.resources = {name: ResourceMeta(name, res) + for name, res in raw.get('resources', {}).items()} + self.payloads = {name: PayloadMeta(name, payload) + for name, payload in raw.get('payloads', {}).items()} + self.extra_bindings = raw.get('extra-bindings', []) + self.actions = {name: ActionMeta(name, action) for name, action in actions_raw.items()} + + @classmethod + def from_yaml(cls, metadata, actions=None): + meta = yaml.safe_load(metadata) + raw_actions = {} + if actions is not None: + raw_actions = yaml.safe_load(actions) + return cls(meta, raw_actions) + + +class RelationMeta: + """Object containing metadata about a relation definition.""" + + def __init__(self, role, relation_name, raw): + self.role = role + self.relation_name = relation_name + self.interface_name = raw['interface'] + self.scope = raw.get('scope') + + +class StorageMeta: + """Object containing metadata about a storage definition.""" + + def __init__(self, name, raw): + self.storage_name = name + self.type = raw['type'] + self.description = raw.get('description', '') + self.shared = raw.get('shared', False) + self.read_only = raw.get('read-only', False) + self.minimum_size = raw.get('minimum-size') + self.location = raw.get('location') + self.multiple_range = None + if 'multiple' in raw: + range = raw['multiple']['range'] + if '-' not in range: + self.multiple_range = (int(range), int(range)) + else: + range = range.split('-') + self.multiple_range = (int(range[0]), int(range[1]) if range[1] else None) + + +class ResourceMeta: + """Object containing metadata about a resource definition.""" + + def __init__(self, name, raw): + self.resource_name = name + self.type = raw['type'] + self.filename = raw.get('filename', None) + self.description = raw.get('description', '') + + +class PayloadMeta: + """Object containing metadata about a payload definition.""" + + def __init__(self, name, raw): + self.payload_name = name + self.type = raw['type'] + + +class ActionMeta: + + def __init__(self, name, raw=None): + raw = raw or {} + self.name = name + self.title = raw.get('title', '') + self.description = raw.get('description', '') + self.parameters = raw.get('params', {}) # {: } + self.required = raw.get('required', []) # [, ...] diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/framework.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/framework.py new file mode 100755 index 00000000..d95eb61f --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/framework.py @@ -0,0 +1,941 @@ +# Copyright 2019 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import inspect +import pickle +import marshal +import types +import sqlite3 +import collections +import collections.abc +import keyword +import weakref +from datetime import timedelta + + +class Handle: + """Handle defines a name for an object in the form of a hierarchical path. + + The provided parent is the object (or that object's handle) that this handle + sits under, or None if the object identified by this handle stands by itself + as the root of its own hierarchy. + + The handle kind is a string that defines a namespace so objects with the + same parent and kind will have unique keys. + + The handle key is a string uniquely identifying the object. No other objects + under the same parent and kind may have the same key. + """ + + def __init__(self, parent, kind, key): + if parent and not isinstance(parent, Handle): + parent = parent.handle + self._parent = parent + self._kind = kind + self._key = key + if parent: + if key: + self._path = f"{parent}/{kind}[{key}]" + else: + self._path = f"{parent}/{kind}" + else: + if key: + self._path = f"{kind}[{key}]" + else: + self._path = f"{kind}" + + def nest(self, kind, key): + return Handle(self, kind, key) + + def __hash__(self): + return hash((self.parent, self.kind, self.key)) + + def __eq__(self, other): + return (self.parent, self.kind, self.key) == (other.parent, other.kind, other.key) + + def __str__(self): + return self.path + + @property + def parent(self): + return self._parent + + @property + def kind(self): + return self._kind + + @property + def key(self): + return self._key + + @property + def path(self): + return self._path + + @classmethod + def from_path(cls, path): + handle = None + for pair in path.split("/"): + pair = pair.split("[") + good = False + if len(pair) == 1: + kind, key = pair[0], None + good = True + elif len(pair) == 2: + kind, key = pair + if key and key[-1] == ']': + key = key[:-1] + good = True + if not good: + raise RuntimeError("attempted to restore invalid handle path {path}") + handle = Handle(handle, kind, key) + return handle + + +class EventBase: + + def __init__(self, handle): + self.handle = handle + self.deferred = False + + def defer(self): + self.deferred = True + + def snapshot(self): + """Return the snapshot data that should be persisted. + + Subclasses must override to save any custom state. + """ + return None + + def restore(self, snapshot): + """Restore the value state from the given snapshot. + + Subclasses must override to restore their custom state. + """ + self.deferred = False + + +class EventSource: + """EventSource wraps an event type with a descriptor to facilitate observing and emitting. + + It is generally used as: + + class SomethingHappened(EventBase): + pass + + class SomeObject(Object): + something_happened = EventSource(SomethingHappened) + + With that, instances of that type will offer the someobj.something_happened + attribute which is a BoundEvent and may be used to emit and observe the event. + """ + + def __init__(self, event_type): + if not isinstance(event_type, type) or not issubclass(event_type, EventBase): + raise RuntimeError(f"Event requires a subclass of EventBase as an argument, got {event_type}") + self.event_type = event_type + self.event_kind = None + self.emitter_type = None + + def __set_name__(self, emitter_type, event_kind): + if self.event_kind is not None: + raise RuntimeError( + f'EventSource({self.event_type.__name__}) reused as ' + f'{self.emitter_type.__name__}.{self.event_kind} and ' + f'{emitter_type.__name__}.{event_kind}') + self.event_kind = event_kind + self.emitter_type = emitter_type + + def __get__(self, emitter, emitter_type=None): + if emitter is None: + return self + # Framework might not be available if accessed as CharmClass.on.event rather than charm_instance.on.event, + # but in that case it couldn't be emitted anyway, so there's no point to registering it. + framework = getattr(emitter, 'framework', None) + if framework is not None: + framework.register_type(self.event_type, emitter, self.event_kind) + return BoundEvent(emitter, self.event_type, self.event_kind) + + +class BoundEvent: + + def __repr__(self): + return (f'') + + def __init__(self, emitter, event_type, event_kind): + self.emitter = emitter + self.event_type = event_type + self.event_kind = event_kind + + def emit(self, *args, **kwargs): + """Emit event to all registered observers. + + The current storage state is committed before and after each observer is notified. + """ + framework = self.emitter.framework + key = framework._next_event_key() + event = self.event_type(Handle(self.emitter, self.event_kind, key), *args, **kwargs) + framework._emit(event) + + +class HandleKind: + """Helper descriptor to define the Object.handle_kind field. + + The handle_kind for an object defaults to its type name, but it may + be explicitly overridden if desired. + """ + + def __get__(self, obj, obj_type): + kind = obj_type.__dict__.get("handle_kind") + if kind: + return kind + return obj_type.__name__ + + +class Object: + + handle_kind = HandleKind() + + def __init__(self, parent, key): + kind = self.handle_kind + if isinstance(parent, Framework): + self.framework = parent + # Avoid Framework instances having a circular reference to themselves. + if self.framework is self: + self.framework = weakref.proxy(self.framework) + self.handle = Handle(None, kind, key) + else: + self.framework = parent.framework + self.handle = Handle(parent, kind, key) + self.framework._track(self) + + # TODO Detect conflicting handles here. + + @property + def model(self): + return self.framework.model + + @property + def meta(self): + return self.framework.meta + + @property + def charm_dir(self): + return self.framework.charm_dir + + +class EventsBase(Object): + """Convenience type to allow defining .on attributes at class level.""" + + handle_kind = "on" + + def __init__(self, parent=None, key=None): + if parent is not None: + super().__init__(parent, key) + else: + self._cache = weakref.WeakKeyDictionary() + + def __get__(self, emitter, emitter_type): + if emitter is None: + return self + instance = self._cache.get(emitter) + if instance is None: + # Same type, different instance, more data. Doing this unusual construct + # means people can subclass just this one class to have their own 'on'. + instance = self._cache[emitter] = type(self)(emitter) + return instance + + @classmethod + def define_event(cls, event_kind, event_type): + """Define an event on this type at runtime. + + cls -- a type to define an event on. + event_kind -- an attribute name that will be used to access the event. Must be a valid python identifier, not be a keyword or an existing attribute. + event_type -- a type of the event to define. + """ + if not event_kind.isidentifier(): + raise RuntimeError(f'unable to define an event with event_kind that is not a valid python identifier: {event_kind}') + elif keyword.iskeyword(event_kind): + raise RuntimeError(f'unable to define an event with event_kind that is a python keyword: {event_kind}') + try: + getattr(cls, event_kind) + raise RuntimeError(f'unable to define an event with event_kind that overlaps with an existing type {cls} attribute: {event_kind}') + except AttributeError: + pass + + event_descriptor = EventSource(event_type) + event_descriptor.__set_name__(cls, event_kind) + setattr(cls, event_kind, event_descriptor) + + def events(self): + """Return a mapping of event_kinds to bound_events for all available events. + """ + events_map = {} + # We have to iterate over the class rather than instance to allow for properties which + # might call this method (e.g., event views), leading to infinite recursion. + for attr_name, attr_value in inspect.getmembers(type(self)): + if isinstance(attr_value, EventSource): + # We actually care about the bound_event, however, since it + # provides the most info for users of this method. + event_kind = attr_name + bound_event = getattr(self, event_kind) + events_map[event_kind] = bound_event + return events_map + + def __getitem__(self, key): + return PrefixedEvents(self, key) + + +class PrefixedEvents: + + def __init__(self, emitter, key): + self._emitter = emitter + self._prefix = key.replace("-", "_") + '_' + + def __getattr__(self, name): + return getattr(self._emitter, self._prefix + name) + + +class PreCommitEvent(EventBase): + pass + + +class CommitEvent(EventBase): + pass + + +class FrameworkEvents(EventsBase): + pre_commit = EventSource(PreCommitEvent) + commit = EventSource(CommitEvent) + + +class NoSnapshotError(Exception): + + def __init__(self, handle_path): + self.handle_path = handle_path + + def __str__(self): + return f'no snapshot data found for {self.handle_path} object' + + +class NoTypeError(Exception): + + def __init__(self, handle_path): + self.handle_path = handle_path + + def __str__(self): + return f"cannot restore {self.handle_path} since no class was registered for it" + + +class SQLiteStorage: + + DB_LOCK_TIMEOUT = timedelta(hours=1) + + def __init__(self, filename): + # The isolation_level argument is set to None such that the implicit transaction management behavior of the sqlite3 module is disabled. + self._db = sqlite3.connect(str(filename), isolation_level=None, timeout=self.DB_LOCK_TIMEOUT.total_seconds()) + self._setup() + + def _setup(self): + # Make sure that the database is locked until the connection is closed, not until the transaction ends. + self._db.execute("PRAGMA locking_mode=EXCLUSIVE") + c = self._db.execute("BEGIN") + c.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='snapshot'") + if c.fetchone()[0] == 0: + # Keep in mind what might happen if the process dies somewhere below. + # The system must not be rendered permanently broken by that. + self._db.execute("CREATE TABLE snapshot (handle TEXT PRIMARY KEY, data BLOB)") + self._db.execute("CREATE TABLE notice (sequence INTEGER PRIMARY KEY AUTOINCREMENT, event_path TEXT, observer_path TEXT, method_name TEXT)") + self._db.commit() + + def close(self): + self._db.close() + + def commit(self): + self._db.commit() + + # There's commit but no rollback. For abort to be supported, we'll need logic that + # can rollback decisions made by third-party code in terms of the internal state + # of objects that have been snapshotted, and hooks to let them know about it and + # take the needed actions to undo their logic until the last snapshot. + # This is doable but will increase significantly the chances for mistakes. + + def save_snapshot(self, handle_path, snapshot_data): + self._db.execute("REPLACE INTO snapshot VALUES (?, ?)", (handle_path, snapshot_data)) + + def load_snapshot(self, handle_path): + c = self._db.cursor() + c.execute("SELECT data FROM snapshot WHERE handle=?", (handle_path,)) + row = c.fetchone() + if row: + return row[0] + return None + + def drop_snapshot(self, handle_path): + self._db.execute("DELETE FROM snapshot WHERE handle=?", (handle_path,)) + + def save_notice(self, event_path, observer_path, method_name): + self._db.execute("INSERT INTO notice VALUES (NULL, ?, ?, ?)", (event_path, observer_path, method_name)) + + def drop_notice(self, event_path, observer_path, method_name): + self._db.execute("DELETE FROM notice WHERE event_path=? AND observer_path=? AND method_name=?", (event_path, observer_path, method_name)) + + def notices(self, event_path): + if event_path: + c = self._db.execute("SELECT event_path, observer_path, method_name FROM notice WHERE event_path=? ORDER BY sequence", (event_path,)) + else: + c = self._db.execute("SELECT event_path, observer_path, method_name FROM notice ORDER BY sequence") + while True: + rows = c.fetchmany() + if not rows: + break + for row in rows: + yield tuple(row) + + +class Framework(Object): + + on = FrameworkEvents() + + # Override properties from Object so that we can set them in __init__. + model = None + meta = None + charm_dir = None + + def __init__(self, data_path, charm_dir, meta, model): + + super().__init__(self, None) + + self._data_path = data_path + self.charm_dir = charm_dir + self.meta = meta + self.model = model + self._observers = [] # [(observer_path, method_name, parent_path, event_key)] + self._observer = weakref.WeakValueDictionary() # {observer_path: observer} + self._objects = weakref.WeakValueDictionary() + self._type_registry = {} # {(parent_path, kind): cls} + self._type_known = set() # {cls} + + self._storage = SQLiteStorage(data_path) + + # We can't use the higher-level StoredState because it relies on events. + self.register_type(StoredStateData, None, StoredStateData.handle_kind) + stored_handle = Handle(None, StoredStateData.handle_kind, '_stored') + try: + self._stored = self.load_snapshot(stored_handle) + except NoSnapshotError: + self._stored = StoredStateData(self, '_stored') + self._stored['event_count'] = 0 + + def close(self): + self._storage.close() + + def _track(self, obj): + """Track object and ensure it is the only object created using its handle path.""" + if obj is self: + # Framework objects don't track themselves + return + if obj.handle.path in self.framework._objects: + raise RuntimeError(f"two objects claiming to be {obj.handle.path} have been created") + self._objects[obj.handle.path] = obj + + def _forget(self, obj): + """Stop tracking the given object. See also _track.""" + self._objects.pop(obj.handle.path, None) + + def commit(self): + # Give a chance for objects to persist data they want to before a commit is made. + self.on.pre_commit.emit() + # Make sure snapshots are saved by instances of StoredStateData. Any possible state + # modifications in on_commit handlers of instances of other classes will not be persisted. + self.on.commit.emit() + # Save our event count after all events have been emitted. + self.save_snapshot(self._stored) + self._storage.commit() + + def register_type(self, cls, parent, kind=None): + if parent and not isinstance(parent, Handle): + parent = parent.handle + if parent: + parent_path = parent.path + else: + parent_path = None + if not kind: + kind = cls.handle_kind + self._type_registry[(parent_path, kind)] = cls + self._type_known.add(cls) + + def save_snapshot(self, value): + """Save a persistent snapshot of the provided value. + + The provided value must implement the following interface: + + value.handle = Handle(...) + value.snapshot() => {...} # Simple builtin types only. + value.restore(snapshot) # Restore custom state from prior snapshot. + """ + if type(value) not in self._type_known: + raise RuntimeError(f"cannot save {type(value).__name__} values before registering that type") + data = value.snapshot() + # Use marshal as a validator, enforcing the use of simple types. + marshal.dumps(data) + # Use pickle for serialization, so the value remains portable. + raw_data = pickle.dumps(data) + self._storage.save_snapshot(value.handle.path, raw_data) + + def load_snapshot(self, handle): + parent_path = None + if handle.parent: + parent_path = handle.parent.path + cls = self._type_registry.get((parent_path, handle.kind)) + if not cls: + raise NoTypeError(handle.path) + raw_data = self._storage.load_snapshot(handle.path) + if not raw_data: + raise NoSnapshotError(handle.path) + data = pickle.loads(raw_data) + obj = cls.__new__(cls) + obj.framework = self + obj.handle = handle + obj.restore(data) + self._track(obj) + return obj + + def drop_snapshot(self, handle): + self._storage.drop_snapshot(handle.path) + + def observe(self, bound_event, observer): + """Register observer to be called when bound_event is emitted. + + The bound_event is generally provided as an attribute of the object that emits + the event, and is created in this style: + + class SomeObject: + something_happened = Event(SomethingHappened) + + That event may be observed as: + + framework.observe(someobj.something_happened, self.on_something_happened) + + If the method to be called follows the name convention "on_", it + may be omitted from the observe call. That means the above is equivalent to: + + framework.observe(someobj.something_happened, self) + + """ + if not isinstance(bound_event, BoundEvent): + raise RuntimeError(f'Framework.observe requires a BoundEvent as second parameter, got {bound_event}') + + event_type = bound_event.event_type + event_kind = bound_event.event_kind + emitter = bound_event.emitter + + self.register_type(event_type, emitter, event_kind) + + if hasattr(emitter, "handle"): + emitter_path = emitter.handle.path + else: + raise RuntimeError(f'event emitter {type(emitter).__name__} must have a "handle" attribute') + + method_name = None + if isinstance(observer, types.MethodType): + method_name = observer.__name__ + observer = observer.__self__ + else: + method_name = "on_" + event_kind + if not hasattr(observer, method_name): + raise RuntimeError(f'Observer method not provided explicitly and {type(observer).__name__} type has no "{method_name}" method') + + # Validate that the method has an acceptable call signature. + sig = inspect.signature(getattr(observer, method_name)) + # Self isn't included in the params list, so the first arg will be the event. + extra_params = list(sig.parameters.values())[1:] + if not sig.parameters: + raise TypeError(f'{type(observer).__name__}.{method_name} must accept event parameter') + elif any(param.default is inspect.Parameter.empty for param in extra_params): + # Allow for additional optional params, since there's no reason to exclude them, but + # required params will break. + raise TypeError(f'{type(observer).__name__}.{method_name} has extra required parameter') + + # TODO Prevent the exact same parameters from being registered more than once. + + self._observer[observer.handle.path] = observer + self._observers.append((observer.handle.path, method_name, emitter_path, event_kind)) + + def _next_event_key(self): + """Return the next event key that should be used, incrementing the internal counter.""" + # Increment the count first; this means the keys will start at 1, and 0 means no events have been emitted. + self._stored['event_count'] += 1 + return str(self._stored['event_count']) + + def _emit(self, event): + """See BoundEvent.emit for the public way to call this.""" + + # Save the event for all known observers before the first notification + # takes place, so that either everyone interested sees it, or nobody does. + self.save_snapshot(event) + event_path = event.handle.path + event_kind = event.handle.kind + parent_path = event.handle.parent.path + # TODO Track observers by (parent_path, event_kind) rather than as a list of all observers. Avoiding linear search through all observers for every event + for observer_path, method_name, _parent_path, _event_kind in self._observers: + if _parent_path != parent_path: + continue + if _event_kind and _event_kind != event_kind: + continue + # Again, only commit this after all notices are saved. + self._storage.save_notice(event_path, observer_path, method_name) + self._reemit(event_path) + + def reemit(self): + """Reemit previously deferred events to the observers that deferred them. + + Only the specific observers that have previously deferred the event will be + notified again. Observers that asked to be notified about events after it's + been first emitted won't be notified, as that would mean potentially observing + events out of order. + """ + self._reemit() + + def _reemit(self, single_event_path=None): + last_event_path = None + deferred = True + for event_path, observer_path, method_name in self._storage.notices(single_event_path): + event_handle = Handle.from_path(event_path) + + if last_event_path != event_path: + if not deferred: + self._storage.drop_snapshot(last_event_path) + last_event_path = event_path + deferred = False + + try: + event = self.load_snapshot(event_handle) + except NoTypeError: + self._storage.drop_notice(event_path, observer_path, method_name) + continue + + event.deferred = False + observer = self._observer.get(observer_path) + if observer: + custom_handler = getattr(observer, method_name, None) + if custom_handler: + custom_handler(event) + + if event.deferred: + deferred = True + else: + self._storage.drop_notice(event_path, observer_path, method_name) + # We intentionally consider this event to be dead and reload it from scratch in the next path. + self.framework._forget(event) + + if not deferred: + self._storage.drop_snapshot(last_event_path) + + +class StoredStateChanged(EventBase): + pass + + +class StoredStateEvents(EventsBase): + changed = EventSource(StoredStateChanged) + + +class StoredStateData(Object): + + on = StoredStateEvents() + + def __init__(self, parent, attr_name): + super().__init__(parent, attr_name) + self._cache = {} + self.dirty = False + + def __getitem__(self, key): + return self._cache.get(key) + + def __setitem__(self, key, value): + self._cache[key] = value + self.dirty = True + + def __contains__(self, key): + return key in self._cache + + def snapshot(self): + return self._cache + + def restore(self, snapshot): + self._cache = snapshot + self.dirty = False + + def on_commit(self, event): + if self.dirty: + self.framework.save_snapshot(self) + self.dirty = False + + +class BoundStoredState: + + def __init__(self, parent, attr_name): + parent.framework.register_type(StoredStateData, parent) + + handle = Handle(parent, StoredStateData.handle_kind, attr_name) + try: + data = parent.framework.load_snapshot(handle) + except NoSnapshotError: + data = StoredStateData(parent, attr_name) + + # __dict__ is used to avoid infinite recursion. + self.__dict__["_data"] = data + self.__dict__["_attr_name"] = attr_name + + parent.framework.observe(parent.framework.on.commit, self._data) + + def __getattr__(self, key): + # "on" is the only reserved key that can't be used in the data map. + if key == "on": + return self._data.on + if key not in self._data: + raise AttributeError(f"attribute '{key}' is not stored") + return _wrap_stored(self._data, self._data[key]) + + def __setattr__(self, key, value): + if key == "on": + raise AttributeError(f"attribute 'on' is reserved and cannot be set") + + value = _unwrap_stored(self._data, value) + + if not isinstance(value, (type(None), int, str, bytes, list, dict, set)): + raise AttributeError(f"attribute '{key}' cannot be set to {type(value).__name__}: must be int/dict/list/etc") + + self._data[key] = _unwrap_stored(self._data, value) + self.on.changed.emit() + + def set_default(self, **kwargs): + """"Set the value of any given key if it has not already been set""" + for k, v in kwargs.items(): + if k not in self._data: + self._data[k] = v + + +class StoredState: + + def __init__(self): + self.parent_type = None + self.attr_name = None + + def __get__(self, parent, parent_type=None): + if self.parent_type is None: + self.parent_type = parent_type + elif self.parent_type is not parent_type: + raise RuntimeError("StoredState shared by {} and {}".format(self.parent_type.__name__, parent_type.__name__)) + + if parent is None: + return self + + bound = parent.__dict__.get(self.attr_name) + if bound is None: + for attr_name, attr_value in parent_type.__dict__.items(): + if attr_value is self: + if self.attr_name and attr_name != self.attr_name: + parent_tname = parent_type.__name__ + raise RuntimeError(f"StoredState shared by {parent_tname}.{self.attr_name} and {parent_tname}.{attr_name}") + self.attr_name = attr_name + bound = BoundStoredState(parent, attr_name) + parent.__dict__[attr_name] = bound + break + else: + raise RuntimeError("cannot find StoredVariable attribute in type {}".format(parent_type.__name__)) + + return bound + + +def _wrap_stored(parent_data, value): + t = type(value) + if t is dict: + return StoredDict(parent_data, value) + if t is list: + return StoredList(parent_data, value) + if t is set: + return StoredSet(parent_data, value) + return value + + +def _unwrap_stored(parent_data, value): + t = type(value) + if t is StoredDict or t is StoredList or t is StoredSet: + return value._under + return value + + +class StoredDict(collections.abc.MutableMapping): + + def __init__(self, stored_data, under): + self._stored_data = stored_data + self._under = under + + def __getitem__(self, key): + return _wrap_stored(self._stored_data, self._under[key]) + + def __setitem__(self, key, value): + self._under[key] = _unwrap_stored(self._stored_data, value) + self._stored_data.dirty = True + + def __delitem__(self, key): + del self._under[key] + self._stored_data.dirty = True + + def __iter__(self): + return self._under.__iter__() + + def __len__(self): + return len(self._under) + + def __eq__(self, other): + if isinstance(other, StoredDict): + return self._under == other._under + elif isinstance(other, collections.abc.Mapping): + return self._under == other + else: + return NotImplemented + + +class StoredList(collections.abc.MutableSequence): + + def __init__(self, stored_data, under): + self._stored_data = stored_data + self._under = under + + def __getitem__(self, index): + return _wrap_stored(self._stored_data, self._under[index]) + + def __setitem__(self, index, value): + self._under[index] = _unwrap_stored(self._stored_data, value) + self._stored_data.dirty = True + + def __delitem__(self, index): + del self._under[index] + self._stored_data.dirty = True + + def __len__(self): + return len(self._under) + + def insert(self, index, value): + self._under.insert(index, value) + self._stored_data.dirty = True + + def append(self, value): + self._under.append(value) + self._stored_data.dirty = True + + def __eq__(self, other): + if isinstance(other, StoredList): + return self._under == other._under + elif isinstance(other, collections.abc.Sequence): + return self._under == other + else: + return NotImplemented + + def __lt__(self, other): + if isinstance(other, StoredList): + return self._under < other._under + elif isinstance(other, collections.abc.Sequence): + return self._under < other + else: + return NotImplemented + + def __le__(self, other): + if isinstance(other, StoredList): + return self._under <= other._under + elif isinstance(other, collections.abc.Sequence): + return self._under <= other + else: + return NotImplemented + + def __gt__(self, other): + if isinstance(other, StoredList): + return self._under > other._under + elif isinstance(other, collections.abc.Sequence): + return self._under > other + else: + return NotImplemented + + def __ge__(self, other): + if isinstance(other, StoredList): + return self._under >= other._under + elif isinstance(other, collections.abc.Sequence): + return self._under >= other + else: + return NotImplemented + + +class StoredSet(collections.abc.MutableSet): + + def __init__(self, stored_data, under): + self._stored_data = stored_data + self._under = under + + def add(self, key): + self._under.add(key) + self._stored_data.dirty = True + + def discard(self, key): + self._under.discard(key) + self._stored_data.dirty = True + + def __contains__(self, key): + return key in self._under + + def __iter__(self): + return self._under.__iter__() + + def __len__(self): + return len(self._under) + + @classmethod + def _from_iterable(cls, it): + """Construct an instance of the class from any iterable input. + + Per https://docs.python.org/3/library/collections.abc.html + if the Set mixin is being used in a class with a different constructor signature, + you will need to override _from_iterable() with a classmethod that can construct + new instances from an iterable argument. + """ + return set(it) + + def __le__(self, other): + if isinstance(other, StoredSet): + return self._under <= other._under + elif isinstance(other, collections.abc.Set): + return self._under <= other + else: + return NotImplemented + + def __ge__(self, other): + if isinstance(other, StoredSet): + return self._under >= other._under + elif isinstance(other, collections.abc.Set): + return self._under >= other + else: + return NotImplemented + + def __eq__(self, other): + if isinstance(other, StoredSet): + return self._under == other._under + elif isinstance(other, collections.abc.Set): + return self._under == other + else: + return NotImplemented diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/jujuversion.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/jujuversion.py new file mode 100755 index 00000000..5256f24f --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/jujuversion.py @@ -0,0 +1,77 @@ +# Copyright 2020 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import re +from functools import total_ordering + + +@total_ordering +class JujuVersion: + + PATTERN = r'^(?P\d{1,9})\.(?P\d{1,9})((?:\.|-(?P[a-z]+))(?P\d{1,9}))?(\.(?P\d{1,9}))?$' + + def __init__(self, version): + m = re.match(self.PATTERN, version) + if not m: + raise RuntimeError(f'"{version}" is not a valid Juju version string') + + d = m.groupdict() + self.major = int(m.group('major')) + self.minor = int(m.group('minor')) + self.tag = d['tag'] or '' + self.patch = int(d['patch'] or 0) + self.build = int(d['build'] or 0) + + def __repr__(self): + if self.tag: + s = f'{self.major}.{self.minor}-{self.tag}{self.patch}' + else: + s = f'{self.major}.{self.minor}.{self.patch}' + if self.build > 0: + s += f'.{self.build}' + return s + + def __eq__(self, other): + if self is other: + return True + if isinstance(other, str): + other = type(self)(other) + elif not isinstance(other, JujuVersion): + raise RuntimeError(f'cannot compare Juju version "{self}" with "{other}"') + return self.major == other.major and self.minor == other.minor\ + and self.tag == other.tag and self.build == other.build and self.patch == other.patch + + def __lt__(self, other): + if self is other: + return False + if isinstance(other, str): + other = type(self)(other) + elif not isinstance(other, JujuVersion): + raise RuntimeError(f'cannot compare Juju version "{self}" with "{other}"') + + if self.major != other.major: + return self.major < other.major + elif self.minor != other.minor: + return self.minor < other.minor + elif self.tag != other.tag: + if not self.tag: + return False + elif not other.tag: + return True + return self.tag < other.tag + elif self.patch != other.patch: + return self.patch < other.patch + elif self.build != other.build: + return self.build < other.build + return False diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/main.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/main.py new file mode 100755 index 00000000..c8d5da2a --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/main.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +# Copyright 2019 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import sys +from pathlib import Path + +import yaml + +import ops.charm +import ops.framework +import ops.model + +CHARM_STATE_FILE = '.unit-state.db' + + +def debugf(format, *args, **kwargs): + pass + + +def _get_charm_dir(): + charm_dir = os.environ.get("JUJU_CHARM_DIR") + if charm_dir is None: + # Assume $JUJU_CHARM_DIR/lib/op/main.py structure. + charm_dir = Path(f'{__file__}/../../..').resolve() + else: + charm_dir = Path(charm_dir).resolve() + return charm_dir + + +def _load_metadata(charm_dir): + metadata = yaml.safe_load((charm_dir / 'metadata.yaml').read_text()) + + actions_meta = charm_dir / 'actions.yaml' + if actions_meta.exists(): + actions_metadata = yaml.safe_load(actions_meta.read_text()) + else: + actions_metadata = {} + return metadata, actions_metadata + + +def _create_event_link(charm, bound_event): + """Create a symlink for a particular event. + + charm -- A charm object. + bound_event -- An event for which to create a symlink. + """ + if issubclass(bound_event.event_type, ops.charm.HookEvent): + event_dir = charm.framework.charm_dir / 'hooks' + event_path = event_dir / bound_event.event_kind.replace('_', '-') + elif issubclass(bound_event.event_type, ops.charm.ActionEvent): + if not bound_event.event_kind.endswith("_action"): + raise RuntimeError(f"action event name {bound_event.event_kind} needs _action suffix") + event_dir = charm.framework.charm_dir / 'actions' + # The event_kind is suffixed with "_action" while the executable is not. + event_path = event_dir / bound_event.event_kind[:-len('_action')].replace('_', '-') + else: + raise RuntimeError(f'cannot create a symlink: unsupported event type {bound_event.event_type}') + + event_dir.mkdir(exist_ok=True) + if not event_path.exists(): + # CPython has different implementations for populating sys.argv[0] for Linux and Windows. For Windows + # it is always an absolute path (any symlinks are resolved) while for Linux it can be a relative path. + target_path = os.path.relpath(os.path.realpath(sys.argv[0]), event_dir) + + # Ignore the non-symlink files or directories assuming the charm author knows what they are doing. + debugf(f'Creating a new relative symlink at {event_path} pointing to {target_path}') + event_path.symlink_to(target_path) + + +def _setup_event_links(charm_dir, charm): + """Set up links for supported events that originate from Juju. + + Whether a charm can handle an event or not can be determined by + introspecting which events are defined on it. + + Hooks or actions are created as symlinks to the charm code file which is determined by inspecting + symlinks provided by the charm author at hooks/install or hooks/start. + + charm_dir -- A root directory of the charm. + charm -- An instance of the Charm class. + """ + for bound_event in charm.on.events().values(): + # Only events that originate from Juju need symlinks. + if issubclass(bound_event.event_type, (ops.charm.HookEvent, ops.charm.ActionEvent)): + _create_event_link(charm, bound_event) + + +def _emit_charm_event(charm, event_name): + """Emits a charm event based on a Juju event name. + + charm -- A charm instance to emit an event from. + event_name -- A Juju event name to emit on a charm. + """ + event_to_emit = None + try: + event_to_emit = getattr(charm.on, event_name) + except AttributeError: + debugf(f"event {event_name} not defined for {charm}") + + # If the event is not supported by the charm implementation, do + # not error out or try to emit it. This is to support rollbacks. + if event_to_emit is not None: + args, kwargs = _get_event_args(charm, event_to_emit) + debugf(f'Emitting Juju event {event_name}') + event_to_emit.emit(*args, **kwargs) + + +def _get_event_args(charm, bound_event): + event_type = bound_event.event_type + model = charm.framework.model + + if issubclass(event_type, ops.charm.RelationEvent): + relation_name = os.environ['JUJU_RELATION'] + relation_id = int(os.environ['JUJU_RELATION_ID'].split(':')[-1]) + relation = model.get_relation(relation_name, relation_id) + else: + relation = None + + remote_app_name = os.environ.get('JUJU_REMOTE_APP', '') + remote_unit_name = os.environ.get('JUJU_REMOTE_UNIT', '') + if remote_app_name or remote_unit_name: + if not remote_app_name: + if '/' not in remote_unit_name: + raise RuntimeError(f'invalid remote unit name: {remote_unit_name}') + remote_app_name = remote_unit_name.split('/')[0] + args = [relation, model.get_app(remote_app_name)] + if remote_unit_name: + args.append(model.get_unit(remote_unit_name)) + return args, {} + elif relation: + return [relation], {} + return [], {} + + +def main(charm_class): + """Setup the charm and dispatch the observed event. + + The event name is based on the way this executable was called (argv[0]). + """ + + charm_dir = _get_charm_dir() + + # Process the Juju event relevant to the current hook execution + # JUJU_HOOK_NAME, JUJU_FUNCTION_NAME, and JUJU_ACTION_NAME are not used + # in order to support simulation of events from debugging sessions. + # TODO: For Windows, when symlinks are used, this is not a valid method of getting an event name (see LP: #1854505). + juju_exec_path = Path(sys.argv[0]) + juju_event_name = juju_exec_path.name.replace('-', '_') + if juju_exec_path.parent.name == 'actions': + juju_event_name = f'{juju_event_name}_action' + + metadata, actions_metadata = _load_metadata(charm_dir) + meta = ops.charm.CharmMeta(metadata, actions_metadata) + unit_name = os.environ['JUJU_UNIT_NAME'] + model = ops.model.Model(unit_name, meta, ops.model.ModelBackend()) + + # TODO: If Juju unit agent crashes after exit(0) from the charm code + # the framework will commit the snapshot but Juju will not commit its + # operation. + charm_state_path = charm_dir / CHARM_STATE_FILE + framework = ops.framework.Framework(charm_state_path, charm_dir, meta, model) + try: + charm = charm_class(framework, None) + + # When a charm is force-upgraded and a unit is in an error state Juju does not run upgrade-charm and + # instead runs the failed hook followed by config-changed. Given the nature of force-upgrading + # the hook setup code is not triggered on config-changed. + # 'start' event is included as Juju does not fire the install event for K8s charms (see LP: #1854635). + if juju_event_name in ('install', 'start', 'upgrade_charm') or juju_event_name.endswith('_storage_attached'): + _setup_event_links(charm_dir, charm) + + framework.reemit() + + _emit_charm_event(charm, juju_event_name) + + framework.commit() + finally: + framework.close() diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/model.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/model.py new file mode 100644 index 00000000..a12dcca2 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/lib/ops/model.py @@ -0,0 +1,679 @@ +# Copyright 2019 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import weakref +import os +import shutil +import tempfile +import time +import datetime + +from abc import ABC, abstractmethod +from collections.abc import Mapping, MutableMapping +from pathlib import Path +from subprocess import run, PIPE, CalledProcessError + + +class Model: + + def __init__(self, unit_name, meta, backend): + self._cache = ModelCache(backend) + self._backend = backend + self.unit = self.get_unit(unit_name) + self.app = self.unit.app + self.relations = RelationMapping(meta.relations, self.unit, self._backend, self._cache) + self.config = ConfigData(self._backend) + self.resources = Resources(list(meta.resources), self._backend) + self.pod = Pod(self._backend) + self.storages = StorageMapping(list(meta.storages), self._backend) + + def get_unit(self, unit_name): + return self._cache.get(Unit, unit_name) + + def get_app(self, app_name): + return self._cache.get(Application, app_name) + + def get_relation(self, relation_name, relation_id=None): + """Get a specific Relation instance. + + If relation_id is given, this will return that Relation instance. + + If relation_id is not given, this will return the Relation instance if the + relation is established only once or None if it is not established. If this + same relation is established multiple times the error TooManyRelatedAppsError is raised. + """ + return self.relations._get_unique(relation_name, relation_id) + + +class ModelCache: + + def __init__(self, backend): + self._backend = backend + self._weakrefs = weakref.WeakValueDictionary() + + def get(self, entity_type, *args): + key = (entity_type,) + args + entity = self._weakrefs.get(key) + if entity is None: + entity = entity_type(*args, backend=self._backend, cache=self) + self._weakrefs[key] = entity + return entity + + +class Application: + + def __init__(self, name, backend, cache): + self.name = name + self._backend = backend + self._cache = cache + self._is_our_app = self.name == self._backend.app_name + self._status = None + + @property + def status(self): + if not self._is_our_app: + return UnknownStatus() + + if not self._backend.is_leader(): + raise RuntimeError('cannot get application status as a non-leader unit') + + if self._status: + return self._status + + s = self._backend.status_get(is_app=True) + self._status = StatusBase.from_name(s['status'], s['message']) + return self._status + + @status.setter + def status(self, value): + if not isinstance(value, StatusBase): + raise InvalidStatusError(f'invalid value provided for application {self} status: {value}') + + if not self._is_our_app: + raise RuntimeError(f'cannot to set status for a remote application {self}') + + if not self._backend.is_leader(): + raise RuntimeError('cannot set application status as a non-leader unit') + + self._backend.status_set(value.name, value.message, is_app=True) + self._status = value + + def __repr__(self): + return f'<{type(self).__module__}.{type(self).__name__} {self.name}>' + + +class Unit: + + def __init__(self, name, backend, cache): + self.name = name + + app_name = name.split('/')[0] + self.app = cache.get(Application, app_name) + + self._backend = backend + self._cache = cache + self._is_our_unit = self.name == self._backend.unit_name + self._status = None + + @property + def status(self): + if not self._is_our_unit: + return UnknownStatus() + + if self._status: + return self._status + + s = self._backend.status_get(is_app=False) + self._status = StatusBase.from_name(s['status'], s['message']) + return self._status + + @status.setter + def status(self, value): + if not isinstance(value, StatusBase): + raise InvalidStatusError(f'invalid value provided for unit {self} status: {value}') + + if not self._is_our_unit: + raise RuntimeError(f'cannot set status for a remote unit {self}') + + self._backend.status_set(value.name, value.message, is_app=False) + self._status = value + + def __repr__(self): + return f'<{type(self).__module__}.{type(self).__name__} {self.name}>' + + def is_leader(self): + if self._is_our_unit: + # This value is not cached as it is not guaranteed to persist for the whole duration + # of a hook execution. + return self._backend.is_leader() + else: + raise RuntimeError(f"cannot determine leadership status for remote applications: {self}") + + +class LazyMapping(Mapping, ABC): + + _lazy_data = None + + @abstractmethod + def _load(self): + raise NotImplementedError() + + @property + def _data(self): + data = self._lazy_data + if data is None: + data = self._lazy_data = self._load() + return data + + def __contains__(self, key): + return key in self._data + + def __len__(self): + return len(self._data) + + def __iter__(self): + return iter(self._data) + + def __getitem__(self, key): + return self._data[key] + + +class RelationMapping(Mapping): + """Map of relation names to lists of Relation instances.""" + + def __init__(self, relations_meta, our_unit, backend, cache): + self._peers = set() + for name, relation_meta in relations_meta.items(): + if relation_meta.role == 'peers': + self._peers.add(name) + self._our_unit = our_unit + self._backend = backend + self._cache = cache + self._data = {relation_name: None for relation_name in relations_meta} + + def __contains__(self, key): + return key in self._data + + def __len__(self): + return len(self._data) + + def __iter__(self): + return iter(self._data) + + def __getitem__(self, relation_name): + is_peer = relation_name in self._peers + relation_list = self._data[relation_name] + if relation_list is None: + relation_list = self._data[relation_name] = [] + for rid in self._backend.relation_ids(relation_name): + relation = Relation(relation_name, rid, is_peer, self._our_unit, self._backend, self._cache) + relation_list.append(relation) + return relation_list + + def _get_unique(self, relation_name, relation_id=None): + if relation_id is not None: + if not isinstance(relation_id, int): + raise ModelError(f'relation name {relation_id} must be int or None not {type(relation_id).__name__}') + for relation in self[relation_name]: + if relation.id == relation_id: + return relation + else: + # The relation may be dead, but it is not forgotten. + is_peer = relation_name in self._peers + return Relation(relation_name, relation_id, is_peer, self._our_unit, self._backend, self._cache) + num_related = len(self[relation_name]) + if num_related == 0: + return None + elif num_related == 1: + return self[relation_name][0] + else: + # TODO: We need something in the framework to catch and gracefully handle + # errors, ideally integrating the error catching with Juju's mechanisms. + raise TooManyRelatedAppsError(relation_name, num_related, 1) + + +class Relation: + def __init__(self, relation_name, relation_id, is_peer, our_unit, backend, cache): + self.name = relation_name + self.id = relation_id + self.app = None + self.units = set() + + # For peer relations, both the remote and the local app are the same. + if is_peer: + self.app = our_unit.app + try: + for unit_name in backend.relation_list(self.id): + unit = cache.get(Unit, unit_name) + self.units.add(unit) + if self.app is None: + self.app = unit.app + except RelationNotFoundError: + # If the relation is dead, just treat it as if it has no remote units. + pass + self.data = RelationData(self, our_unit, backend) + + def __repr__(self): + return f'<{type(self).__module__}.{type(self).__name__} {self.name}:{self.id}>' + + +class RelationData(Mapping): + def __init__(self, relation, our_unit, backend): + self.relation = weakref.proxy(relation) + self._data = {our_unit: RelationDataContent(self.relation, our_unit, backend)} + self._data.update({our_unit.app: RelationDataContent(self.relation, our_unit.app, backend)}) + self._data.update({unit: RelationDataContent(self.relation, unit, backend) for unit in self.relation.units}) + # The relation might be dead so avoid a None key here. + if self.relation.app: + self._data.update({self.relation.app: RelationDataContent(self.relation, self.relation.app, backend)}) + + def __contains__(self, key): + return key in self._data + + def __len__(self): + return len(self._data) + + def __iter__(self): + return iter(self._data) + + def __getitem__(self, key): + return self._data[key] + + +# We mix in MutableMapping here to get some convenience implementations, but whether it's actually +# mutable or not is controlled by the flag. +class RelationDataContent(LazyMapping, MutableMapping): + + def __init__(self, relation, entity, backend): + self.relation = relation + self._entity = entity + self._backend = backend + self._is_app = isinstance(entity, Application) + + def _load(self): + try: + return self._backend.relation_get(self.relation.id, self._entity.name, self._is_app) + except RelationNotFoundError: + # Dead relations tell no tales (and have no data). + return {} + + def _is_mutable(self): + if self._is_app: + is_our_app = self._backend.app_name == self._entity.name + if not is_our_app: + return False + # Whether the application data bag is mutable or not depends on whether this unit is a leader or not, + # but this is not guaranteed to be always true during the same hook execution. + return self._backend.is_leader() + else: + is_our_unit = self._backend.unit_name == self._entity.name + if is_our_unit: + return True + return False + + def __setitem__(self, key, value): + if not self._is_mutable(): + raise RelationDataError(f'cannot set relation data for {self._entity.name}') + if not isinstance(value, str): + raise RelationDataError('relation data values must be strings') + + self._backend.relation_set(self.relation.id, key, value, self._is_app) + + # Don't load data unnecessarily if we're only updating. + if self._lazy_data is not None: + if value == '': + # Match the behavior of Juju, which is that setting the value to an empty string will + # remove the key entirely from the relation data. + del self._data[key] + else: + self._data[key] = value + + def __delitem__(self, key): + # Match the behavior of Juju, which is that setting the value to an empty string will + # remove the key entirely from the relation data. + self.__setitem__(key, '') + + +class ConfigData(LazyMapping): + + def __init__(self, backend): + self._backend = backend + + def _load(self): + return self._backend.config_get() + + +class StatusBase: + """Status values specific to applications and units.""" + + _statuses = {} + + def __init__(self, message): + self.message = message + + def __new__(cls, *args, **kwargs): + if cls is StatusBase: + raise TypeError("cannot instantiate a base class") + cls._statuses[cls.name] = cls + return super().__new__(cls) + + @classmethod + def from_name(cls, name, message): + return cls._statuses[name](message) + + +class ActiveStatus(StatusBase): + """The unit is ready. + + The unit believes it is correctly offering all the services it has been asked to offer. + """ + name = 'active' + + def __init__(self, message=None): + super().__init__(message or '') + + +class BlockedStatus(StatusBase): + """The unit requires manual intervention. + + An operator has to manually intervene to unblock the unit and let it proceed. + """ + name = 'blocked' + + +class MaintenanceStatus(StatusBase): + """The unit is performing maintenance tasks. + + The unit is not yet providing services, but is actively doing work in preparation for providing those services. + This is a "spinning" state, not an error state. It reflects activity on the unit itself, not on peers or related units. + """ + name = 'maintenance' + + +class UnknownStatus(StatusBase): + """The unit status is unknown. + + A unit-agent has finished calling install, config-changed and start, but the charm has not called status-set yet. + """ + name = 'unknown' + + def __init__(self): + # Unknown status cannot be set and does not have a message associated with it. + super().__init__('') + + +class WaitingStatus(StatusBase): + """A unit is unable to progress. + + The unit is unable to progress to an active state because an application to which it is related is not running. + """ + name = 'waiting' + + +class Resources: + """Object representing resources for the charm. + """ + + def __init__(self, names, backend): + self._backend = backend + self._paths = {name: None for name in names} + + def fetch(self, name): + """Fetch the resource from the controller or store. + + If successfully fetched, this returns a Path object to where the resource is stored + on disk, otherwise it raises a ModelError. + """ + if name not in self._paths: + raise RuntimeError(f'invalid resource name: {name}') + if self._paths[name] is None: + self._paths[name] = Path(self._backend.resource_get(name)) + return self._paths[name] + + +class Pod: + def __init__(self, backend): + self._backend = backend + + def set_spec(self, spec, k8s_resources=None): + if not self._backend.is_leader(): + raise ModelError('cannot set a pod spec as this unit is not a leader') + self._backend.pod_spec_set(spec, k8s_resources) + + +class StorageMapping(Mapping): + """Map of storage names to lists of Storage instances.""" + + def __init__(self, storage_names, backend): + self._backend = backend + self._storage_map = {storage_name: None for storage_name in storage_names} + + def __contains__(self, key): + return key in self._storage_map + + def __len__(self): + return len(self._storage_map) + + def __iter__(self): + return iter(self._storage_map) + + def __getitem__(self, storage_name): + storage_list = self._storage_map[storage_name] + if storage_list is None: + storage_list = self._storage_map[storage_name] = [] + for storage_id in self._backend.storage_list(storage_name): + storage_list.append(Storage(storage_name, storage_id, self._backend)) + return storage_list + + def request(self, storage_name, count=1): + """Requests new storage instances of a given name. + + Uses storage-add tool to request additional storage. Juju will notify the unit + via -storage-attached events when it becomes available. + """ + if storage_name not in self._storage_map: + raise ModelError(f'cannot add storage with {storage_name} as it is not present in the charm metadata') + self._backend.storage_add(storage_name, count) + + +class Storage: + + def __init__(self, storage_name, storage_id, backend): + self.name = storage_name + self.id = storage_id + self._backend = backend + self._location = None + + @property + def location(self): + if self._location is None: + self._location = Path(self._backend.storage_get(f'{self.name}/{self.id}', "location")) + return self._location + + +class ModelError(Exception): + pass + + +class TooManyRelatedAppsError(ModelError): + def __init__(self, relation_name, num_related, max_supported): + super().__init__(f'Too many remote applications on {relation_name} ({num_related} > {max_supported})') + self.relation_name = relation_name + self.num_related = num_related + self.max_supported = max_supported + + +class RelationDataError(ModelError): + pass + + +class RelationNotFoundError(ModelError): + pass + + +class InvalidStatusError(ModelError): + pass + + +class ModelBackend: + + LEASE_RENEWAL_PERIOD = datetime.timedelta(seconds=30) + + def __init__(self): + self.unit_name = os.environ['JUJU_UNIT_NAME'] + self.app_name = self.unit_name.split('/')[0] + + self._is_leader = None + self._leader_check_time = 0 + + def _run(self, *args, return_output=False, use_json=False): + kwargs = dict(stdout=PIPE, stderr=PIPE) + if use_json: + args += ('--format=json',) + try: + result = run(args, check=True, **kwargs) + except CalledProcessError as e: + raise ModelError(e.stderr) + if return_output: + if result.stdout is None: + return '' + else: + text = result.stdout.decode('utf8') + if use_json: + return json.loads(text) + else: + return text + + def relation_ids(self, relation_name): + relation_ids = self._run('relation-ids', relation_name, return_output=True, use_json=True) + return [int(relation_id.split(':')[-1]) for relation_id in relation_ids] + + def relation_list(self, relation_id): + try: + return self._run('relation-list', '-r', str(relation_id), return_output=True, use_json=True) + except ModelError as e: + if 'relation not found' in str(e): + raise RelationNotFoundError() from e + raise + + def relation_get(self, relation_id, member_name, is_app): + if not isinstance(is_app, bool): + raise TypeError('is_app parameter to relation_get must be a boolean') + + try: + return self._run('relation-get', '-r', str(relation_id), '-', member_name, f'--app={is_app}', return_output=True, use_json=True) + except ModelError as e: + if 'relation not found' in str(e): + raise RelationNotFoundError() from e + raise + + def relation_set(self, relation_id, key, value, is_app): + if not isinstance(is_app, bool): + raise TypeError('is_app parameter to relation_set must be a boolean') + + try: + return self._run('relation-set', '-r', str(relation_id), f'{key}={value}', f'--app={is_app}') + except ModelError as e: + if 'relation not found' in str(e): + raise RelationNotFoundError() from e + raise + + def config_get(self): + return self._run('config-get', return_output=True, use_json=True) + + def is_leader(self): + """Obtain the current leadership status for the unit the charm code is executing on. + + The value is cached for the duration of a lease which is 30s in Juju. + """ + now = time.monotonic() + time_since_check = datetime.timedelta(seconds=now - self._leader_check_time) + if time_since_check > self.LEASE_RENEWAL_PERIOD or self._is_leader is None: + # Current time MUST be saved before running is-leader to ensure the cache + # is only used inside the window that is-leader itself asserts. + self._leader_check_time = now + self._is_leader = self._run('is-leader', return_output=True, use_json=True) + + return self._is_leader + + def resource_get(self, resource_name): + return self._run('resource-get', resource_name, return_output=True).strip() + + def pod_spec_set(self, spec, k8s_resources): + tmpdir = Path(tempfile.mkdtemp('-pod-spec-set')) + try: + spec_path = tmpdir / 'spec.json' + spec_path.write_text(json.dumps(spec)) + args = ['--file', str(spec_path)] + if k8s_resources: + k8s_res_path = tmpdir / 'k8s-resources.json' + k8s_res_path.write_text(json.dumps(k8s_resources)) + args.extend(['--k8s-resources', str(k8s_res_path)]) + self._run('pod-spec-set', *args) + finally: + shutil.rmtree(tmpdir) + + def status_get(self, *, is_app=False): + """Get a status of a unit or an application. + app -- A boolean indicating whether the status should be retrieved for a unit or an application. + """ + return self._run('status-get', '--include-data', f'--application={is_app}') + + def status_set(self, status, message='', *, is_app=False): + """Set a status of a unit or an application. + app -- A boolean indicating whether the status should be set for a unit or an application. + """ + if not isinstance(is_app, bool): + raise TypeError('is_app parameter must be boolean') + return self._run('status-set', f'--application={is_app}', status, message) + + def storage_list(self, name): + return [int(s.split('/')[1]) for s in self._run('storage-list', name, return_output=True, use_json=True)] + + def storage_get(self, storage_name_id, attribute): + return self._run('storage-get', '-s', storage_name_id, attribute, return_output=True, use_json=True) + + def storage_add(self, name, count=1): + if not isinstance(count, int) or isinstance(count, bool): + raise TypeError(f'storage count must be integer, got: {count} ({type(count)})') + self._run('storage-add', f'{name}={count}') + + def action_get(self): + return self._run(f'action-get', return_output=True, use_json=True) + + def action_set(self, results): + self._run(f'action-set', *[f"{k}={v}" for k, v in results.items()]) + + def action_log(self, message): + self._run(f'action-log', f"{message}") + + def action_fail(self, message=''): + self._run(f'action-fail', f"{message}") + + def network_get(self, endpoint_name, relation_id=None): + """Return network info provided by network-get for a given endpoint. + + endpoint_name -- A name of an endpoint (relation name or extra-binding name). + relation_id -- An optional relation id to get network info for. + """ + cmd = ['network-get', endpoint_name] + if relation_id is not None: + cmd.extend(['-r', str(relation_id)]) + try: + return self._run(*cmd, return_output=True, use_json=True) + except ModelError as e: + if 'relation not found' in str(e): + raise RelationNotFoundError() from e + raise diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/metadata.yaml b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/metadata.yaml new file mode 100644 index 00000000..fb67ecbf --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/metadata.yaml @@ -0,0 +1,8 @@ +name: simple-k8s +summary: A simple example Kubernetes charm +description: | + Simple is an example charm used in OSM Hackfests +series: + - kubernetes +deployment: + mode: operator diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms.osm/LICENSE b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms.osm/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms.osm/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms.osm/README.md b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms.osm/README.md new file mode 100644 index 00000000..a5d80722 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms.osm/README.md @@ -0,0 +1,67 @@ +# charms.osm +A Python library to aid the development of charms for Open Source Mano (OSM) + +## SSHProxy + +Example: + +```python +from charms.osm.sshproxy import SSHProxy + +# Check if SSH Proxy has key +if not SSHProxy.has_ssh_key(): + # Generate SSH Key + SSHProxy.generate_ssh_key() + +# Get generated public and private keys +SSHProxy.get_ssh_public_key() +SSHProxy.get_ssh_private_key() + +# Get Proxy +proxy = SSHProxy( + hostname=config["ssh-hostname"], + username=config["ssh-username"], + password=config["ssh-password"], +) + +# Verify credentials +verified = proxy.verify_credentials() + +if verified: + # Run commands in remote machine + proxy.run("touch /home/ubuntu/touch") +``` + +## Libansible + +```python +from charms.osm import libansible + +# Install ansible packages in the charm +libansible.install_ansible_support() + +result = libansible.execute_playbook( + "configure-remote.yaml", # Name of the playbook <-- Put the playbook in playbooks/ folder + config["ssh-hostname"], + config["ssh-username"], + config["ssh-password"], + dict_vars, # Dictionary with variables to populate in the playbook +) +``` + +## Usage + +Import submodules: + +```bash +git submodule add https://github.com/charmed-osm/charms.osm mod/charms.osm +git submodule add https://github.com/juju/charm-helpers.git mod/charm-helpers # Only for libansible +``` + +Add symlinks: + +```bash +mkdir -p lib/charms +ln -s ../mod/charms.osm/charms/osm lib/charms/osm +ln -s ../mod/charm-helpers/charmhelpers lib/charmhelpers # Only for libansible +``` diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms.osm/charms/osm/libansible.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms.osm/charms/osm/libansible.py new file mode 100644 index 00000000..32fd26ae --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms.osm/charms/osm/libansible.py @@ -0,0 +1,108 @@ +## +# Copyright 2020 Canonical Ltd. +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +## + +import fnmatch +import os +import yaml +import subprocess +import sys + +sys.path.append("lib") +import charmhelpers.fetch + + +ansible_hosts_path = "/etc/ansible/hosts" + + +def install_ansible_support(from_ppa=True, ppa_location="ppa:ansible/ansible"): + """Installs the ansible package. + + By default it is installed from the `PPA`_ linked from + the ansible `website`_ or from a ppa specified by a charm config.. + + .. _PPA: https://launchpad.net/~rquillo/+archive/ansible + .. _website: http://docs.ansible.com/intro_installation.html#latest-releases-via-apt-ubuntu + + If from_ppa is empty, you must ensure that the package is available + from a configured repository. + """ + if from_ppa: + charmhelpers.fetch.add_source(ppa_location) + charmhelpers.fetch.apt_update(fatal=True) + charmhelpers.fetch.apt_install("ansible") + with open(ansible_hosts_path, "w+") as hosts_file: + hosts_file.write("localhost ansible_connection=local") + + +def create_hosts(hostname, username, password, hosts): + inventory_path = "/etc/ansible/hosts" + + with open(inventory_path, "w") as f: + f.write("[{}]\n".format(hosts)) + h1 = "host ansible_host={0} ansible_user={1} ansible_password={2}\n".format( + hostname, username, password + ) + f.write(h1) + + +def create_ansible_cfg(): + ansible_config_path = "/etc/ansible/ansible.cfg" + + with open(ansible_config_path, "w") as f: + f.write("[defaults]\n") + f.write("host_key_checking = False\n") + + +# Function to find the playbook path +def find(pattern, path): + result = "" + for root, dirs, files in os.walk(path): + for name in files: + if fnmatch.fnmatch(name, pattern): + result = os.path.join(root, name) + return result + + +def execute_playbook(playbook_file, hostname, user, password, vars_dict=None): + playbook_path = find(playbook_file, "/var/lib/juju/agents/") + + with open(playbook_path, "r") as f: + playbook_data = yaml.load(f) + + hosts = "all" + if "hosts" in playbook_data[0].keys() and playbook_data[0]["hosts"]: + hosts = playbook_data[0]["hosts"] + + create_ansible_cfg() + create_hosts(hostname, user, password, hosts) + + call = "ansible-playbook {} ".format(playbook_path) + + if vars_dict and isinstance(vars_dict, dict) and len(vars_dict) > 0: + call += "--extra-vars " + + string_var = "" + for k,v in vars_dict.items(): + string_var += "{}={} ".format(k, v) + + string_var = string_var.strip() + call += '"{}"'.format(string_var) + + call = call.strip() + result = subprocess.check_output(call, shell=True) + + return result diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms.osm/charms/osm/ns.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms.osm/charms/osm/ns.py new file mode 100644 index 00000000..25be4056 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms.osm/charms/osm/ns.py @@ -0,0 +1,301 @@ +# A prototype of a library to aid in the development and operation of +# OSM Network Service charms + +import asyncio +import logging +import os +import os.path +import re +import subprocess +import sys +import time +import yaml + +try: + import juju +except ImportError: + # Not all cloud images are created equal + if not os.path.exists("/usr/bin/python3") or not os.path.exists("/usr/bin/pip3"): + # Update the apt cache + subprocess.check_call(["apt-get", "update"]) + + # Install the Python3 package + subprocess.check_call(["apt-get", "install", "-y", "python3", "python3-pip"],) + + + # Install the libjuju build dependencies + subprocess.check_call(["apt-get", "install", "-y", "libffi-dev", "libssl-dev"],) + + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "juju"], + ) + +from juju.controller import Controller + +# Quiet the debug logging +logging.getLogger('websockets.protocol').setLevel(logging.INFO) +logging.getLogger('juju.client.connection').setLevel(logging.WARN) +logging.getLogger('juju.model').setLevel(logging.WARN) +logging.getLogger('juju.machine').setLevel(logging.WARN) + + +class NetworkService: + """A lightweight interface to the Juju controller. + + This NetworkService client is specifically designed to allow a higher-level + "NS" charm to interoperate with "VNF" charms, allowing for the execution of + Primitives across other charms within the same model. + """ + endpoint = None + user = 'admin' + secret = None + port = 17070 + loop = None + client = None + model = None + cacert = None + + def __init__(self, user, secret, endpoint=None): + + self.user = user + self.secret = secret + if endpoint is None: + addresses = os.environ['JUJU_API_ADDRESSES'] + for address in addresses.split(' '): + self.endpoint = address + else: + self.endpoint = endpoint + + # Stash the name of the model + self.model = os.environ['JUJU_MODEL_NAME'] + + # Load the ca-cert from agent.conf + AGENT_PATH = os.path.dirname(os.environ['JUJU_CHARM_DIR']) + with open("{}/agent.conf".format(AGENT_PATH), "r") as f: + try: + y = yaml.safe_load(f) + self.cacert = y['cacert'] + except yaml.YAMLError as exc: + print("Unable to find Juju ca-cert.") + raise exc + + # Create our event loop + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + + async def connect(self): + """Connect to the Juju controller.""" + controller = Controller() + + print( + "Connecting to controller... ws://{}:{} as {}/{}".format( + self.endpoint, + self.port, + self.user, + self.secret[-4:].rjust(len(self.secret), "*"), + ) + ) + await controller.connect( + endpoint=self.endpoint, + username=self.user, + password=self.secret, + cacert=self.cacert, + ) + + return controller + + def __del__(self): + self.logout() + + async def disconnect(self): + """Disconnect from the Juju controller.""" + if self.client: + print("Disconnecting Juju controller") + await self.client.disconnect() + + def login(self): + """Login to the Juju controller.""" + if not self.client: + # Connect to the Juju API server + self.client = self.loop.run_until_complete(self.connect()) + return self.client + + def logout(self): + """Logout of the Juju controller.""" + + if self.loop: + print("Disconnecting from API") + self.loop.run_until_complete(self.disconnect()) + + def FormatApplicationName(self, *args): + """ + Generate a Juju-compatible Application name + + :param args tuple: Positional arguments to be used to construct the + application name. + + Limitations:: + - Only accepts characters a-z and non-consequitive dashes (-) + - Application name should not exceed 50 characters + + Examples:: + + FormatApplicationName("ping_pong_ns", "ping_vnf", "a") + """ + appname = "" + for c in "-".join(list(args)): + if c.isdigit(): + c = chr(97 + int(c)) + elif not c.isalpha(): + c = "-" + appname += c + + return re.sub('-+', '-', appname.lower()) + + def GetApplicationName(self, nsr_name, vnf_name, vnf_member_index): + """Get the runtime application name of a VNF/VDU. + + This will generate an application name matching the name of the deployed charm, + given the right parameters. + + :param nsr_name str: The name of the running Network Service, as specified at instantiation. + :param vnf_name str: The name of the VNF or VDU + :param vnf_member_index: The vnf-member-index as specified in the descriptor + """ + + application_name = self.FormatApplicationName(nsr_name, vnf_member_index, vnf_name) + + # This matches the logic used by the LCM + application_name = application_name[0:48] + vca_index = int(vnf_member_index) - 1 + application_name += '-' + chr(97 + vca_index // 26) + chr(97 + vca_index % 26) + + return application_name + + def ExecutePrimitiveGetOutput(self, application, primitive, params={}, timeout=600): + """Execute a single primitive and return it's output. + + This is a blocking method that will execute a single primitive and wait + for its completion before return it's output. + + :param application str: The application name provided by `GetApplicationName`. + :param primitive str: The name of the primitive to execute. + :param params list: A list of parameters. + :param timeout int: A timeout, in seconds, to wait for the primitive to finish. Defaults to 600 seconds. + """ + uuid = self.ExecutePrimitive(application, primitive, params) + + status = None + output = None + + starttime = time.time() + while(time.time() < starttime + timeout): + status = self.GetPrimitiveStatus(uuid) + if status in ['completed', 'failed']: + break + time.sleep(10) + + # When the primitive is done, get the output + if status in ['completed', 'failed']: + output = self.GetPrimitiveOutput(uuid) + + return output + + def ExecutePrimitive(self, application, primitive, params={}): + """Execute a primitive. + + This is a non-blocking method to execute a primitive. It will return + the UUID of the queued primitive execution, which you can use + for subsequent calls to `GetPrimitiveStatus` and `GetPrimitiveOutput`. + + :param application string: The name of the application + :param primitive string: The name of the Primitive. + :param params list: A list of parameters. + + :returns uuid string: The UUID of the executed Primitive + """ + uuid = None + + if not self.client: + self.login() + + model = self.loop.run_until_complete( + self.client.get_model(self.model) + ) + + # Get the application + if application in model.applications: + app = model.applications[application] + + # Execute the primitive + unit = app.units[0] + if unit: + action = self.loop.run_until_complete( + unit.run_action(primitive, **params) + ) + uuid = action.id + print("Executing action: {}".format(uuid)) + self.loop.run_until_complete( + model.disconnect() + ) + else: + # Invalid mapping: application not found. Raise exception + raise Exception("Application not found: {}".format(application)) + + return uuid + + def GetPrimitiveStatus(self, uuid): + """Get the status of a Primitive execution. + + This will return one of the following strings: + - pending + - running + - completed + - failed + + :param uuid string: The UUID of the executed Primitive. + :returns: The status of the executed Primitive + """ + status = None + + if not self.client: + self.login() + + model = self.loop.run_until_complete( + self.client.get_model(self.model) + ) + + status = self.loop.run_until_complete( + model.get_action_status(uuid) + ) + + self.loop.run_until_complete( + model.disconnect() + ) + + return status[uuid] + + def GetPrimitiveOutput(self, uuid): + """Get the output of a completed Primitive execution. + + + :param uuid string: The UUID of the executed Primitive. + :returns: The output of the execution, or None if it's still running. + """ + result = None + if not self.client: + self.login() + + model = self.loop.run_until_complete( + self.client.get_model(self.model) + ) + + result = self.loop.run_until_complete( + model.get_action_output(uuid) + ) + + self.loop.run_until_complete( + model.disconnect() + ) + + return result diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms.osm/charms/osm/sshproxy.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms.osm/charms/osm/sshproxy.py new file mode 100644 index 00000000..724b98cf --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms.osm/charms/osm/sshproxy.py @@ -0,0 +1,250 @@ +"""Module to help with executing commands over SSH.""" +## +# Copyright 2016 Canonical Ltd. +# All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +## + +# from charmhelpers.core import unitdata +# from charmhelpers.core.hookenv import log + +import io +import ipaddress + +import os +import socket +import shlex +import traceback +import sys + +from subprocess import ( + check_call, + Popen, + CalledProcessError, + PIPE, +) + +def install_dependencies(): + # Make sure Python3 + PIP are available + if not os.path.exists("/usr/bin/python3") or not os.path.exists("/usr/bin/pip3"): + # This is needed when running as a k8s charm, as the ubuntu:latest + # image doesn't include either package. + + # Update the apt cache + check_call(["apt-get", "update"]) + + # Install the Python3 package + check_call(["apt-get", "install", "-y", "python3", "python3-pip"],) + + # Install the build dependencies for our requirements (paramiko) + check_call(["apt-get", "install", "-y", "libffi-dev", "libssl-dev"],) + + check_call( + [sys.executable, "-m", "pip", "install", "paramiko"], + ) + +try: + import paramiko +except Exception as ex: + install_dependencies() + import paramiko + +class SSHProxy: + private_key_path = "/root/.ssh/id_sshproxy" + public_key_path = "/root/.ssh/id_sshproxy.pub" + key_type = "rsa" + key_bits = 4096 + + def __init__(self, hostname: str, username: str, password: str = ""): + self.hostname = hostname + self.username = username + self.password = password + + @staticmethod + def generate_ssh_key(): + """Generate a 4096-bit rsa keypair.""" + if not os.path.exists(SSHProxy.private_key_path): + cmd = "ssh-keygen -t {} -b {} -N '' -f {}".format( + SSHProxy.key_type, SSHProxy.key_bits, SSHProxy.private_key_path, + ) + + try: + check_call(cmd, shell=True) + except CalledProcessError: + return False + + return True + + @staticmethod + def write_ssh_keys(public, private): + """Write a 4096-bit rsa keypair.""" + with open(SSHProxy.public_key_path, "w") as f: + f.write(public) + f.close() + with open(SSHProxy.private_key_path, "w") as f: + f.write(private) + f.close() + + @staticmethod + def get_ssh_public_key(): + publickey = "" + if os.path.exists(SSHProxy.private_key_path): + with open(SSHProxy.public_key_path, "r") as f: + publickey = f.read() + return publickey + + @staticmethod + def get_ssh_private_key(): + privatekey = "" + if os.path.exists(SSHProxy.private_key_path): + with open(SSHProxy.private_key_path, "r") as f: + privatekey = f.read() + return privatekey + + @staticmethod + def has_ssh_key(): + return True if os.path.exists(SSHProxy.private_key_path) else False + + def run(self, cmd: str) -> (str, str): + """Run a command remotely via SSH. + + Note: The previous behavior was to run the command locally if SSH wasn't + configured, but that can lead to cases where execution succeeds when you'd + expect it not to. + """ + if isinstance(cmd, str): + cmd = shlex.split(cmd) + + host = self._get_hostname() + user = self.username + passwd = self.password + key = self.private_key_path + + # Make sure we have everything we need to connect + if host and user: + return self._ssh(cmd) + + raise Exception("Invalid SSH credentials.") + + def sftp(self, local, remote): + client = self._get_ssh_client() + + # Create an sftp connection from the underlying transport + sftp = paramiko.SFTPClient.from_transport(client.get_transport()) + sftp.put(local, remote) + client.close() + pass + + def verify_credentials(self): + """Verify the SSH credentials. + + :return (bool, str): Verified, Stderr + """ + try: + (stdout, stderr) = self.run("hostname") + except CalledProcessError as e: + stderr = "Command failed: {} ({})".format(" ".join(e.cmd), str(e.output)) + except paramiko.ssh_exception.AuthenticationException as e: + stderr = "{}.".format(e) + except paramiko.ssh_exception.BadAuthenticationType as e: + stderr = "{}".format(e.explanation) + except paramiko.ssh_exception.BadHostKeyException as e: + stderr = "Host key mismatch: expected {} but got {}.".format( + e.expected_key, e.got_key, + ) + except (TimeoutError, socket.timeout): + stderr = "Timeout attempting to reach {}".format(self._get_hostname()) + except Exception as error: + tb = traceback.format_exc() + stderr = "Unhandled exception: {}".format(tb) + + if len(stderr) == 0: + return True, stderr + return False, stderr + + ################### + # Private methods # + ################### + def _get_hostname(self): + """Get the hostname for the ssh target. + + HACK: This function was added to work around an issue where the + ssh-hostname was passed in the format of a.b.c.d;a.b.c.d, where the first + is the floating ip, and the second the non-floating ip, for an Openstack + instance. + """ + return self.hostname.split(";")[0] + + def _get_ssh_client(self): + """Return a connected Paramiko ssh object.""" + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + pkey = None + + # Otherwise, check for the auto-generated private key + if os.path.exists(self.private_key_path): + with open(self.private_key_path) as f: + pkey = paramiko.RSAKey.from_private_key(f) + + ########################################################################### + # There is a bug in some versions of OpenSSH 4.3 (CentOS/RHEL 5) where # + # the server may not send the SSH_MSG_USERAUTH_BANNER message except when # + # responding to an auth_none request. For example, paramiko will attempt # + # to use password authentication when a password is set, but the server # + # could deny that, instead requesting keyboard-interactive. The hack to # + # workaround this is to attempt a reconnect, which will receive the right # + # banner, and authentication can proceed. See the following for more info # + # https://github.com/paramiko/paramiko/issues/432 # + # https://github.com/paramiko/paramiko/pull/438 # + ########################################################################### + + try: + client.connect( + self.hostname, + port=22, + username=self.username, + password=self.password, + pkey=pkey, + ) + except paramiko.ssh_exception.SSHException as e: + if "Error reading SSH protocol banner" == str(e): + # Once more, with feeling + client.connect( + host, port=22, username=user, password=password, pkey=pkey + ) + else: + # Reraise the original exception + raise e + + return client + + def _ssh(self, cmd): + """Run an arbitrary command over SSH. + + Returns a tuple of (stdout, stderr) + """ + client = self._get_ssh_client() + + cmds = " ".join(cmd) + stdin, stdout, stderr = client.exec_command(cmds, get_pty=True) + retcode = stdout.channel.recv_exit_status() + client.close() # @TODO re-use connections + if retcode > 0: + output = stderr.read().strip() + raise CalledProcessError(returncode=retcode, cmd=cmd, output=output) + return ( + stdout.read().decode("utf-8").strip(), + stderr.read().decode("utf-8").strip(), + ) diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms/LICENSE b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms/README.md b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms/README.md new file mode 100644 index 00000000..a5667070 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms/README.md @@ -0,0 +1,29 @@ +# charms.requirementstxt + +A Python library, to aid the development of charms, that will automatically install Python dependencies as declared by a `requirements.txt` file in the root of the charm. + +## Usage + +Install the charms.requirementstxt library in your charm: + +```bash +git submodule add https://github.com/AdamIsrael/charms.osm mod/charms.osm +mkdir -p lib/charms +ln -s ../mod/charms.osm/charms/osm lib/charms/osm +``` + +Import the `charms.requirementstxt` library early, before any dependencies it may install. + +In `src/charm.py`: + +```python +#!/usr/bin/env python3 + +import sys + +sys.path.append("lib") + +import charms.requirementstxt +... + +``` \ No newline at end of file diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms/charms/requirementstxt.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms/charms/requirementstxt.py new file mode 100644 index 00000000..298d5845 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/charms/charms/requirementstxt.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +# Requirements.txt support + +import sys + +sys.path.append("lib") + +from ops.framework import StoredState + +import os +import subprocess +import sys +from remote_pdb import RemotePdb + +REQUIREMENTS_TXT = "{}/requirements.txt".format(os.environ["JUJU_CHARM_DIR"]) + + +def install_requirements(): + if os.path.exists(REQUIREMENTS_TXT): + + # First, make sure python3 and python3-pip are installed + if not os.path.exists("/usr/bin/python3") or not os.path.exists("/usr/bin/pip3"): + # Update the apt cache + subprocess.check_call(["apt-get", "update"]) + # Install the Python3 package + subprocess.check_call( + ["apt-get", "install", "-y", "python3", "python3-pip", "python3-paramiko"], + # Eat stdout so it's not returned in an action's stdout + # TODO: redirect to a file handle and log to juju log + # stdout=subprocess.DEVNULL, + ) + + # Lastly, install the python requirements + cmd = [sys.executable, "-m", "pip", "install", "-r", REQUIREMENTS_TXT] + # stdout = subprocess.check_output(cmd) + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) + + stdout, stderr = p.communicate() + + print(stdout) + print(stderr) + # subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", REQUIREMENTS_TXT], + # # Eat stdout so it's not returned in an action's stdout + # # TODO: redirect to a file handle and log to juju log + # # stdout=subprocess.DEVNULL, + # ) + + +# Use StoredState to make sure we're run exactly once automatically +# RemotePdb('127.0.0.1', 4444).set_trace() + +state = StoredState() + +installed = getattr(state, "requirements_txt_installed", None) +if not installed: + install_requirements() + state.requirements_txt_installed = True + diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/.flake8 b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/.flake8 new file mode 100644 index 00000000..d0224f38 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 160 +exclude = sandbox diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/.gitignore b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/.gitignore new file mode 100644 index 00000000..cf0f3716 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/.gitignore @@ -0,0 +1,3 @@ +__pycache__ +/sandbox +.idea diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/.travis.yml b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/.travis.yml new file mode 100644 index 00000000..adfdcb93 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/.travis.yml @@ -0,0 +1,15 @@ +dist: bionic + +language: python + +python: + - "3.6" + - "3.7" + +install: + - sudo apt update + - sudo apt install flake8 make + - pip3 install pyyaml autopep8 + +script: + - make test diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/LICENSE.txt b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/LICENSE.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/Makefile b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/Makefile new file mode 100644 index 00000000..3c822540 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/Makefile @@ -0,0 +1,41 @@ +# Copyright 2019 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +test: lint + @python3 -m unittest + +lint: quotelint check-copyright + @autopep8 -r --aggressive --diff --exit-code . + @flake8 --config=.flake8 + +quotelint: + @x=$$(grep -rnH --include \*.py "\\\\[\"']"); \ + if [ "$$x" ]; then \ + echo "Please fix the quoting to avoid spurious backslashes:"; \ + echo "$$x"; \ + exit 1; \ + fi >&2 + +check-copyright: + @x=$$(find . -name \*.py -not -empty -type f -print0 | xargs -0 grep -L "^# Copyright"); \ + if [ "$$x" ]; then \ + echo "Please add copyright headers to the following files:"; \ + echo "$$x"; \ + exit 1; \ + fi >&2 + + + + +.PHONY: lint test quotelint check-copyright diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/README.md b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/README.md new file mode 100644 index 00000000..f684a828 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/README.md @@ -0,0 +1,120 @@ +# Operator Framework for Charms + +This framework is not yet stable and is subject to change, but is available +for early testing. + +## Getting Started + +The following overall structure for your charm directory is recommended: + +``` +. ++-- config.yaml ++-- metadata.yaml ++-- mod/ ++-- lib/ +| +-- ops -> ../mod/operator/ops ++-- src/ +| +-- charm.py ++-- hooks/ + +-- install -> ../src/charm.py + +-- start -> ../src/charm.py # for k8s charms per below +``` + +The `mod/` directory should contain the operator framework dependency as a git +submodule: + +``` +git submodule add https://github.com/canonical/operator mod/operator +``` + +Then symlink from the git submodule for the operator framework into the `lib/` +directory of your charm so it can be imported at run time: + +``` +ln -s ../mod/operator/ops lib/ops +``` + +Other dependencies included as git submodules can be added in the `mod/` +directory and symlinked into `lib/` as well. + +You can sync subsequent changes from the framework and other submodule +dependencies by running: + +``` +git submodule update +``` + +Those cloning and checking out the source for your charm for the first time +will need to run: + +``` +git submodule update --init +``` + +Your `src/charm.py` is the entry point for your charm logic. It should be set +to executable and use Python 3.6 or greater. At a minimum, it needs to define +a subclass of `CharmBase` and pass that into the framework's `main` function: + +```python +import sys +sys.path.append('lib') # noqa: E402 + +from ops.charm import CharmBase +from ops.main import main + + +class MyCharm(CharmBase): + pass + + +if __name__ == "__main__": + main(MyCharm) +``` + +This charm does nothing, because the `MyCharm` class passed to the operator +framework's `main` function is empty. Functionality can be added to the charm +by instructing it to observe particular Juju events when the `MyCharm` object +is initialized. For example, + +```python +class MyCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.start, self.on_start) + + def on_start(self, event): + # Handle the start event here. +``` + +Every standard event in Juju may be observed that way, and you can also easily +define your own events in your custom types. + +> The second argument to `observe` can be either the handler as a bound +> method, or the observer itself if the handler is a method of the observer +> that follows the conventional naming pattern. That is, in this case, we +> could have called just `self.framework.obseve(self.on.start, self)`. + +The `hooks/` directory must contain a symlink to your `src/charm.py` entry +point so that Juju can call it. You only need to set up the `hooks/install` link +(`hooks/start` for K8s charms, until [lp#1854635](https://bugs.launchpad.net/juju/+bug/1854635) +is resolved), and the framework will create all others at runtime. + +Once your charm is ready, upload it to the charm store and deploy it as +normal with: + +``` +# Replace ${CHARM} with the name of the charm. +charm push . cs:~${USER}/${CHARM} +# Replace ${VERSION} with the version created by `charm push`. +charm release cs:~${USER}/${CHARM}-${VERSION} +charm grant cs:~${USER}/${CHARM}-${VERSION} everyone +# And now deploy your charm. +juju deploy cs:~${USER}/$CHARM +``` + +Alternatively, to deploy directly from local disk, run: + +``` +juju deploy . +``` diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/__init__.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/charm.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/charm.py new file mode 100755 index 00000000..71472f96 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/charm.py @@ -0,0 +1,306 @@ +# Copyright 2019 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os + +import yaml + +from ops.framework import Object, EventSource, EventBase, EventsBase + + +class HookEvent(EventBase): + pass + + +class ActionEvent(EventBase): + + def defer(self): + raise RuntimeError('cannot defer action events') + + def restore(self, snapshot): + env_action_name = os.environ.get('JUJU_ACTION_NAME') + event_action_name = self.handle.kind[:-len('_action')].replace('_', '-') + if event_action_name != env_action_name: + # This could only happen if the dev manually emits the action, or from a bug. + raise RuntimeError('action event kind does not match current action') + # Params are loaded at restore rather than __init__ because the model is not available in __init__. + self.params = self.framework.model._backend.action_get() + + def set_results(self, results): + self.framework.model._backend.action_set(results) + + def log(self, message): + self.framework.model._backend.action_log(message) + + def fail(self, message=''): + self.framework.model._backend.action_fail(message) + + +class InstallEvent(HookEvent): + pass + + +class StartEvent(HookEvent): + pass + + +class StopEvent(HookEvent): + pass + + +class ConfigChangedEvent(HookEvent): + pass + + +class UpdateStatusEvent(HookEvent): + pass + + +class UpgradeCharmEvent(HookEvent): + pass + + +class PreSeriesUpgradeEvent(HookEvent): + pass + + +class PostSeriesUpgradeEvent(HookEvent): + pass + + +class LeaderElectedEvent(HookEvent): + pass + + +class LeaderSettingsChangedEvent(HookEvent): + pass + + +class RelationEvent(HookEvent): + def __init__(self, handle, relation, app=None, unit=None): + super().__init__(handle) + + if unit and unit.app != app: + raise RuntimeError(f'cannot create RelationEvent with application {app} and unit {unit}') + + self.relation = relation + self.app = app + self.unit = unit + + def snapshot(self): + snapshot = { + 'relation_name': self.relation.name, + 'relation_id': self.relation.id, + } + if self.app: + snapshot['app_name'] = self.app.name + if self.unit: + snapshot['unit_name'] = self.unit.name + return snapshot + + def restore(self, snapshot): + self.relation = self.framework.model.get_relation(snapshot['relation_name'], snapshot['relation_id']) + + app_name = snapshot.get('app_name') + if app_name: + self.app = self.framework.model.get_app(app_name) + else: + self.app = None + + unit_name = snapshot.get('unit_name') + if unit_name: + self.unit = self.framework.model.get_unit(unit_name) + else: + self.unit = None + + +class RelationJoinedEvent(RelationEvent): + pass + + +class RelationChangedEvent(RelationEvent): + pass + + +class RelationDepartedEvent(RelationEvent): + pass + + +class RelationBrokenEvent(RelationEvent): + pass + + +class StorageEvent(HookEvent): + pass + + +class StorageAttachedEvent(StorageEvent): + pass + + +class StorageDetachingEvent(StorageEvent): + pass + + +class CharmEvents(EventsBase): + + install = EventSource(InstallEvent) + start = EventSource(StartEvent) + stop = EventSource(StopEvent) + update_status = EventSource(UpdateStatusEvent) + config_changed = EventSource(ConfigChangedEvent) + upgrade_charm = EventSource(UpgradeCharmEvent) + pre_series_upgrade = EventSource(PreSeriesUpgradeEvent) + post_series_upgrade = EventSource(PostSeriesUpgradeEvent) + leader_elected = EventSource(LeaderElectedEvent) + leader_settings_changed = EventSource(LeaderSettingsChangedEvent) + + +class CharmBase(Object): + + on = CharmEvents() + + def __init__(self, framework, key): + super().__init__(framework, key) + + for relation_name in self.framework.meta.relations: + relation_name = relation_name.replace('-', '_') + self.on.define_event(f'{relation_name}_relation_joined', RelationJoinedEvent) + self.on.define_event(f'{relation_name}_relation_changed', RelationChangedEvent) + self.on.define_event(f'{relation_name}_relation_departed', RelationDepartedEvent) + self.on.define_event(f'{relation_name}_relation_broken', RelationBrokenEvent) + + for storage_name in self.framework.meta.storages: + storage_name = storage_name.replace('-', '_') + self.on.define_event(f'{storage_name}_storage_attached', StorageAttachedEvent) + self.on.define_event(f'{storage_name}_storage_detaching', StorageDetachingEvent) + + for action_name in self.framework.meta.actions: + action_name = action_name.replace('-', '_') + self.on.define_event(f'{action_name}_action', ActionEvent) + + +class CharmMeta: + """Object containing the metadata for the charm. + + The maintainers, tags, terms, series, and extra_bindings attributes are all + lists of strings. The requires, provides, peers, relations, storage, + resources, and payloads attributes are all mappings of names to instances + of the respective RelationMeta, StorageMeta, ResourceMeta, or PayloadMeta. + + The relations attribute is a convenience accessor which includes all of the + requires, provides, and peers RelationMeta items. If needed, the role of + the relation definition can be obtained from its role attribute. + """ + + def __init__(self, raw={}, actions_raw={}): + self.name = raw.get('name', '') + self.summary = raw.get('summary', '') + self.description = raw.get('description', '') + self.maintainers = [] + if 'maintainer' in raw: + self.maintainers.append(raw['maintainer']) + if 'maintainers' in raw: + self.maintainers.extend(raw['maintainers']) + self.tags = raw.get('tags', []) + self.terms = raw.get('terms', []) + self.series = raw.get('series', []) + self.subordinate = raw.get('subordinate', False) + self.min_juju_version = raw.get('min-juju-version') + self.requires = {name: RelationMeta('requires', name, rel) + for name, rel in raw.get('requires', {}).items()} + self.provides = {name: RelationMeta('provides', name, rel) + for name, rel in raw.get('provides', {}).items()} + self.peers = {name: RelationMeta('peers', name, rel) + for name, rel in raw.get('peers', {}).items()} + self.relations = {} + self.relations.update(self.requires) + self.relations.update(self.provides) + self.relations.update(self.peers) + self.storages = {name: StorageMeta(name, storage) + for name, storage in raw.get('storage', {}).items()} + self.resources = {name: ResourceMeta(name, res) + for name, res in raw.get('resources', {}).items()} + self.payloads = {name: PayloadMeta(name, payload) + for name, payload in raw.get('payloads', {}).items()} + self.extra_bindings = raw.get('extra-bindings', []) + self.actions = {name: ActionMeta(name, action) for name, action in actions_raw.items()} + + @classmethod + def from_yaml(cls, metadata, actions=None): + meta = yaml.safe_load(metadata) + raw_actions = {} + if actions is not None: + raw_actions = yaml.safe_load(actions) + return cls(meta, raw_actions) + + +class RelationMeta: + """Object containing metadata about a relation definition.""" + + def __init__(self, role, relation_name, raw): + self.role = role + self.relation_name = relation_name + self.interface_name = raw['interface'] + self.scope = raw.get('scope') + + +class StorageMeta: + """Object containing metadata about a storage definition.""" + + def __init__(self, name, raw): + self.storage_name = name + self.type = raw['type'] + self.description = raw.get('description', '') + self.shared = raw.get('shared', False) + self.read_only = raw.get('read-only', False) + self.minimum_size = raw.get('minimum-size') + self.location = raw.get('location') + self.multiple_range = None + if 'multiple' in raw: + range = raw['multiple']['range'] + if '-' not in range: + self.multiple_range = (int(range), int(range)) + else: + range = range.split('-') + self.multiple_range = (int(range[0]), int(range[1]) if range[1] else None) + + +class ResourceMeta: + """Object containing metadata about a resource definition.""" + + def __init__(self, name, raw): + self.resource_name = name + self.type = raw['type'] + self.filename = raw.get('filename', None) + self.description = raw.get('description', '') + + +class PayloadMeta: + """Object containing metadata about a payload definition.""" + + def __init__(self, name, raw): + self.payload_name = name + self.type = raw['type'] + + +class ActionMeta: + + def __init__(self, name, raw=None): + raw = raw or {} + self.name = name + self.title = raw.get('title', '') + self.description = raw.get('description', '') + self.parameters = raw.get('params', {}) # {: } + self.required = raw.get('required', []) # [, ...] diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/framework.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/framework.py new file mode 100755 index 00000000..d95eb61f --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/framework.py @@ -0,0 +1,941 @@ +# Copyright 2019 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import inspect +import pickle +import marshal +import types +import sqlite3 +import collections +import collections.abc +import keyword +import weakref +from datetime import timedelta + + +class Handle: + """Handle defines a name for an object in the form of a hierarchical path. + + The provided parent is the object (or that object's handle) that this handle + sits under, or None if the object identified by this handle stands by itself + as the root of its own hierarchy. + + The handle kind is a string that defines a namespace so objects with the + same parent and kind will have unique keys. + + The handle key is a string uniquely identifying the object. No other objects + under the same parent and kind may have the same key. + """ + + def __init__(self, parent, kind, key): + if parent and not isinstance(parent, Handle): + parent = parent.handle + self._parent = parent + self._kind = kind + self._key = key + if parent: + if key: + self._path = f"{parent}/{kind}[{key}]" + else: + self._path = f"{parent}/{kind}" + else: + if key: + self._path = f"{kind}[{key}]" + else: + self._path = f"{kind}" + + def nest(self, kind, key): + return Handle(self, kind, key) + + def __hash__(self): + return hash((self.parent, self.kind, self.key)) + + def __eq__(self, other): + return (self.parent, self.kind, self.key) == (other.parent, other.kind, other.key) + + def __str__(self): + return self.path + + @property + def parent(self): + return self._parent + + @property + def kind(self): + return self._kind + + @property + def key(self): + return self._key + + @property + def path(self): + return self._path + + @classmethod + def from_path(cls, path): + handle = None + for pair in path.split("/"): + pair = pair.split("[") + good = False + if len(pair) == 1: + kind, key = pair[0], None + good = True + elif len(pair) == 2: + kind, key = pair + if key and key[-1] == ']': + key = key[:-1] + good = True + if not good: + raise RuntimeError("attempted to restore invalid handle path {path}") + handle = Handle(handle, kind, key) + return handle + + +class EventBase: + + def __init__(self, handle): + self.handle = handle + self.deferred = False + + def defer(self): + self.deferred = True + + def snapshot(self): + """Return the snapshot data that should be persisted. + + Subclasses must override to save any custom state. + """ + return None + + def restore(self, snapshot): + """Restore the value state from the given snapshot. + + Subclasses must override to restore their custom state. + """ + self.deferred = False + + +class EventSource: + """EventSource wraps an event type with a descriptor to facilitate observing and emitting. + + It is generally used as: + + class SomethingHappened(EventBase): + pass + + class SomeObject(Object): + something_happened = EventSource(SomethingHappened) + + With that, instances of that type will offer the someobj.something_happened + attribute which is a BoundEvent and may be used to emit and observe the event. + """ + + def __init__(self, event_type): + if not isinstance(event_type, type) or not issubclass(event_type, EventBase): + raise RuntimeError(f"Event requires a subclass of EventBase as an argument, got {event_type}") + self.event_type = event_type + self.event_kind = None + self.emitter_type = None + + def __set_name__(self, emitter_type, event_kind): + if self.event_kind is not None: + raise RuntimeError( + f'EventSource({self.event_type.__name__}) reused as ' + f'{self.emitter_type.__name__}.{self.event_kind} and ' + f'{emitter_type.__name__}.{event_kind}') + self.event_kind = event_kind + self.emitter_type = emitter_type + + def __get__(self, emitter, emitter_type=None): + if emitter is None: + return self + # Framework might not be available if accessed as CharmClass.on.event rather than charm_instance.on.event, + # but in that case it couldn't be emitted anyway, so there's no point to registering it. + framework = getattr(emitter, 'framework', None) + if framework is not None: + framework.register_type(self.event_type, emitter, self.event_kind) + return BoundEvent(emitter, self.event_type, self.event_kind) + + +class BoundEvent: + + def __repr__(self): + return (f'') + + def __init__(self, emitter, event_type, event_kind): + self.emitter = emitter + self.event_type = event_type + self.event_kind = event_kind + + def emit(self, *args, **kwargs): + """Emit event to all registered observers. + + The current storage state is committed before and after each observer is notified. + """ + framework = self.emitter.framework + key = framework._next_event_key() + event = self.event_type(Handle(self.emitter, self.event_kind, key), *args, **kwargs) + framework._emit(event) + + +class HandleKind: + """Helper descriptor to define the Object.handle_kind field. + + The handle_kind for an object defaults to its type name, but it may + be explicitly overridden if desired. + """ + + def __get__(self, obj, obj_type): + kind = obj_type.__dict__.get("handle_kind") + if kind: + return kind + return obj_type.__name__ + + +class Object: + + handle_kind = HandleKind() + + def __init__(self, parent, key): + kind = self.handle_kind + if isinstance(parent, Framework): + self.framework = parent + # Avoid Framework instances having a circular reference to themselves. + if self.framework is self: + self.framework = weakref.proxy(self.framework) + self.handle = Handle(None, kind, key) + else: + self.framework = parent.framework + self.handle = Handle(parent, kind, key) + self.framework._track(self) + + # TODO Detect conflicting handles here. + + @property + def model(self): + return self.framework.model + + @property + def meta(self): + return self.framework.meta + + @property + def charm_dir(self): + return self.framework.charm_dir + + +class EventsBase(Object): + """Convenience type to allow defining .on attributes at class level.""" + + handle_kind = "on" + + def __init__(self, parent=None, key=None): + if parent is not None: + super().__init__(parent, key) + else: + self._cache = weakref.WeakKeyDictionary() + + def __get__(self, emitter, emitter_type): + if emitter is None: + return self + instance = self._cache.get(emitter) + if instance is None: + # Same type, different instance, more data. Doing this unusual construct + # means people can subclass just this one class to have their own 'on'. + instance = self._cache[emitter] = type(self)(emitter) + return instance + + @classmethod + def define_event(cls, event_kind, event_type): + """Define an event on this type at runtime. + + cls -- a type to define an event on. + event_kind -- an attribute name that will be used to access the event. Must be a valid python identifier, not be a keyword or an existing attribute. + event_type -- a type of the event to define. + """ + if not event_kind.isidentifier(): + raise RuntimeError(f'unable to define an event with event_kind that is not a valid python identifier: {event_kind}') + elif keyword.iskeyword(event_kind): + raise RuntimeError(f'unable to define an event with event_kind that is a python keyword: {event_kind}') + try: + getattr(cls, event_kind) + raise RuntimeError(f'unable to define an event with event_kind that overlaps with an existing type {cls} attribute: {event_kind}') + except AttributeError: + pass + + event_descriptor = EventSource(event_type) + event_descriptor.__set_name__(cls, event_kind) + setattr(cls, event_kind, event_descriptor) + + def events(self): + """Return a mapping of event_kinds to bound_events for all available events. + """ + events_map = {} + # We have to iterate over the class rather than instance to allow for properties which + # might call this method (e.g., event views), leading to infinite recursion. + for attr_name, attr_value in inspect.getmembers(type(self)): + if isinstance(attr_value, EventSource): + # We actually care about the bound_event, however, since it + # provides the most info for users of this method. + event_kind = attr_name + bound_event = getattr(self, event_kind) + events_map[event_kind] = bound_event + return events_map + + def __getitem__(self, key): + return PrefixedEvents(self, key) + + +class PrefixedEvents: + + def __init__(self, emitter, key): + self._emitter = emitter + self._prefix = key.replace("-", "_") + '_' + + def __getattr__(self, name): + return getattr(self._emitter, self._prefix + name) + + +class PreCommitEvent(EventBase): + pass + + +class CommitEvent(EventBase): + pass + + +class FrameworkEvents(EventsBase): + pre_commit = EventSource(PreCommitEvent) + commit = EventSource(CommitEvent) + + +class NoSnapshotError(Exception): + + def __init__(self, handle_path): + self.handle_path = handle_path + + def __str__(self): + return f'no snapshot data found for {self.handle_path} object' + + +class NoTypeError(Exception): + + def __init__(self, handle_path): + self.handle_path = handle_path + + def __str__(self): + return f"cannot restore {self.handle_path} since no class was registered for it" + + +class SQLiteStorage: + + DB_LOCK_TIMEOUT = timedelta(hours=1) + + def __init__(self, filename): + # The isolation_level argument is set to None such that the implicit transaction management behavior of the sqlite3 module is disabled. + self._db = sqlite3.connect(str(filename), isolation_level=None, timeout=self.DB_LOCK_TIMEOUT.total_seconds()) + self._setup() + + def _setup(self): + # Make sure that the database is locked until the connection is closed, not until the transaction ends. + self._db.execute("PRAGMA locking_mode=EXCLUSIVE") + c = self._db.execute("BEGIN") + c.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='snapshot'") + if c.fetchone()[0] == 0: + # Keep in mind what might happen if the process dies somewhere below. + # The system must not be rendered permanently broken by that. + self._db.execute("CREATE TABLE snapshot (handle TEXT PRIMARY KEY, data BLOB)") + self._db.execute("CREATE TABLE notice (sequence INTEGER PRIMARY KEY AUTOINCREMENT, event_path TEXT, observer_path TEXT, method_name TEXT)") + self._db.commit() + + def close(self): + self._db.close() + + def commit(self): + self._db.commit() + + # There's commit but no rollback. For abort to be supported, we'll need logic that + # can rollback decisions made by third-party code in terms of the internal state + # of objects that have been snapshotted, and hooks to let them know about it and + # take the needed actions to undo their logic until the last snapshot. + # This is doable but will increase significantly the chances for mistakes. + + def save_snapshot(self, handle_path, snapshot_data): + self._db.execute("REPLACE INTO snapshot VALUES (?, ?)", (handle_path, snapshot_data)) + + def load_snapshot(self, handle_path): + c = self._db.cursor() + c.execute("SELECT data FROM snapshot WHERE handle=?", (handle_path,)) + row = c.fetchone() + if row: + return row[0] + return None + + def drop_snapshot(self, handle_path): + self._db.execute("DELETE FROM snapshot WHERE handle=?", (handle_path,)) + + def save_notice(self, event_path, observer_path, method_name): + self._db.execute("INSERT INTO notice VALUES (NULL, ?, ?, ?)", (event_path, observer_path, method_name)) + + def drop_notice(self, event_path, observer_path, method_name): + self._db.execute("DELETE FROM notice WHERE event_path=? AND observer_path=? AND method_name=?", (event_path, observer_path, method_name)) + + def notices(self, event_path): + if event_path: + c = self._db.execute("SELECT event_path, observer_path, method_name FROM notice WHERE event_path=? ORDER BY sequence", (event_path,)) + else: + c = self._db.execute("SELECT event_path, observer_path, method_name FROM notice ORDER BY sequence") + while True: + rows = c.fetchmany() + if not rows: + break + for row in rows: + yield tuple(row) + + +class Framework(Object): + + on = FrameworkEvents() + + # Override properties from Object so that we can set them in __init__. + model = None + meta = None + charm_dir = None + + def __init__(self, data_path, charm_dir, meta, model): + + super().__init__(self, None) + + self._data_path = data_path + self.charm_dir = charm_dir + self.meta = meta + self.model = model + self._observers = [] # [(observer_path, method_name, parent_path, event_key)] + self._observer = weakref.WeakValueDictionary() # {observer_path: observer} + self._objects = weakref.WeakValueDictionary() + self._type_registry = {} # {(parent_path, kind): cls} + self._type_known = set() # {cls} + + self._storage = SQLiteStorage(data_path) + + # We can't use the higher-level StoredState because it relies on events. + self.register_type(StoredStateData, None, StoredStateData.handle_kind) + stored_handle = Handle(None, StoredStateData.handle_kind, '_stored') + try: + self._stored = self.load_snapshot(stored_handle) + except NoSnapshotError: + self._stored = StoredStateData(self, '_stored') + self._stored['event_count'] = 0 + + def close(self): + self._storage.close() + + def _track(self, obj): + """Track object and ensure it is the only object created using its handle path.""" + if obj is self: + # Framework objects don't track themselves + return + if obj.handle.path in self.framework._objects: + raise RuntimeError(f"two objects claiming to be {obj.handle.path} have been created") + self._objects[obj.handle.path] = obj + + def _forget(self, obj): + """Stop tracking the given object. See also _track.""" + self._objects.pop(obj.handle.path, None) + + def commit(self): + # Give a chance for objects to persist data they want to before a commit is made. + self.on.pre_commit.emit() + # Make sure snapshots are saved by instances of StoredStateData. Any possible state + # modifications in on_commit handlers of instances of other classes will not be persisted. + self.on.commit.emit() + # Save our event count after all events have been emitted. + self.save_snapshot(self._stored) + self._storage.commit() + + def register_type(self, cls, parent, kind=None): + if parent and not isinstance(parent, Handle): + parent = parent.handle + if parent: + parent_path = parent.path + else: + parent_path = None + if not kind: + kind = cls.handle_kind + self._type_registry[(parent_path, kind)] = cls + self._type_known.add(cls) + + def save_snapshot(self, value): + """Save a persistent snapshot of the provided value. + + The provided value must implement the following interface: + + value.handle = Handle(...) + value.snapshot() => {...} # Simple builtin types only. + value.restore(snapshot) # Restore custom state from prior snapshot. + """ + if type(value) not in self._type_known: + raise RuntimeError(f"cannot save {type(value).__name__} values before registering that type") + data = value.snapshot() + # Use marshal as a validator, enforcing the use of simple types. + marshal.dumps(data) + # Use pickle for serialization, so the value remains portable. + raw_data = pickle.dumps(data) + self._storage.save_snapshot(value.handle.path, raw_data) + + def load_snapshot(self, handle): + parent_path = None + if handle.parent: + parent_path = handle.parent.path + cls = self._type_registry.get((parent_path, handle.kind)) + if not cls: + raise NoTypeError(handle.path) + raw_data = self._storage.load_snapshot(handle.path) + if not raw_data: + raise NoSnapshotError(handle.path) + data = pickle.loads(raw_data) + obj = cls.__new__(cls) + obj.framework = self + obj.handle = handle + obj.restore(data) + self._track(obj) + return obj + + def drop_snapshot(self, handle): + self._storage.drop_snapshot(handle.path) + + def observe(self, bound_event, observer): + """Register observer to be called when bound_event is emitted. + + The bound_event is generally provided as an attribute of the object that emits + the event, and is created in this style: + + class SomeObject: + something_happened = Event(SomethingHappened) + + That event may be observed as: + + framework.observe(someobj.something_happened, self.on_something_happened) + + If the method to be called follows the name convention "on_", it + may be omitted from the observe call. That means the above is equivalent to: + + framework.observe(someobj.something_happened, self) + + """ + if not isinstance(bound_event, BoundEvent): + raise RuntimeError(f'Framework.observe requires a BoundEvent as second parameter, got {bound_event}') + + event_type = bound_event.event_type + event_kind = bound_event.event_kind + emitter = bound_event.emitter + + self.register_type(event_type, emitter, event_kind) + + if hasattr(emitter, "handle"): + emitter_path = emitter.handle.path + else: + raise RuntimeError(f'event emitter {type(emitter).__name__} must have a "handle" attribute') + + method_name = None + if isinstance(observer, types.MethodType): + method_name = observer.__name__ + observer = observer.__self__ + else: + method_name = "on_" + event_kind + if not hasattr(observer, method_name): + raise RuntimeError(f'Observer method not provided explicitly and {type(observer).__name__} type has no "{method_name}" method') + + # Validate that the method has an acceptable call signature. + sig = inspect.signature(getattr(observer, method_name)) + # Self isn't included in the params list, so the first arg will be the event. + extra_params = list(sig.parameters.values())[1:] + if not sig.parameters: + raise TypeError(f'{type(observer).__name__}.{method_name} must accept event parameter') + elif any(param.default is inspect.Parameter.empty for param in extra_params): + # Allow for additional optional params, since there's no reason to exclude them, but + # required params will break. + raise TypeError(f'{type(observer).__name__}.{method_name} has extra required parameter') + + # TODO Prevent the exact same parameters from being registered more than once. + + self._observer[observer.handle.path] = observer + self._observers.append((observer.handle.path, method_name, emitter_path, event_kind)) + + def _next_event_key(self): + """Return the next event key that should be used, incrementing the internal counter.""" + # Increment the count first; this means the keys will start at 1, and 0 means no events have been emitted. + self._stored['event_count'] += 1 + return str(self._stored['event_count']) + + def _emit(self, event): + """See BoundEvent.emit for the public way to call this.""" + + # Save the event for all known observers before the first notification + # takes place, so that either everyone interested sees it, or nobody does. + self.save_snapshot(event) + event_path = event.handle.path + event_kind = event.handle.kind + parent_path = event.handle.parent.path + # TODO Track observers by (parent_path, event_kind) rather than as a list of all observers. Avoiding linear search through all observers for every event + for observer_path, method_name, _parent_path, _event_kind in self._observers: + if _parent_path != parent_path: + continue + if _event_kind and _event_kind != event_kind: + continue + # Again, only commit this after all notices are saved. + self._storage.save_notice(event_path, observer_path, method_name) + self._reemit(event_path) + + def reemit(self): + """Reemit previously deferred events to the observers that deferred them. + + Only the specific observers that have previously deferred the event will be + notified again. Observers that asked to be notified about events after it's + been first emitted won't be notified, as that would mean potentially observing + events out of order. + """ + self._reemit() + + def _reemit(self, single_event_path=None): + last_event_path = None + deferred = True + for event_path, observer_path, method_name in self._storage.notices(single_event_path): + event_handle = Handle.from_path(event_path) + + if last_event_path != event_path: + if not deferred: + self._storage.drop_snapshot(last_event_path) + last_event_path = event_path + deferred = False + + try: + event = self.load_snapshot(event_handle) + except NoTypeError: + self._storage.drop_notice(event_path, observer_path, method_name) + continue + + event.deferred = False + observer = self._observer.get(observer_path) + if observer: + custom_handler = getattr(observer, method_name, None) + if custom_handler: + custom_handler(event) + + if event.deferred: + deferred = True + else: + self._storage.drop_notice(event_path, observer_path, method_name) + # We intentionally consider this event to be dead and reload it from scratch in the next path. + self.framework._forget(event) + + if not deferred: + self._storage.drop_snapshot(last_event_path) + + +class StoredStateChanged(EventBase): + pass + + +class StoredStateEvents(EventsBase): + changed = EventSource(StoredStateChanged) + + +class StoredStateData(Object): + + on = StoredStateEvents() + + def __init__(self, parent, attr_name): + super().__init__(parent, attr_name) + self._cache = {} + self.dirty = False + + def __getitem__(self, key): + return self._cache.get(key) + + def __setitem__(self, key, value): + self._cache[key] = value + self.dirty = True + + def __contains__(self, key): + return key in self._cache + + def snapshot(self): + return self._cache + + def restore(self, snapshot): + self._cache = snapshot + self.dirty = False + + def on_commit(self, event): + if self.dirty: + self.framework.save_snapshot(self) + self.dirty = False + + +class BoundStoredState: + + def __init__(self, parent, attr_name): + parent.framework.register_type(StoredStateData, parent) + + handle = Handle(parent, StoredStateData.handle_kind, attr_name) + try: + data = parent.framework.load_snapshot(handle) + except NoSnapshotError: + data = StoredStateData(parent, attr_name) + + # __dict__ is used to avoid infinite recursion. + self.__dict__["_data"] = data + self.__dict__["_attr_name"] = attr_name + + parent.framework.observe(parent.framework.on.commit, self._data) + + def __getattr__(self, key): + # "on" is the only reserved key that can't be used in the data map. + if key == "on": + return self._data.on + if key not in self._data: + raise AttributeError(f"attribute '{key}' is not stored") + return _wrap_stored(self._data, self._data[key]) + + def __setattr__(self, key, value): + if key == "on": + raise AttributeError(f"attribute 'on' is reserved and cannot be set") + + value = _unwrap_stored(self._data, value) + + if not isinstance(value, (type(None), int, str, bytes, list, dict, set)): + raise AttributeError(f"attribute '{key}' cannot be set to {type(value).__name__}: must be int/dict/list/etc") + + self._data[key] = _unwrap_stored(self._data, value) + self.on.changed.emit() + + def set_default(self, **kwargs): + """"Set the value of any given key if it has not already been set""" + for k, v in kwargs.items(): + if k not in self._data: + self._data[k] = v + + +class StoredState: + + def __init__(self): + self.parent_type = None + self.attr_name = None + + def __get__(self, parent, parent_type=None): + if self.parent_type is None: + self.parent_type = parent_type + elif self.parent_type is not parent_type: + raise RuntimeError("StoredState shared by {} and {}".format(self.parent_type.__name__, parent_type.__name__)) + + if parent is None: + return self + + bound = parent.__dict__.get(self.attr_name) + if bound is None: + for attr_name, attr_value in parent_type.__dict__.items(): + if attr_value is self: + if self.attr_name and attr_name != self.attr_name: + parent_tname = parent_type.__name__ + raise RuntimeError(f"StoredState shared by {parent_tname}.{self.attr_name} and {parent_tname}.{attr_name}") + self.attr_name = attr_name + bound = BoundStoredState(parent, attr_name) + parent.__dict__[attr_name] = bound + break + else: + raise RuntimeError("cannot find StoredVariable attribute in type {}".format(parent_type.__name__)) + + return bound + + +def _wrap_stored(parent_data, value): + t = type(value) + if t is dict: + return StoredDict(parent_data, value) + if t is list: + return StoredList(parent_data, value) + if t is set: + return StoredSet(parent_data, value) + return value + + +def _unwrap_stored(parent_data, value): + t = type(value) + if t is StoredDict or t is StoredList or t is StoredSet: + return value._under + return value + + +class StoredDict(collections.abc.MutableMapping): + + def __init__(self, stored_data, under): + self._stored_data = stored_data + self._under = under + + def __getitem__(self, key): + return _wrap_stored(self._stored_data, self._under[key]) + + def __setitem__(self, key, value): + self._under[key] = _unwrap_stored(self._stored_data, value) + self._stored_data.dirty = True + + def __delitem__(self, key): + del self._under[key] + self._stored_data.dirty = True + + def __iter__(self): + return self._under.__iter__() + + def __len__(self): + return len(self._under) + + def __eq__(self, other): + if isinstance(other, StoredDict): + return self._under == other._under + elif isinstance(other, collections.abc.Mapping): + return self._under == other + else: + return NotImplemented + + +class StoredList(collections.abc.MutableSequence): + + def __init__(self, stored_data, under): + self._stored_data = stored_data + self._under = under + + def __getitem__(self, index): + return _wrap_stored(self._stored_data, self._under[index]) + + def __setitem__(self, index, value): + self._under[index] = _unwrap_stored(self._stored_data, value) + self._stored_data.dirty = True + + def __delitem__(self, index): + del self._under[index] + self._stored_data.dirty = True + + def __len__(self): + return len(self._under) + + def insert(self, index, value): + self._under.insert(index, value) + self._stored_data.dirty = True + + def append(self, value): + self._under.append(value) + self._stored_data.dirty = True + + def __eq__(self, other): + if isinstance(other, StoredList): + return self._under == other._under + elif isinstance(other, collections.abc.Sequence): + return self._under == other + else: + return NotImplemented + + def __lt__(self, other): + if isinstance(other, StoredList): + return self._under < other._under + elif isinstance(other, collections.abc.Sequence): + return self._under < other + else: + return NotImplemented + + def __le__(self, other): + if isinstance(other, StoredList): + return self._under <= other._under + elif isinstance(other, collections.abc.Sequence): + return self._under <= other + else: + return NotImplemented + + def __gt__(self, other): + if isinstance(other, StoredList): + return self._under > other._under + elif isinstance(other, collections.abc.Sequence): + return self._under > other + else: + return NotImplemented + + def __ge__(self, other): + if isinstance(other, StoredList): + return self._under >= other._under + elif isinstance(other, collections.abc.Sequence): + return self._under >= other + else: + return NotImplemented + + +class StoredSet(collections.abc.MutableSet): + + def __init__(self, stored_data, under): + self._stored_data = stored_data + self._under = under + + def add(self, key): + self._under.add(key) + self._stored_data.dirty = True + + def discard(self, key): + self._under.discard(key) + self._stored_data.dirty = True + + def __contains__(self, key): + return key in self._under + + def __iter__(self): + return self._under.__iter__() + + def __len__(self): + return len(self._under) + + @classmethod + def _from_iterable(cls, it): + """Construct an instance of the class from any iterable input. + + Per https://docs.python.org/3/library/collections.abc.html + if the Set mixin is being used in a class with a different constructor signature, + you will need to override _from_iterable() with a classmethod that can construct + new instances from an iterable argument. + """ + return set(it) + + def __le__(self, other): + if isinstance(other, StoredSet): + return self._under <= other._under + elif isinstance(other, collections.abc.Set): + return self._under <= other + else: + return NotImplemented + + def __ge__(self, other): + if isinstance(other, StoredSet): + return self._under >= other._under + elif isinstance(other, collections.abc.Set): + return self._under >= other + else: + return NotImplemented + + def __eq__(self, other): + if isinstance(other, StoredSet): + return self._under == other._under + elif isinstance(other, collections.abc.Set): + return self._under == other + else: + return NotImplemented diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/jujuversion.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/jujuversion.py new file mode 100755 index 00000000..5256f24f --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/jujuversion.py @@ -0,0 +1,77 @@ +# Copyright 2020 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import re +from functools import total_ordering + + +@total_ordering +class JujuVersion: + + PATTERN = r'^(?P\d{1,9})\.(?P\d{1,9})((?:\.|-(?P[a-z]+))(?P\d{1,9}))?(\.(?P\d{1,9}))?$' + + def __init__(self, version): + m = re.match(self.PATTERN, version) + if not m: + raise RuntimeError(f'"{version}" is not a valid Juju version string') + + d = m.groupdict() + self.major = int(m.group('major')) + self.minor = int(m.group('minor')) + self.tag = d['tag'] or '' + self.patch = int(d['patch'] or 0) + self.build = int(d['build'] or 0) + + def __repr__(self): + if self.tag: + s = f'{self.major}.{self.minor}-{self.tag}{self.patch}' + else: + s = f'{self.major}.{self.minor}.{self.patch}' + if self.build > 0: + s += f'.{self.build}' + return s + + def __eq__(self, other): + if self is other: + return True + if isinstance(other, str): + other = type(self)(other) + elif not isinstance(other, JujuVersion): + raise RuntimeError(f'cannot compare Juju version "{self}" with "{other}"') + return self.major == other.major and self.minor == other.minor\ + and self.tag == other.tag and self.build == other.build and self.patch == other.patch + + def __lt__(self, other): + if self is other: + return False + if isinstance(other, str): + other = type(self)(other) + elif not isinstance(other, JujuVersion): + raise RuntimeError(f'cannot compare Juju version "{self}" with "{other}"') + + if self.major != other.major: + return self.major < other.major + elif self.minor != other.minor: + return self.minor < other.minor + elif self.tag != other.tag: + if not self.tag: + return False + elif not other.tag: + return True + return self.tag < other.tag + elif self.patch != other.patch: + return self.patch < other.patch + elif self.build != other.build: + return self.build < other.build + return False diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/main.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/main.py new file mode 100755 index 00000000..c8d5da2a --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/main.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +# Copyright 2019 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import sys +from pathlib import Path + +import yaml + +import ops.charm +import ops.framework +import ops.model + +CHARM_STATE_FILE = '.unit-state.db' + + +def debugf(format, *args, **kwargs): + pass + + +def _get_charm_dir(): + charm_dir = os.environ.get("JUJU_CHARM_DIR") + if charm_dir is None: + # Assume $JUJU_CHARM_DIR/lib/op/main.py structure. + charm_dir = Path(f'{__file__}/../../..').resolve() + else: + charm_dir = Path(charm_dir).resolve() + return charm_dir + + +def _load_metadata(charm_dir): + metadata = yaml.safe_load((charm_dir / 'metadata.yaml').read_text()) + + actions_meta = charm_dir / 'actions.yaml' + if actions_meta.exists(): + actions_metadata = yaml.safe_load(actions_meta.read_text()) + else: + actions_metadata = {} + return metadata, actions_metadata + + +def _create_event_link(charm, bound_event): + """Create a symlink for a particular event. + + charm -- A charm object. + bound_event -- An event for which to create a symlink. + """ + if issubclass(bound_event.event_type, ops.charm.HookEvent): + event_dir = charm.framework.charm_dir / 'hooks' + event_path = event_dir / bound_event.event_kind.replace('_', '-') + elif issubclass(bound_event.event_type, ops.charm.ActionEvent): + if not bound_event.event_kind.endswith("_action"): + raise RuntimeError(f"action event name {bound_event.event_kind} needs _action suffix") + event_dir = charm.framework.charm_dir / 'actions' + # The event_kind is suffixed with "_action" while the executable is not. + event_path = event_dir / bound_event.event_kind[:-len('_action')].replace('_', '-') + else: + raise RuntimeError(f'cannot create a symlink: unsupported event type {bound_event.event_type}') + + event_dir.mkdir(exist_ok=True) + if not event_path.exists(): + # CPython has different implementations for populating sys.argv[0] for Linux and Windows. For Windows + # it is always an absolute path (any symlinks are resolved) while for Linux it can be a relative path. + target_path = os.path.relpath(os.path.realpath(sys.argv[0]), event_dir) + + # Ignore the non-symlink files or directories assuming the charm author knows what they are doing. + debugf(f'Creating a new relative symlink at {event_path} pointing to {target_path}') + event_path.symlink_to(target_path) + + +def _setup_event_links(charm_dir, charm): + """Set up links for supported events that originate from Juju. + + Whether a charm can handle an event or not can be determined by + introspecting which events are defined on it. + + Hooks or actions are created as symlinks to the charm code file which is determined by inspecting + symlinks provided by the charm author at hooks/install or hooks/start. + + charm_dir -- A root directory of the charm. + charm -- An instance of the Charm class. + """ + for bound_event in charm.on.events().values(): + # Only events that originate from Juju need symlinks. + if issubclass(bound_event.event_type, (ops.charm.HookEvent, ops.charm.ActionEvent)): + _create_event_link(charm, bound_event) + + +def _emit_charm_event(charm, event_name): + """Emits a charm event based on a Juju event name. + + charm -- A charm instance to emit an event from. + event_name -- A Juju event name to emit on a charm. + """ + event_to_emit = None + try: + event_to_emit = getattr(charm.on, event_name) + except AttributeError: + debugf(f"event {event_name} not defined for {charm}") + + # If the event is not supported by the charm implementation, do + # not error out or try to emit it. This is to support rollbacks. + if event_to_emit is not None: + args, kwargs = _get_event_args(charm, event_to_emit) + debugf(f'Emitting Juju event {event_name}') + event_to_emit.emit(*args, **kwargs) + + +def _get_event_args(charm, bound_event): + event_type = bound_event.event_type + model = charm.framework.model + + if issubclass(event_type, ops.charm.RelationEvent): + relation_name = os.environ['JUJU_RELATION'] + relation_id = int(os.environ['JUJU_RELATION_ID'].split(':')[-1]) + relation = model.get_relation(relation_name, relation_id) + else: + relation = None + + remote_app_name = os.environ.get('JUJU_REMOTE_APP', '') + remote_unit_name = os.environ.get('JUJU_REMOTE_UNIT', '') + if remote_app_name or remote_unit_name: + if not remote_app_name: + if '/' not in remote_unit_name: + raise RuntimeError(f'invalid remote unit name: {remote_unit_name}') + remote_app_name = remote_unit_name.split('/')[0] + args = [relation, model.get_app(remote_app_name)] + if remote_unit_name: + args.append(model.get_unit(remote_unit_name)) + return args, {} + elif relation: + return [relation], {} + return [], {} + + +def main(charm_class): + """Setup the charm and dispatch the observed event. + + The event name is based on the way this executable was called (argv[0]). + """ + + charm_dir = _get_charm_dir() + + # Process the Juju event relevant to the current hook execution + # JUJU_HOOK_NAME, JUJU_FUNCTION_NAME, and JUJU_ACTION_NAME are not used + # in order to support simulation of events from debugging sessions. + # TODO: For Windows, when symlinks are used, this is not a valid method of getting an event name (see LP: #1854505). + juju_exec_path = Path(sys.argv[0]) + juju_event_name = juju_exec_path.name.replace('-', '_') + if juju_exec_path.parent.name == 'actions': + juju_event_name = f'{juju_event_name}_action' + + metadata, actions_metadata = _load_metadata(charm_dir) + meta = ops.charm.CharmMeta(metadata, actions_metadata) + unit_name = os.environ['JUJU_UNIT_NAME'] + model = ops.model.Model(unit_name, meta, ops.model.ModelBackend()) + + # TODO: If Juju unit agent crashes after exit(0) from the charm code + # the framework will commit the snapshot but Juju will not commit its + # operation. + charm_state_path = charm_dir / CHARM_STATE_FILE + framework = ops.framework.Framework(charm_state_path, charm_dir, meta, model) + try: + charm = charm_class(framework, None) + + # When a charm is force-upgraded and a unit is in an error state Juju does not run upgrade-charm and + # instead runs the failed hook followed by config-changed. Given the nature of force-upgrading + # the hook setup code is not triggered on config-changed. + # 'start' event is included as Juju does not fire the install event for K8s charms (see LP: #1854635). + if juju_event_name in ('install', 'start', 'upgrade_charm') or juju_event_name.endswith('_storage_attached'): + _setup_event_links(charm_dir, charm) + + framework.reemit() + + _emit_charm_event(charm, juju_event_name) + + framework.commit() + finally: + framework.close() diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/model.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/model.py new file mode 100644 index 00000000..a12dcca2 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/ops/model.py @@ -0,0 +1,679 @@ +# Copyright 2019 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import weakref +import os +import shutil +import tempfile +import time +import datetime + +from abc import ABC, abstractmethod +from collections.abc import Mapping, MutableMapping +from pathlib import Path +from subprocess import run, PIPE, CalledProcessError + + +class Model: + + def __init__(self, unit_name, meta, backend): + self._cache = ModelCache(backend) + self._backend = backend + self.unit = self.get_unit(unit_name) + self.app = self.unit.app + self.relations = RelationMapping(meta.relations, self.unit, self._backend, self._cache) + self.config = ConfigData(self._backend) + self.resources = Resources(list(meta.resources), self._backend) + self.pod = Pod(self._backend) + self.storages = StorageMapping(list(meta.storages), self._backend) + + def get_unit(self, unit_name): + return self._cache.get(Unit, unit_name) + + def get_app(self, app_name): + return self._cache.get(Application, app_name) + + def get_relation(self, relation_name, relation_id=None): + """Get a specific Relation instance. + + If relation_id is given, this will return that Relation instance. + + If relation_id is not given, this will return the Relation instance if the + relation is established only once or None if it is not established. If this + same relation is established multiple times the error TooManyRelatedAppsError is raised. + """ + return self.relations._get_unique(relation_name, relation_id) + + +class ModelCache: + + def __init__(self, backend): + self._backend = backend + self._weakrefs = weakref.WeakValueDictionary() + + def get(self, entity_type, *args): + key = (entity_type,) + args + entity = self._weakrefs.get(key) + if entity is None: + entity = entity_type(*args, backend=self._backend, cache=self) + self._weakrefs[key] = entity + return entity + + +class Application: + + def __init__(self, name, backend, cache): + self.name = name + self._backend = backend + self._cache = cache + self._is_our_app = self.name == self._backend.app_name + self._status = None + + @property + def status(self): + if not self._is_our_app: + return UnknownStatus() + + if not self._backend.is_leader(): + raise RuntimeError('cannot get application status as a non-leader unit') + + if self._status: + return self._status + + s = self._backend.status_get(is_app=True) + self._status = StatusBase.from_name(s['status'], s['message']) + return self._status + + @status.setter + def status(self, value): + if not isinstance(value, StatusBase): + raise InvalidStatusError(f'invalid value provided for application {self} status: {value}') + + if not self._is_our_app: + raise RuntimeError(f'cannot to set status for a remote application {self}') + + if not self._backend.is_leader(): + raise RuntimeError('cannot set application status as a non-leader unit') + + self._backend.status_set(value.name, value.message, is_app=True) + self._status = value + + def __repr__(self): + return f'<{type(self).__module__}.{type(self).__name__} {self.name}>' + + +class Unit: + + def __init__(self, name, backend, cache): + self.name = name + + app_name = name.split('/')[0] + self.app = cache.get(Application, app_name) + + self._backend = backend + self._cache = cache + self._is_our_unit = self.name == self._backend.unit_name + self._status = None + + @property + def status(self): + if not self._is_our_unit: + return UnknownStatus() + + if self._status: + return self._status + + s = self._backend.status_get(is_app=False) + self._status = StatusBase.from_name(s['status'], s['message']) + return self._status + + @status.setter + def status(self, value): + if not isinstance(value, StatusBase): + raise InvalidStatusError(f'invalid value provided for unit {self} status: {value}') + + if not self._is_our_unit: + raise RuntimeError(f'cannot set status for a remote unit {self}') + + self._backend.status_set(value.name, value.message, is_app=False) + self._status = value + + def __repr__(self): + return f'<{type(self).__module__}.{type(self).__name__} {self.name}>' + + def is_leader(self): + if self._is_our_unit: + # This value is not cached as it is not guaranteed to persist for the whole duration + # of a hook execution. + return self._backend.is_leader() + else: + raise RuntimeError(f"cannot determine leadership status for remote applications: {self}") + + +class LazyMapping(Mapping, ABC): + + _lazy_data = None + + @abstractmethod + def _load(self): + raise NotImplementedError() + + @property + def _data(self): + data = self._lazy_data + if data is None: + data = self._lazy_data = self._load() + return data + + def __contains__(self, key): + return key in self._data + + def __len__(self): + return len(self._data) + + def __iter__(self): + return iter(self._data) + + def __getitem__(self, key): + return self._data[key] + + +class RelationMapping(Mapping): + """Map of relation names to lists of Relation instances.""" + + def __init__(self, relations_meta, our_unit, backend, cache): + self._peers = set() + for name, relation_meta in relations_meta.items(): + if relation_meta.role == 'peers': + self._peers.add(name) + self._our_unit = our_unit + self._backend = backend + self._cache = cache + self._data = {relation_name: None for relation_name in relations_meta} + + def __contains__(self, key): + return key in self._data + + def __len__(self): + return len(self._data) + + def __iter__(self): + return iter(self._data) + + def __getitem__(self, relation_name): + is_peer = relation_name in self._peers + relation_list = self._data[relation_name] + if relation_list is None: + relation_list = self._data[relation_name] = [] + for rid in self._backend.relation_ids(relation_name): + relation = Relation(relation_name, rid, is_peer, self._our_unit, self._backend, self._cache) + relation_list.append(relation) + return relation_list + + def _get_unique(self, relation_name, relation_id=None): + if relation_id is not None: + if not isinstance(relation_id, int): + raise ModelError(f'relation name {relation_id} must be int or None not {type(relation_id).__name__}') + for relation in self[relation_name]: + if relation.id == relation_id: + return relation + else: + # The relation may be dead, but it is not forgotten. + is_peer = relation_name in self._peers + return Relation(relation_name, relation_id, is_peer, self._our_unit, self._backend, self._cache) + num_related = len(self[relation_name]) + if num_related == 0: + return None + elif num_related == 1: + return self[relation_name][0] + else: + # TODO: We need something in the framework to catch and gracefully handle + # errors, ideally integrating the error catching with Juju's mechanisms. + raise TooManyRelatedAppsError(relation_name, num_related, 1) + + +class Relation: + def __init__(self, relation_name, relation_id, is_peer, our_unit, backend, cache): + self.name = relation_name + self.id = relation_id + self.app = None + self.units = set() + + # For peer relations, both the remote and the local app are the same. + if is_peer: + self.app = our_unit.app + try: + for unit_name in backend.relation_list(self.id): + unit = cache.get(Unit, unit_name) + self.units.add(unit) + if self.app is None: + self.app = unit.app + except RelationNotFoundError: + # If the relation is dead, just treat it as if it has no remote units. + pass + self.data = RelationData(self, our_unit, backend) + + def __repr__(self): + return f'<{type(self).__module__}.{type(self).__name__} {self.name}:{self.id}>' + + +class RelationData(Mapping): + def __init__(self, relation, our_unit, backend): + self.relation = weakref.proxy(relation) + self._data = {our_unit: RelationDataContent(self.relation, our_unit, backend)} + self._data.update({our_unit.app: RelationDataContent(self.relation, our_unit.app, backend)}) + self._data.update({unit: RelationDataContent(self.relation, unit, backend) for unit in self.relation.units}) + # The relation might be dead so avoid a None key here. + if self.relation.app: + self._data.update({self.relation.app: RelationDataContent(self.relation, self.relation.app, backend)}) + + def __contains__(self, key): + return key in self._data + + def __len__(self): + return len(self._data) + + def __iter__(self): + return iter(self._data) + + def __getitem__(self, key): + return self._data[key] + + +# We mix in MutableMapping here to get some convenience implementations, but whether it's actually +# mutable or not is controlled by the flag. +class RelationDataContent(LazyMapping, MutableMapping): + + def __init__(self, relation, entity, backend): + self.relation = relation + self._entity = entity + self._backend = backend + self._is_app = isinstance(entity, Application) + + def _load(self): + try: + return self._backend.relation_get(self.relation.id, self._entity.name, self._is_app) + except RelationNotFoundError: + # Dead relations tell no tales (and have no data). + return {} + + def _is_mutable(self): + if self._is_app: + is_our_app = self._backend.app_name == self._entity.name + if not is_our_app: + return False + # Whether the application data bag is mutable or not depends on whether this unit is a leader or not, + # but this is not guaranteed to be always true during the same hook execution. + return self._backend.is_leader() + else: + is_our_unit = self._backend.unit_name == self._entity.name + if is_our_unit: + return True + return False + + def __setitem__(self, key, value): + if not self._is_mutable(): + raise RelationDataError(f'cannot set relation data for {self._entity.name}') + if not isinstance(value, str): + raise RelationDataError('relation data values must be strings') + + self._backend.relation_set(self.relation.id, key, value, self._is_app) + + # Don't load data unnecessarily if we're only updating. + if self._lazy_data is not None: + if value == '': + # Match the behavior of Juju, which is that setting the value to an empty string will + # remove the key entirely from the relation data. + del self._data[key] + else: + self._data[key] = value + + def __delitem__(self, key): + # Match the behavior of Juju, which is that setting the value to an empty string will + # remove the key entirely from the relation data. + self.__setitem__(key, '') + + +class ConfigData(LazyMapping): + + def __init__(self, backend): + self._backend = backend + + def _load(self): + return self._backend.config_get() + + +class StatusBase: + """Status values specific to applications and units.""" + + _statuses = {} + + def __init__(self, message): + self.message = message + + def __new__(cls, *args, **kwargs): + if cls is StatusBase: + raise TypeError("cannot instantiate a base class") + cls._statuses[cls.name] = cls + return super().__new__(cls) + + @classmethod + def from_name(cls, name, message): + return cls._statuses[name](message) + + +class ActiveStatus(StatusBase): + """The unit is ready. + + The unit believes it is correctly offering all the services it has been asked to offer. + """ + name = 'active' + + def __init__(self, message=None): + super().__init__(message or '') + + +class BlockedStatus(StatusBase): + """The unit requires manual intervention. + + An operator has to manually intervene to unblock the unit and let it proceed. + """ + name = 'blocked' + + +class MaintenanceStatus(StatusBase): + """The unit is performing maintenance tasks. + + The unit is not yet providing services, but is actively doing work in preparation for providing those services. + This is a "spinning" state, not an error state. It reflects activity on the unit itself, not on peers or related units. + """ + name = 'maintenance' + + +class UnknownStatus(StatusBase): + """The unit status is unknown. + + A unit-agent has finished calling install, config-changed and start, but the charm has not called status-set yet. + """ + name = 'unknown' + + def __init__(self): + # Unknown status cannot be set and does not have a message associated with it. + super().__init__('') + + +class WaitingStatus(StatusBase): + """A unit is unable to progress. + + The unit is unable to progress to an active state because an application to which it is related is not running. + """ + name = 'waiting' + + +class Resources: + """Object representing resources for the charm. + """ + + def __init__(self, names, backend): + self._backend = backend + self._paths = {name: None for name in names} + + def fetch(self, name): + """Fetch the resource from the controller or store. + + If successfully fetched, this returns a Path object to where the resource is stored + on disk, otherwise it raises a ModelError. + """ + if name not in self._paths: + raise RuntimeError(f'invalid resource name: {name}') + if self._paths[name] is None: + self._paths[name] = Path(self._backend.resource_get(name)) + return self._paths[name] + + +class Pod: + def __init__(self, backend): + self._backend = backend + + def set_spec(self, spec, k8s_resources=None): + if not self._backend.is_leader(): + raise ModelError('cannot set a pod spec as this unit is not a leader') + self._backend.pod_spec_set(spec, k8s_resources) + + +class StorageMapping(Mapping): + """Map of storage names to lists of Storage instances.""" + + def __init__(self, storage_names, backend): + self._backend = backend + self._storage_map = {storage_name: None for storage_name in storage_names} + + def __contains__(self, key): + return key in self._storage_map + + def __len__(self): + return len(self._storage_map) + + def __iter__(self): + return iter(self._storage_map) + + def __getitem__(self, storage_name): + storage_list = self._storage_map[storage_name] + if storage_list is None: + storage_list = self._storage_map[storage_name] = [] + for storage_id in self._backend.storage_list(storage_name): + storage_list.append(Storage(storage_name, storage_id, self._backend)) + return storage_list + + def request(self, storage_name, count=1): + """Requests new storage instances of a given name. + + Uses storage-add tool to request additional storage. Juju will notify the unit + via -storage-attached events when it becomes available. + """ + if storage_name not in self._storage_map: + raise ModelError(f'cannot add storage with {storage_name} as it is not present in the charm metadata') + self._backend.storage_add(storage_name, count) + + +class Storage: + + def __init__(self, storage_name, storage_id, backend): + self.name = storage_name + self.id = storage_id + self._backend = backend + self._location = None + + @property + def location(self): + if self._location is None: + self._location = Path(self._backend.storage_get(f'{self.name}/{self.id}', "location")) + return self._location + + +class ModelError(Exception): + pass + + +class TooManyRelatedAppsError(ModelError): + def __init__(self, relation_name, num_related, max_supported): + super().__init__(f'Too many remote applications on {relation_name} ({num_related} > {max_supported})') + self.relation_name = relation_name + self.num_related = num_related + self.max_supported = max_supported + + +class RelationDataError(ModelError): + pass + + +class RelationNotFoundError(ModelError): + pass + + +class InvalidStatusError(ModelError): + pass + + +class ModelBackend: + + LEASE_RENEWAL_PERIOD = datetime.timedelta(seconds=30) + + def __init__(self): + self.unit_name = os.environ['JUJU_UNIT_NAME'] + self.app_name = self.unit_name.split('/')[0] + + self._is_leader = None + self._leader_check_time = 0 + + def _run(self, *args, return_output=False, use_json=False): + kwargs = dict(stdout=PIPE, stderr=PIPE) + if use_json: + args += ('--format=json',) + try: + result = run(args, check=True, **kwargs) + except CalledProcessError as e: + raise ModelError(e.stderr) + if return_output: + if result.stdout is None: + return '' + else: + text = result.stdout.decode('utf8') + if use_json: + return json.loads(text) + else: + return text + + def relation_ids(self, relation_name): + relation_ids = self._run('relation-ids', relation_name, return_output=True, use_json=True) + return [int(relation_id.split(':')[-1]) for relation_id in relation_ids] + + def relation_list(self, relation_id): + try: + return self._run('relation-list', '-r', str(relation_id), return_output=True, use_json=True) + except ModelError as e: + if 'relation not found' in str(e): + raise RelationNotFoundError() from e + raise + + def relation_get(self, relation_id, member_name, is_app): + if not isinstance(is_app, bool): + raise TypeError('is_app parameter to relation_get must be a boolean') + + try: + return self._run('relation-get', '-r', str(relation_id), '-', member_name, f'--app={is_app}', return_output=True, use_json=True) + except ModelError as e: + if 'relation not found' in str(e): + raise RelationNotFoundError() from e + raise + + def relation_set(self, relation_id, key, value, is_app): + if not isinstance(is_app, bool): + raise TypeError('is_app parameter to relation_set must be a boolean') + + try: + return self._run('relation-set', '-r', str(relation_id), f'{key}={value}', f'--app={is_app}') + except ModelError as e: + if 'relation not found' in str(e): + raise RelationNotFoundError() from e + raise + + def config_get(self): + return self._run('config-get', return_output=True, use_json=True) + + def is_leader(self): + """Obtain the current leadership status for the unit the charm code is executing on. + + The value is cached for the duration of a lease which is 30s in Juju. + """ + now = time.monotonic() + time_since_check = datetime.timedelta(seconds=now - self._leader_check_time) + if time_since_check > self.LEASE_RENEWAL_PERIOD or self._is_leader is None: + # Current time MUST be saved before running is-leader to ensure the cache + # is only used inside the window that is-leader itself asserts. + self._leader_check_time = now + self._is_leader = self._run('is-leader', return_output=True, use_json=True) + + return self._is_leader + + def resource_get(self, resource_name): + return self._run('resource-get', resource_name, return_output=True).strip() + + def pod_spec_set(self, spec, k8s_resources): + tmpdir = Path(tempfile.mkdtemp('-pod-spec-set')) + try: + spec_path = tmpdir / 'spec.json' + spec_path.write_text(json.dumps(spec)) + args = ['--file', str(spec_path)] + if k8s_resources: + k8s_res_path = tmpdir / 'k8s-resources.json' + k8s_res_path.write_text(json.dumps(k8s_resources)) + args.extend(['--k8s-resources', str(k8s_res_path)]) + self._run('pod-spec-set', *args) + finally: + shutil.rmtree(tmpdir) + + def status_get(self, *, is_app=False): + """Get a status of a unit or an application. + app -- A boolean indicating whether the status should be retrieved for a unit or an application. + """ + return self._run('status-get', '--include-data', f'--application={is_app}') + + def status_set(self, status, message='', *, is_app=False): + """Set a status of a unit or an application. + app -- A boolean indicating whether the status should be set for a unit or an application. + """ + if not isinstance(is_app, bool): + raise TypeError('is_app parameter must be boolean') + return self._run('status-set', f'--application={is_app}', status, message) + + def storage_list(self, name): + return [int(s.split('/')[1]) for s in self._run('storage-list', name, return_output=True, use_json=True)] + + def storage_get(self, storage_name_id, attribute): + return self._run('storage-get', '-s', storage_name_id, attribute, return_output=True, use_json=True) + + def storage_add(self, name, count=1): + if not isinstance(count, int) or isinstance(count, bool): + raise TypeError(f'storage count must be integer, got: {count} ({type(count)})') + self._run('storage-add', f'{name}={count}') + + def action_get(self): + return self._run(f'action-get', return_output=True, use_json=True) + + def action_set(self, results): + self._run(f'action-set', *[f"{k}={v}" for k, v in results.items()]) + + def action_log(self, message): + self._run(f'action-log', f"{message}") + + def action_fail(self, message=''): + self._run(f'action-fail', f"{message}") + + def network_get(self, endpoint_name, relation_id=None): + """Return network info provided by network-get for a given endpoint. + + endpoint_name -- A name of an endpoint (relation name or extra-binding name). + relation_id -- An optional relation id to get network info for. + """ + cmd = ['network-get', endpoint_name] + if relation_id is not None: + cmd.extend(['-r', str(relation_id)]) + try: + return self._run(*cmd, return_output=True, use_json=True) + except ModelError as e: + if 'relation not found' in str(e): + raise RelationNotFoundError() from e + raise diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/setup.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/setup.py new file mode 100644 index 00000000..44765e20 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/setup.py @@ -0,0 +1,38 @@ +# Copyright 2019 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from setuptools import setup + +with open("README.md", "r") as fh: + long_description = fh.read() + +setup( + name="ops", + version="0.0.1", + description="The Python library behind great charms", + long_description=long_description, + long_description_content_type="text/markdown", + license="Apache-2.0", + url="https://github.com/canonical/operator", + packages=["ops"], + classifiers=[ + "Development Status :: 4 - Beta", + + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + + "License :: OSI Approved :: Apache Software License", + ], +) diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/__init__.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/bin/relation-ids b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/bin/relation-ids new file mode 100755 index 00000000..a7e0ead2 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/bin/relation-ids @@ -0,0 +1,11 @@ +#!/bin/bash + +case $1 in + db) echo '["db:1"]' ;; + mon) echo '["mon:2"]' ;; + ha) echo '[]' ;; + db0) echo '[]' ;; + db1) echo '["db1:4"]' ;; + db2) echo '["db2:5", "db2:6"]' ;; + *) echo '[]' ;; +esac diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/bin/relation-list b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/bin/relation-list new file mode 100755 index 00000000..88490159 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/bin/relation-list @@ -0,0 +1,16 @@ +#!/bin/bash + +fail_not_found() { + 1>&2 echo "ERROR invalid value \"$1\" for option -r: relation not found" + exit 2 +} + +case $2 in + 1) echo '["remote/0"]' ;; + 2) echo '["remote/0"]' ;; + 3) fail_not_found $2 ;; + 4) echo '["remoteapp1/0"]' ;; + 5) echo '["remoteapp1/0"]' ;; + 6) echo '["remoteapp2/0"]' ;; + *) fail_not_found $2 ;; +esac diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/config.yaml b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/config.yaml new file mode 100644 index 00000000..ffc01860 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/config.yaml @@ -0,0 +1 @@ +"options": {} diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/__init__.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/__init__.py new file mode 100755 index 00000000..e69de29b diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/__init__.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/charm.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/charm.py new file mode 100755 index 00000000..71472f96 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/charm.py @@ -0,0 +1,306 @@ +# Copyright 2019 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os + +import yaml + +from ops.framework import Object, EventSource, EventBase, EventsBase + + +class HookEvent(EventBase): + pass + + +class ActionEvent(EventBase): + + def defer(self): + raise RuntimeError('cannot defer action events') + + def restore(self, snapshot): + env_action_name = os.environ.get('JUJU_ACTION_NAME') + event_action_name = self.handle.kind[:-len('_action')].replace('_', '-') + if event_action_name != env_action_name: + # This could only happen if the dev manually emits the action, or from a bug. + raise RuntimeError('action event kind does not match current action') + # Params are loaded at restore rather than __init__ because the model is not available in __init__. + self.params = self.framework.model._backend.action_get() + + def set_results(self, results): + self.framework.model._backend.action_set(results) + + def log(self, message): + self.framework.model._backend.action_log(message) + + def fail(self, message=''): + self.framework.model._backend.action_fail(message) + + +class InstallEvent(HookEvent): + pass + + +class StartEvent(HookEvent): + pass + + +class StopEvent(HookEvent): + pass + + +class ConfigChangedEvent(HookEvent): + pass + + +class UpdateStatusEvent(HookEvent): + pass + + +class UpgradeCharmEvent(HookEvent): + pass + + +class PreSeriesUpgradeEvent(HookEvent): + pass + + +class PostSeriesUpgradeEvent(HookEvent): + pass + + +class LeaderElectedEvent(HookEvent): + pass + + +class LeaderSettingsChangedEvent(HookEvent): + pass + + +class RelationEvent(HookEvent): + def __init__(self, handle, relation, app=None, unit=None): + super().__init__(handle) + + if unit and unit.app != app: + raise RuntimeError(f'cannot create RelationEvent with application {app} and unit {unit}') + + self.relation = relation + self.app = app + self.unit = unit + + def snapshot(self): + snapshot = { + 'relation_name': self.relation.name, + 'relation_id': self.relation.id, + } + if self.app: + snapshot['app_name'] = self.app.name + if self.unit: + snapshot['unit_name'] = self.unit.name + return snapshot + + def restore(self, snapshot): + self.relation = self.framework.model.get_relation(snapshot['relation_name'], snapshot['relation_id']) + + app_name = snapshot.get('app_name') + if app_name: + self.app = self.framework.model.get_app(app_name) + else: + self.app = None + + unit_name = snapshot.get('unit_name') + if unit_name: + self.unit = self.framework.model.get_unit(unit_name) + else: + self.unit = None + + +class RelationJoinedEvent(RelationEvent): + pass + + +class RelationChangedEvent(RelationEvent): + pass + + +class RelationDepartedEvent(RelationEvent): + pass + + +class RelationBrokenEvent(RelationEvent): + pass + + +class StorageEvent(HookEvent): + pass + + +class StorageAttachedEvent(StorageEvent): + pass + + +class StorageDetachingEvent(StorageEvent): + pass + + +class CharmEvents(EventsBase): + + install = EventSource(InstallEvent) + start = EventSource(StartEvent) + stop = EventSource(StopEvent) + update_status = EventSource(UpdateStatusEvent) + config_changed = EventSource(ConfigChangedEvent) + upgrade_charm = EventSource(UpgradeCharmEvent) + pre_series_upgrade = EventSource(PreSeriesUpgradeEvent) + post_series_upgrade = EventSource(PostSeriesUpgradeEvent) + leader_elected = EventSource(LeaderElectedEvent) + leader_settings_changed = EventSource(LeaderSettingsChangedEvent) + + +class CharmBase(Object): + + on = CharmEvents() + + def __init__(self, framework, key): + super().__init__(framework, key) + + for relation_name in self.framework.meta.relations: + relation_name = relation_name.replace('-', '_') + self.on.define_event(f'{relation_name}_relation_joined', RelationJoinedEvent) + self.on.define_event(f'{relation_name}_relation_changed', RelationChangedEvent) + self.on.define_event(f'{relation_name}_relation_departed', RelationDepartedEvent) + self.on.define_event(f'{relation_name}_relation_broken', RelationBrokenEvent) + + for storage_name in self.framework.meta.storages: + storage_name = storage_name.replace('-', '_') + self.on.define_event(f'{storage_name}_storage_attached', StorageAttachedEvent) + self.on.define_event(f'{storage_name}_storage_detaching', StorageDetachingEvent) + + for action_name in self.framework.meta.actions: + action_name = action_name.replace('-', '_') + self.on.define_event(f'{action_name}_action', ActionEvent) + + +class CharmMeta: + """Object containing the metadata for the charm. + + The maintainers, tags, terms, series, and extra_bindings attributes are all + lists of strings. The requires, provides, peers, relations, storage, + resources, and payloads attributes are all mappings of names to instances + of the respective RelationMeta, StorageMeta, ResourceMeta, or PayloadMeta. + + The relations attribute is a convenience accessor which includes all of the + requires, provides, and peers RelationMeta items. If needed, the role of + the relation definition can be obtained from its role attribute. + """ + + def __init__(self, raw={}, actions_raw={}): + self.name = raw.get('name', '') + self.summary = raw.get('summary', '') + self.description = raw.get('description', '') + self.maintainers = [] + if 'maintainer' in raw: + self.maintainers.append(raw['maintainer']) + if 'maintainers' in raw: + self.maintainers.extend(raw['maintainers']) + self.tags = raw.get('tags', []) + self.terms = raw.get('terms', []) + self.series = raw.get('series', []) + self.subordinate = raw.get('subordinate', False) + self.min_juju_version = raw.get('min-juju-version') + self.requires = {name: RelationMeta('requires', name, rel) + for name, rel in raw.get('requires', {}).items()} + self.provides = {name: RelationMeta('provides', name, rel) + for name, rel in raw.get('provides', {}).items()} + self.peers = {name: RelationMeta('peers', name, rel) + for name, rel in raw.get('peers', {}).items()} + self.relations = {} + self.relations.update(self.requires) + self.relations.update(self.provides) + self.relations.update(self.peers) + self.storages = {name: StorageMeta(name, storage) + for name, storage in raw.get('storage', {}).items()} + self.resources = {name: ResourceMeta(name, res) + for name, res in raw.get('resources', {}).items()} + self.payloads = {name: PayloadMeta(name, payload) + for name, payload in raw.get('payloads', {}).items()} + self.extra_bindings = raw.get('extra-bindings', []) + self.actions = {name: ActionMeta(name, action) for name, action in actions_raw.items()} + + @classmethod + def from_yaml(cls, metadata, actions=None): + meta = yaml.safe_load(metadata) + raw_actions = {} + if actions is not None: + raw_actions = yaml.safe_load(actions) + return cls(meta, raw_actions) + + +class RelationMeta: + """Object containing metadata about a relation definition.""" + + def __init__(self, role, relation_name, raw): + self.role = role + self.relation_name = relation_name + self.interface_name = raw['interface'] + self.scope = raw.get('scope') + + +class StorageMeta: + """Object containing metadata about a storage definition.""" + + def __init__(self, name, raw): + self.storage_name = name + self.type = raw['type'] + self.description = raw.get('description', '') + self.shared = raw.get('shared', False) + self.read_only = raw.get('read-only', False) + self.minimum_size = raw.get('minimum-size') + self.location = raw.get('location') + self.multiple_range = None + if 'multiple' in raw: + range = raw['multiple']['range'] + if '-' not in range: + self.multiple_range = (int(range), int(range)) + else: + range = range.split('-') + self.multiple_range = (int(range[0]), int(range[1]) if range[1] else None) + + +class ResourceMeta: + """Object containing metadata about a resource definition.""" + + def __init__(self, name, raw): + self.resource_name = name + self.type = raw['type'] + self.filename = raw.get('filename', None) + self.description = raw.get('description', '') + + +class PayloadMeta: + """Object containing metadata about a payload definition.""" + + def __init__(self, name, raw): + self.payload_name = name + self.type = raw['type'] + + +class ActionMeta: + + def __init__(self, name, raw=None): + raw = raw or {} + self.name = name + self.title = raw.get('title', '') + self.description = raw.get('description', '') + self.parameters = raw.get('params', {}) # {: } + self.required = raw.get('required', []) # [, ...] diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/framework.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/framework.py new file mode 100755 index 00000000..d95eb61f --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/framework.py @@ -0,0 +1,941 @@ +# Copyright 2019 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import inspect +import pickle +import marshal +import types +import sqlite3 +import collections +import collections.abc +import keyword +import weakref +from datetime import timedelta + + +class Handle: + """Handle defines a name for an object in the form of a hierarchical path. + + The provided parent is the object (or that object's handle) that this handle + sits under, or None if the object identified by this handle stands by itself + as the root of its own hierarchy. + + The handle kind is a string that defines a namespace so objects with the + same parent and kind will have unique keys. + + The handle key is a string uniquely identifying the object. No other objects + under the same parent and kind may have the same key. + """ + + def __init__(self, parent, kind, key): + if parent and not isinstance(parent, Handle): + parent = parent.handle + self._parent = parent + self._kind = kind + self._key = key + if parent: + if key: + self._path = f"{parent}/{kind}[{key}]" + else: + self._path = f"{parent}/{kind}" + else: + if key: + self._path = f"{kind}[{key}]" + else: + self._path = f"{kind}" + + def nest(self, kind, key): + return Handle(self, kind, key) + + def __hash__(self): + return hash((self.parent, self.kind, self.key)) + + def __eq__(self, other): + return (self.parent, self.kind, self.key) == (other.parent, other.kind, other.key) + + def __str__(self): + return self.path + + @property + def parent(self): + return self._parent + + @property + def kind(self): + return self._kind + + @property + def key(self): + return self._key + + @property + def path(self): + return self._path + + @classmethod + def from_path(cls, path): + handle = None + for pair in path.split("/"): + pair = pair.split("[") + good = False + if len(pair) == 1: + kind, key = pair[0], None + good = True + elif len(pair) == 2: + kind, key = pair + if key and key[-1] == ']': + key = key[:-1] + good = True + if not good: + raise RuntimeError("attempted to restore invalid handle path {path}") + handle = Handle(handle, kind, key) + return handle + + +class EventBase: + + def __init__(self, handle): + self.handle = handle + self.deferred = False + + def defer(self): + self.deferred = True + + def snapshot(self): + """Return the snapshot data that should be persisted. + + Subclasses must override to save any custom state. + """ + return None + + def restore(self, snapshot): + """Restore the value state from the given snapshot. + + Subclasses must override to restore their custom state. + """ + self.deferred = False + + +class EventSource: + """EventSource wraps an event type with a descriptor to facilitate observing and emitting. + + It is generally used as: + + class SomethingHappened(EventBase): + pass + + class SomeObject(Object): + something_happened = EventSource(SomethingHappened) + + With that, instances of that type will offer the someobj.something_happened + attribute which is a BoundEvent and may be used to emit and observe the event. + """ + + def __init__(self, event_type): + if not isinstance(event_type, type) or not issubclass(event_type, EventBase): + raise RuntimeError(f"Event requires a subclass of EventBase as an argument, got {event_type}") + self.event_type = event_type + self.event_kind = None + self.emitter_type = None + + def __set_name__(self, emitter_type, event_kind): + if self.event_kind is not None: + raise RuntimeError( + f'EventSource({self.event_type.__name__}) reused as ' + f'{self.emitter_type.__name__}.{self.event_kind} and ' + f'{emitter_type.__name__}.{event_kind}') + self.event_kind = event_kind + self.emitter_type = emitter_type + + def __get__(self, emitter, emitter_type=None): + if emitter is None: + return self + # Framework might not be available if accessed as CharmClass.on.event rather than charm_instance.on.event, + # but in that case it couldn't be emitted anyway, so there's no point to registering it. + framework = getattr(emitter, 'framework', None) + if framework is not None: + framework.register_type(self.event_type, emitter, self.event_kind) + return BoundEvent(emitter, self.event_type, self.event_kind) + + +class BoundEvent: + + def __repr__(self): + return (f'') + + def __init__(self, emitter, event_type, event_kind): + self.emitter = emitter + self.event_type = event_type + self.event_kind = event_kind + + def emit(self, *args, **kwargs): + """Emit event to all registered observers. + + The current storage state is committed before and after each observer is notified. + """ + framework = self.emitter.framework + key = framework._next_event_key() + event = self.event_type(Handle(self.emitter, self.event_kind, key), *args, **kwargs) + framework._emit(event) + + +class HandleKind: + """Helper descriptor to define the Object.handle_kind field. + + The handle_kind for an object defaults to its type name, but it may + be explicitly overridden if desired. + """ + + def __get__(self, obj, obj_type): + kind = obj_type.__dict__.get("handle_kind") + if kind: + return kind + return obj_type.__name__ + + +class Object: + + handle_kind = HandleKind() + + def __init__(self, parent, key): + kind = self.handle_kind + if isinstance(parent, Framework): + self.framework = parent + # Avoid Framework instances having a circular reference to themselves. + if self.framework is self: + self.framework = weakref.proxy(self.framework) + self.handle = Handle(None, kind, key) + else: + self.framework = parent.framework + self.handle = Handle(parent, kind, key) + self.framework._track(self) + + # TODO Detect conflicting handles here. + + @property + def model(self): + return self.framework.model + + @property + def meta(self): + return self.framework.meta + + @property + def charm_dir(self): + return self.framework.charm_dir + + +class EventsBase(Object): + """Convenience type to allow defining .on attributes at class level.""" + + handle_kind = "on" + + def __init__(self, parent=None, key=None): + if parent is not None: + super().__init__(parent, key) + else: + self._cache = weakref.WeakKeyDictionary() + + def __get__(self, emitter, emitter_type): + if emitter is None: + return self + instance = self._cache.get(emitter) + if instance is None: + # Same type, different instance, more data. Doing this unusual construct + # means people can subclass just this one class to have their own 'on'. + instance = self._cache[emitter] = type(self)(emitter) + return instance + + @classmethod + def define_event(cls, event_kind, event_type): + """Define an event on this type at runtime. + + cls -- a type to define an event on. + event_kind -- an attribute name that will be used to access the event. Must be a valid python identifier, not be a keyword or an existing attribute. + event_type -- a type of the event to define. + """ + if not event_kind.isidentifier(): + raise RuntimeError(f'unable to define an event with event_kind that is not a valid python identifier: {event_kind}') + elif keyword.iskeyword(event_kind): + raise RuntimeError(f'unable to define an event with event_kind that is a python keyword: {event_kind}') + try: + getattr(cls, event_kind) + raise RuntimeError(f'unable to define an event with event_kind that overlaps with an existing type {cls} attribute: {event_kind}') + except AttributeError: + pass + + event_descriptor = EventSource(event_type) + event_descriptor.__set_name__(cls, event_kind) + setattr(cls, event_kind, event_descriptor) + + def events(self): + """Return a mapping of event_kinds to bound_events for all available events. + """ + events_map = {} + # We have to iterate over the class rather than instance to allow for properties which + # might call this method (e.g., event views), leading to infinite recursion. + for attr_name, attr_value in inspect.getmembers(type(self)): + if isinstance(attr_value, EventSource): + # We actually care about the bound_event, however, since it + # provides the most info for users of this method. + event_kind = attr_name + bound_event = getattr(self, event_kind) + events_map[event_kind] = bound_event + return events_map + + def __getitem__(self, key): + return PrefixedEvents(self, key) + + +class PrefixedEvents: + + def __init__(self, emitter, key): + self._emitter = emitter + self._prefix = key.replace("-", "_") + '_' + + def __getattr__(self, name): + return getattr(self._emitter, self._prefix + name) + + +class PreCommitEvent(EventBase): + pass + + +class CommitEvent(EventBase): + pass + + +class FrameworkEvents(EventsBase): + pre_commit = EventSource(PreCommitEvent) + commit = EventSource(CommitEvent) + + +class NoSnapshotError(Exception): + + def __init__(self, handle_path): + self.handle_path = handle_path + + def __str__(self): + return f'no snapshot data found for {self.handle_path} object' + + +class NoTypeError(Exception): + + def __init__(self, handle_path): + self.handle_path = handle_path + + def __str__(self): + return f"cannot restore {self.handle_path} since no class was registered for it" + + +class SQLiteStorage: + + DB_LOCK_TIMEOUT = timedelta(hours=1) + + def __init__(self, filename): + # The isolation_level argument is set to None such that the implicit transaction management behavior of the sqlite3 module is disabled. + self._db = sqlite3.connect(str(filename), isolation_level=None, timeout=self.DB_LOCK_TIMEOUT.total_seconds()) + self._setup() + + def _setup(self): + # Make sure that the database is locked until the connection is closed, not until the transaction ends. + self._db.execute("PRAGMA locking_mode=EXCLUSIVE") + c = self._db.execute("BEGIN") + c.execute("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='snapshot'") + if c.fetchone()[0] == 0: + # Keep in mind what might happen if the process dies somewhere below. + # The system must not be rendered permanently broken by that. + self._db.execute("CREATE TABLE snapshot (handle TEXT PRIMARY KEY, data BLOB)") + self._db.execute("CREATE TABLE notice (sequence INTEGER PRIMARY KEY AUTOINCREMENT, event_path TEXT, observer_path TEXT, method_name TEXT)") + self._db.commit() + + def close(self): + self._db.close() + + def commit(self): + self._db.commit() + + # There's commit but no rollback. For abort to be supported, we'll need logic that + # can rollback decisions made by third-party code in terms of the internal state + # of objects that have been snapshotted, and hooks to let them know about it and + # take the needed actions to undo their logic until the last snapshot. + # This is doable but will increase significantly the chances for mistakes. + + def save_snapshot(self, handle_path, snapshot_data): + self._db.execute("REPLACE INTO snapshot VALUES (?, ?)", (handle_path, snapshot_data)) + + def load_snapshot(self, handle_path): + c = self._db.cursor() + c.execute("SELECT data FROM snapshot WHERE handle=?", (handle_path,)) + row = c.fetchone() + if row: + return row[0] + return None + + def drop_snapshot(self, handle_path): + self._db.execute("DELETE FROM snapshot WHERE handle=?", (handle_path,)) + + def save_notice(self, event_path, observer_path, method_name): + self._db.execute("INSERT INTO notice VALUES (NULL, ?, ?, ?)", (event_path, observer_path, method_name)) + + def drop_notice(self, event_path, observer_path, method_name): + self._db.execute("DELETE FROM notice WHERE event_path=? AND observer_path=? AND method_name=?", (event_path, observer_path, method_name)) + + def notices(self, event_path): + if event_path: + c = self._db.execute("SELECT event_path, observer_path, method_name FROM notice WHERE event_path=? ORDER BY sequence", (event_path,)) + else: + c = self._db.execute("SELECT event_path, observer_path, method_name FROM notice ORDER BY sequence") + while True: + rows = c.fetchmany() + if not rows: + break + for row in rows: + yield tuple(row) + + +class Framework(Object): + + on = FrameworkEvents() + + # Override properties from Object so that we can set them in __init__. + model = None + meta = None + charm_dir = None + + def __init__(self, data_path, charm_dir, meta, model): + + super().__init__(self, None) + + self._data_path = data_path + self.charm_dir = charm_dir + self.meta = meta + self.model = model + self._observers = [] # [(observer_path, method_name, parent_path, event_key)] + self._observer = weakref.WeakValueDictionary() # {observer_path: observer} + self._objects = weakref.WeakValueDictionary() + self._type_registry = {} # {(parent_path, kind): cls} + self._type_known = set() # {cls} + + self._storage = SQLiteStorage(data_path) + + # We can't use the higher-level StoredState because it relies on events. + self.register_type(StoredStateData, None, StoredStateData.handle_kind) + stored_handle = Handle(None, StoredStateData.handle_kind, '_stored') + try: + self._stored = self.load_snapshot(stored_handle) + except NoSnapshotError: + self._stored = StoredStateData(self, '_stored') + self._stored['event_count'] = 0 + + def close(self): + self._storage.close() + + def _track(self, obj): + """Track object and ensure it is the only object created using its handle path.""" + if obj is self: + # Framework objects don't track themselves + return + if obj.handle.path in self.framework._objects: + raise RuntimeError(f"two objects claiming to be {obj.handle.path} have been created") + self._objects[obj.handle.path] = obj + + def _forget(self, obj): + """Stop tracking the given object. See also _track.""" + self._objects.pop(obj.handle.path, None) + + def commit(self): + # Give a chance for objects to persist data they want to before a commit is made. + self.on.pre_commit.emit() + # Make sure snapshots are saved by instances of StoredStateData. Any possible state + # modifications in on_commit handlers of instances of other classes will not be persisted. + self.on.commit.emit() + # Save our event count after all events have been emitted. + self.save_snapshot(self._stored) + self._storage.commit() + + def register_type(self, cls, parent, kind=None): + if parent and not isinstance(parent, Handle): + parent = parent.handle + if parent: + parent_path = parent.path + else: + parent_path = None + if not kind: + kind = cls.handle_kind + self._type_registry[(parent_path, kind)] = cls + self._type_known.add(cls) + + def save_snapshot(self, value): + """Save a persistent snapshot of the provided value. + + The provided value must implement the following interface: + + value.handle = Handle(...) + value.snapshot() => {...} # Simple builtin types only. + value.restore(snapshot) # Restore custom state from prior snapshot. + """ + if type(value) not in self._type_known: + raise RuntimeError(f"cannot save {type(value).__name__} values before registering that type") + data = value.snapshot() + # Use marshal as a validator, enforcing the use of simple types. + marshal.dumps(data) + # Use pickle for serialization, so the value remains portable. + raw_data = pickle.dumps(data) + self._storage.save_snapshot(value.handle.path, raw_data) + + def load_snapshot(self, handle): + parent_path = None + if handle.parent: + parent_path = handle.parent.path + cls = self._type_registry.get((parent_path, handle.kind)) + if not cls: + raise NoTypeError(handle.path) + raw_data = self._storage.load_snapshot(handle.path) + if not raw_data: + raise NoSnapshotError(handle.path) + data = pickle.loads(raw_data) + obj = cls.__new__(cls) + obj.framework = self + obj.handle = handle + obj.restore(data) + self._track(obj) + return obj + + def drop_snapshot(self, handle): + self._storage.drop_snapshot(handle.path) + + def observe(self, bound_event, observer): + """Register observer to be called when bound_event is emitted. + + The bound_event is generally provided as an attribute of the object that emits + the event, and is created in this style: + + class SomeObject: + something_happened = Event(SomethingHappened) + + That event may be observed as: + + framework.observe(someobj.something_happened, self.on_something_happened) + + If the method to be called follows the name convention "on_", it + may be omitted from the observe call. That means the above is equivalent to: + + framework.observe(someobj.something_happened, self) + + """ + if not isinstance(bound_event, BoundEvent): + raise RuntimeError(f'Framework.observe requires a BoundEvent as second parameter, got {bound_event}') + + event_type = bound_event.event_type + event_kind = bound_event.event_kind + emitter = bound_event.emitter + + self.register_type(event_type, emitter, event_kind) + + if hasattr(emitter, "handle"): + emitter_path = emitter.handle.path + else: + raise RuntimeError(f'event emitter {type(emitter).__name__} must have a "handle" attribute') + + method_name = None + if isinstance(observer, types.MethodType): + method_name = observer.__name__ + observer = observer.__self__ + else: + method_name = "on_" + event_kind + if not hasattr(observer, method_name): + raise RuntimeError(f'Observer method not provided explicitly and {type(observer).__name__} type has no "{method_name}" method') + + # Validate that the method has an acceptable call signature. + sig = inspect.signature(getattr(observer, method_name)) + # Self isn't included in the params list, so the first arg will be the event. + extra_params = list(sig.parameters.values())[1:] + if not sig.parameters: + raise TypeError(f'{type(observer).__name__}.{method_name} must accept event parameter') + elif any(param.default is inspect.Parameter.empty for param in extra_params): + # Allow for additional optional params, since there's no reason to exclude them, but + # required params will break. + raise TypeError(f'{type(observer).__name__}.{method_name} has extra required parameter') + + # TODO Prevent the exact same parameters from being registered more than once. + + self._observer[observer.handle.path] = observer + self._observers.append((observer.handle.path, method_name, emitter_path, event_kind)) + + def _next_event_key(self): + """Return the next event key that should be used, incrementing the internal counter.""" + # Increment the count first; this means the keys will start at 1, and 0 means no events have been emitted. + self._stored['event_count'] += 1 + return str(self._stored['event_count']) + + def _emit(self, event): + """See BoundEvent.emit for the public way to call this.""" + + # Save the event for all known observers before the first notification + # takes place, so that either everyone interested sees it, or nobody does. + self.save_snapshot(event) + event_path = event.handle.path + event_kind = event.handle.kind + parent_path = event.handle.parent.path + # TODO Track observers by (parent_path, event_kind) rather than as a list of all observers. Avoiding linear search through all observers for every event + for observer_path, method_name, _parent_path, _event_kind in self._observers: + if _parent_path != parent_path: + continue + if _event_kind and _event_kind != event_kind: + continue + # Again, only commit this after all notices are saved. + self._storage.save_notice(event_path, observer_path, method_name) + self._reemit(event_path) + + def reemit(self): + """Reemit previously deferred events to the observers that deferred them. + + Only the specific observers that have previously deferred the event will be + notified again. Observers that asked to be notified about events after it's + been first emitted won't be notified, as that would mean potentially observing + events out of order. + """ + self._reemit() + + def _reemit(self, single_event_path=None): + last_event_path = None + deferred = True + for event_path, observer_path, method_name in self._storage.notices(single_event_path): + event_handle = Handle.from_path(event_path) + + if last_event_path != event_path: + if not deferred: + self._storage.drop_snapshot(last_event_path) + last_event_path = event_path + deferred = False + + try: + event = self.load_snapshot(event_handle) + except NoTypeError: + self._storage.drop_notice(event_path, observer_path, method_name) + continue + + event.deferred = False + observer = self._observer.get(observer_path) + if observer: + custom_handler = getattr(observer, method_name, None) + if custom_handler: + custom_handler(event) + + if event.deferred: + deferred = True + else: + self._storage.drop_notice(event_path, observer_path, method_name) + # We intentionally consider this event to be dead and reload it from scratch in the next path. + self.framework._forget(event) + + if not deferred: + self._storage.drop_snapshot(last_event_path) + + +class StoredStateChanged(EventBase): + pass + + +class StoredStateEvents(EventsBase): + changed = EventSource(StoredStateChanged) + + +class StoredStateData(Object): + + on = StoredStateEvents() + + def __init__(self, parent, attr_name): + super().__init__(parent, attr_name) + self._cache = {} + self.dirty = False + + def __getitem__(self, key): + return self._cache.get(key) + + def __setitem__(self, key, value): + self._cache[key] = value + self.dirty = True + + def __contains__(self, key): + return key in self._cache + + def snapshot(self): + return self._cache + + def restore(self, snapshot): + self._cache = snapshot + self.dirty = False + + def on_commit(self, event): + if self.dirty: + self.framework.save_snapshot(self) + self.dirty = False + + +class BoundStoredState: + + def __init__(self, parent, attr_name): + parent.framework.register_type(StoredStateData, parent) + + handle = Handle(parent, StoredStateData.handle_kind, attr_name) + try: + data = parent.framework.load_snapshot(handle) + except NoSnapshotError: + data = StoredStateData(parent, attr_name) + + # __dict__ is used to avoid infinite recursion. + self.__dict__["_data"] = data + self.__dict__["_attr_name"] = attr_name + + parent.framework.observe(parent.framework.on.commit, self._data) + + def __getattr__(self, key): + # "on" is the only reserved key that can't be used in the data map. + if key == "on": + return self._data.on + if key not in self._data: + raise AttributeError(f"attribute '{key}' is not stored") + return _wrap_stored(self._data, self._data[key]) + + def __setattr__(self, key, value): + if key == "on": + raise AttributeError(f"attribute 'on' is reserved and cannot be set") + + value = _unwrap_stored(self._data, value) + + if not isinstance(value, (type(None), int, str, bytes, list, dict, set)): + raise AttributeError(f"attribute '{key}' cannot be set to {type(value).__name__}: must be int/dict/list/etc") + + self._data[key] = _unwrap_stored(self._data, value) + self.on.changed.emit() + + def set_default(self, **kwargs): + """"Set the value of any given key if it has not already been set""" + for k, v in kwargs.items(): + if k not in self._data: + self._data[k] = v + + +class StoredState: + + def __init__(self): + self.parent_type = None + self.attr_name = None + + def __get__(self, parent, parent_type=None): + if self.parent_type is None: + self.parent_type = parent_type + elif self.parent_type is not parent_type: + raise RuntimeError("StoredState shared by {} and {}".format(self.parent_type.__name__, parent_type.__name__)) + + if parent is None: + return self + + bound = parent.__dict__.get(self.attr_name) + if bound is None: + for attr_name, attr_value in parent_type.__dict__.items(): + if attr_value is self: + if self.attr_name and attr_name != self.attr_name: + parent_tname = parent_type.__name__ + raise RuntimeError(f"StoredState shared by {parent_tname}.{self.attr_name} and {parent_tname}.{attr_name}") + self.attr_name = attr_name + bound = BoundStoredState(parent, attr_name) + parent.__dict__[attr_name] = bound + break + else: + raise RuntimeError("cannot find StoredVariable attribute in type {}".format(parent_type.__name__)) + + return bound + + +def _wrap_stored(parent_data, value): + t = type(value) + if t is dict: + return StoredDict(parent_data, value) + if t is list: + return StoredList(parent_data, value) + if t is set: + return StoredSet(parent_data, value) + return value + + +def _unwrap_stored(parent_data, value): + t = type(value) + if t is StoredDict or t is StoredList or t is StoredSet: + return value._under + return value + + +class StoredDict(collections.abc.MutableMapping): + + def __init__(self, stored_data, under): + self._stored_data = stored_data + self._under = under + + def __getitem__(self, key): + return _wrap_stored(self._stored_data, self._under[key]) + + def __setitem__(self, key, value): + self._under[key] = _unwrap_stored(self._stored_data, value) + self._stored_data.dirty = True + + def __delitem__(self, key): + del self._under[key] + self._stored_data.dirty = True + + def __iter__(self): + return self._under.__iter__() + + def __len__(self): + return len(self._under) + + def __eq__(self, other): + if isinstance(other, StoredDict): + return self._under == other._under + elif isinstance(other, collections.abc.Mapping): + return self._under == other + else: + return NotImplemented + + +class StoredList(collections.abc.MutableSequence): + + def __init__(self, stored_data, under): + self._stored_data = stored_data + self._under = under + + def __getitem__(self, index): + return _wrap_stored(self._stored_data, self._under[index]) + + def __setitem__(self, index, value): + self._under[index] = _unwrap_stored(self._stored_data, value) + self._stored_data.dirty = True + + def __delitem__(self, index): + del self._under[index] + self._stored_data.dirty = True + + def __len__(self): + return len(self._under) + + def insert(self, index, value): + self._under.insert(index, value) + self._stored_data.dirty = True + + def append(self, value): + self._under.append(value) + self._stored_data.dirty = True + + def __eq__(self, other): + if isinstance(other, StoredList): + return self._under == other._under + elif isinstance(other, collections.abc.Sequence): + return self._under == other + else: + return NotImplemented + + def __lt__(self, other): + if isinstance(other, StoredList): + return self._under < other._under + elif isinstance(other, collections.abc.Sequence): + return self._under < other + else: + return NotImplemented + + def __le__(self, other): + if isinstance(other, StoredList): + return self._under <= other._under + elif isinstance(other, collections.abc.Sequence): + return self._under <= other + else: + return NotImplemented + + def __gt__(self, other): + if isinstance(other, StoredList): + return self._under > other._under + elif isinstance(other, collections.abc.Sequence): + return self._under > other + else: + return NotImplemented + + def __ge__(self, other): + if isinstance(other, StoredList): + return self._under >= other._under + elif isinstance(other, collections.abc.Sequence): + return self._under >= other + else: + return NotImplemented + + +class StoredSet(collections.abc.MutableSet): + + def __init__(self, stored_data, under): + self._stored_data = stored_data + self._under = under + + def add(self, key): + self._under.add(key) + self._stored_data.dirty = True + + def discard(self, key): + self._under.discard(key) + self._stored_data.dirty = True + + def __contains__(self, key): + return key in self._under + + def __iter__(self): + return self._under.__iter__() + + def __len__(self): + return len(self._under) + + @classmethod + def _from_iterable(cls, it): + """Construct an instance of the class from any iterable input. + + Per https://docs.python.org/3/library/collections.abc.html + if the Set mixin is being used in a class with a different constructor signature, + you will need to override _from_iterable() with a classmethod that can construct + new instances from an iterable argument. + """ + return set(it) + + def __le__(self, other): + if isinstance(other, StoredSet): + return self._under <= other._under + elif isinstance(other, collections.abc.Set): + return self._under <= other + else: + return NotImplemented + + def __ge__(self, other): + if isinstance(other, StoredSet): + return self._under >= other._under + elif isinstance(other, collections.abc.Set): + return self._under >= other + else: + return NotImplemented + + def __eq__(self, other): + if isinstance(other, StoredSet): + return self._under == other._under + elif isinstance(other, collections.abc.Set): + return self._under == other + else: + return NotImplemented diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/jujuversion.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/jujuversion.py new file mode 100755 index 00000000..5256f24f --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/jujuversion.py @@ -0,0 +1,77 @@ +# Copyright 2020 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import re +from functools import total_ordering + + +@total_ordering +class JujuVersion: + + PATTERN = r'^(?P\d{1,9})\.(?P\d{1,9})((?:\.|-(?P[a-z]+))(?P\d{1,9}))?(\.(?P\d{1,9}))?$' + + def __init__(self, version): + m = re.match(self.PATTERN, version) + if not m: + raise RuntimeError(f'"{version}" is not a valid Juju version string') + + d = m.groupdict() + self.major = int(m.group('major')) + self.minor = int(m.group('minor')) + self.tag = d['tag'] or '' + self.patch = int(d['patch'] or 0) + self.build = int(d['build'] or 0) + + def __repr__(self): + if self.tag: + s = f'{self.major}.{self.minor}-{self.tag}{self.patch}' + else: + s = f'{self.major}.{self.minor}.{self.patch}' + if self.build > 0: + s += f'.{self.build}' + return s + + def __eq__(self, other): + if self is other: + return True + if isinstance(other, str): + other = type(self)(other) + elif not isinstance(other, JujuVersion): + raise RuntimeError(f'cannot compare Juju version "{self}" with "{other}"') + return self.major == other.major and self.minor == other.minor\ + and self.tag == other.tag and self.build == other.build and self.patch == other.patch + + def __lt__(self, other): + if self is other: + return False + if isinstance(other, str): + other = type(self)(other) + elif not isinstance(other, JujuVersion): + raise RuntimeError(f'cannot compare Juju version "{self}" with "{other}"') + + if self.major != other.major: + return self.major < other.major + elif self.minor != other.minor: + return self.minor < other.minor + elif self.tag != other.tag: + if not self.tag: + return False + elif not other.tag: + return True + return self.tag < other.tag + elif self.patch != other.patch: + return self.patch < other.patch + elif self.build != other.build: + return self.build < other.build + return False diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/main.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/main.py new file mode 100755 index 00000000..c8d5da2a --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/main.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +# Copyright 2019 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import sys +from pathlib import Path + +import yaml + +import ops.charm +import ops.framework +import ops.model + +CHARM_STATE_FILE = '.unit-state.db' + + +def debugf(format, *args, **kwargs): + pass + + +def _get_charm_dir(): + charm_dir = os.environ.get("JUJU_CHARM_DIR") + if charm_dir is None: + # Assume $JUJU_CHARM_DIR/lib/op/main.py structure. + charm_dir = Path(f'{__file__}/../../..').resolve() + else: + charm_dir = Path(charm_dir).resolve() + return charm_dir + + +def _load_metadata(charm_dir): + metadata = yaml.safe_load((charm_dir / 'metadata.yaml').read_text()) + + actions_meta = charm_dir / 'actions.yaml' + if actions_meta.exists(): + actions_metadata = yaml.safe_load(actions_meta.read_text()) + else: + actions_metadata = {} + return metadata, actions_metadata + + +def _create_event_link(charm, bound_event): + """Create a symlink for a particular event. + + charm -- A charm object. + bound_event -- An event for which to create a symlink. + """ + if issubclass(bound_event.event_type, ops.charm.HookEvent): + event_dir = charm.framework.charm_dir / 'hooks' + event_path = event_dir / bound_event.event_kind.replace('_', '-') + elif issubclass(bound_event.event_type, ops.charm.ActionEvent): + if not bound_event.event_kind.endswith("_action"): + raise RuntimeError(f"action event name {bound_event.event_kind} needs _action suffix") + event_dir = charm.framework.charm_dir / 'actions' + # The event_kind is suffixed with "_action" while the executable is not. + event_path = event_dir / bound_event.event_kind[:-len('_action')].replace('_', '-') + else: + raise RuntimeError(f'cannot create a symlink: unsupported event type {bound_event.event_type}') + + event_dir.mkdir(exist_ok=True) + if not event_path.exists(): + # CPython has different implementations for populating sys.argv[0] for Linux and Windows. For Windows + # it is always an absolute path (any symlinks are resolved) while for Linux it can be a relative path. + target_path = os.path.relpath(os.path.realpath(sys.argv[0]), event_dir) + + # Ignore the non-symlink files or directories assuming the charm author knows what they are doing. + debugf(f'Creating a new relative symlink at {event_path} pointing to {target_path}') + event_path.symlink_to(target_path) + + +def _setup_event_links(charm_dir, charm): + """Set up links for supported events that originate from Juju. + + Whether a charm can handle an event or not can be determined by + introspecting which events are defined on it. + + Hooks or actions are created as symlinks to the charm code file which is determined by inspecting + symlinks provided by the charm author at hooks/install or hooks/start. + + charm_dir -- A root directory of the charm. + charm -- An instance of the Charm class. + """ + for bound_event in charm.on.events().values(): + # Only events that originate from Juju need symlinks. + if issubclass(bound_event.event_type, (ops.charm.HookEvent, ops.charm.ActionEvent)): + _create_event_link(charm, bound_event) + + +def _emit_charm_event(charm, event_name): + """Emits a charm event based on a Juju event name. + + charm -- A charm instance to emit an event from. + event_name -- A Juju event name to emit on a charm. + """ + event_to_emit = None + try: + event_to_emit = getattr(charm.on, event_name) + except AttributeError: + debugf(f"event {event_name} not defined for {charm}") + + # If the event is not supported by the charm implementation, do + # not error out or try to emit it. This is to support rollbacks. + if event_to_emit is not None: + args, kwargs = _get_event_args(charm, event_to_emit) + debugf(f'Emitting Juju event {event_name}') + event_to_emit.emit(*args, **kwargs) + + +def _get_event_args(charm, bound_event): + event_type = bound_event.event_type + model = charm.framework.model + + if issubclass(event_type, ops.charm.RelationEvent): + relation_name = os.environ['JUJU_RELATION'] + relation_id = int(os.environ['JUJU_RELATION_ID'].split(':')[-1]) + relation = model.get_relation(relation_name, relation_id) + else: + relation = None + + remote_app_name = os.environ.get('JUJU_REMOTE_APP', '') + remote_unit_name = os.environ.get('JUJU_REMOTE_UNIT', '') + if remote_app_name or remote_unit_name: + if not remote_app_name: + if '/' not in remote_unit_name: + raise RuntimeError(f'invalid remote unit name: {remote_unit_name}') + remote_app_name = remote_unit_name.split('/')[0] + args = [relation, model.get_app(remote_app_name)] + if remote_unit_name: + args.append(model.get_unit(remote_unit_name)) + return args, {} + elif relation: + return [relation], {} + return [], {} + + +def main(charm_class): + """Setup the charm and dispatch the observed event. + + The event name is based on the way this executable was called (argv[0]). + """ + + charm_dir = _get_charm_dir() + + # Process the Juju event relevant to the current hook execution + # JUJU_HOOK_NAME, JUJU_FUNCTION_NAME, and JUJU_ACTION_NAME are not used + # in order to support simulation of events from debugging sessions. + # TODO: For Windows, when symlinks are used, this is not a valid method of getting an event name (see LP: #1854505). + juju_exec_path = Path(sys.argv[0]) + juju_event_name = juju_exec_path.name.replace('-', '_') + if juju_exec_path.parent.name == 'actions': + juju_event_name = f'{juju_event_name}_action' + + metadata, actions_metadata = _load_metadata(charm_dir) + meta = ops.charm.CharmMeta(metadata, actions_metadata) + unit_name = os.environ['JUJU_UNIT_NAME'] + model = ops.model.Model(unit_name, meta, ops.model.ModelBackend()) + + # TODO: If Juju unit agent crashes after exit(0) from the charm code + # the framework will commit the snapshot but Juju will not commit its + # operation. + charm_state_path = charm_dir / CHARM_STATE_FILE + framework = ops.framework.Framework(charm_state_path, charm_dir, meta, model) + try: + charm = charm_class(framework, None) + + # When a charm is force-upgraded and a unit is in an error state Juju does not run upgrade-charm and + # instead runs the failed hook followed by config-changed. Given the nature of force-upgrading + # the hook setup code is not triggered on config-changed. + # 'start' event is included as Juju does not fire the install event for K8s charms (see LP: #1854635). + if juju_event_name in ('install', 'start', 'upgrade_charm') or juju_event_name.endswith('_storage_attached'): + _setup_event_links(charm_dir, charm) + + framework.reemit() + + _emit_charm_event(charm, juju_event_name) + + framework.commit() + finally: + framework.close() diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/model.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/model.py new file mode 100644 index 00000000..a12dcca2 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/lib/ops/model.py @@ -0,0 +1,679 @@ +# Copyright 2019 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import weakref +import os +import shutil +import tempfile +import time +import datetime + +from abc import ABC, abstractmethod +from collections.abc import Mapping, MutableMapping +from pathlib import Path +from subprocess import run, PIPE, CalledProcessError + + +class Model: + + def __init__(self, unit_name, meta, backend): + self._cache = ModelCache(backend) + self._backend = backend + self.unit = self.get_unit(unit_name) + self.app = self.unit.app + self.relations = RelationMapping(meta.relations, self.unit, self._backend, self._cache) + self.config = ConfigData(self._backend) + self.resources = Resources(list(meta.resources), self._backend) + self.pod = Pod(self._backend) + self.storages = StorageMapping(list(meta.storages), self._backend) + + def get_unit(self, unit_name): + return self._cache.get(Unit, unit_name) + + def get_app(self, app_name): + return self._cache.get(Application, app_name) + + def get_relation(self, relation_name, relation_id=None): + """Get a specific Relation instance. + + If relation_id is given, this will return that Relation instance. + + If relation_id is not given, this will return the Relation instance if the + relation is established only once or None if it is not established. If this + same relation is established multiple times the error TooManyRelatedAppsError is raised. + """ + return self.relations._get_unique(relation_name, relation_id) + + +class ModelCache: + + def __init__(self, backend): + self._backend = backend + self._weakrefs = weakref.WeakValueDictionary() + + def get(self, entity_type, *args): + key = (entity_type,) + args + entity = self._weakrefs.get(key) + if entity is None: + entity = entity_type(*args, backend=self._backend, cache=self) + self._weakrefs[key] = entity + return entity + + +class Application: + + def __init__(self, name, backend, cache): + self.name = name + self._backend = backend + self._cache = cache + self._is_our_app = self.name == self._backend.app_name + self._status = None + + @property + def status(self): + if not self._is_our_app: + return UnknownStatus() + + if not self._backend.is_leader(): + raise RuntimeError('cannot get application status as a non-leader unit') + + if self._status: + return self._status + + s = self._backend.status_get(is_app=True) + self._status = StatusBase.from_name(s['status'], s['message']) + return self._status + + @status.setter + def status(self, value): + if not isinstance(value, StatusBase): + raise InvalidStatusError(f'invalid value provided for application {self} status: {value}') + + if not self._is_our_app: + raise RuntimeError(f'cannot to set status for a remote application {self}') + + if not self._backend.is_leader(): + raise RuntimeError('cannot set application status as a non-leader unit') + + self._backend.status_set(value.name, value.message, is_app=True) + self._status = value + + def __repr__(self): + return f'<{type(self).__module__}.{type(self).__name__} {self.name}>' + + +class Unit: + + def __init__(self, name, backend, cache): + self.name = name + + app_name = name.split('/')[0] + self.app = cache.get(Application, app_name) + + self._backend = backend + self._cache = cache + self._is_our_unit = self.name == self._backend.unit_name + self._status = None + + @property + def status(self): + if not self._is_our_unit: + return UnknownStatus() + + if self._status: + return self._status + + s = self._backend.status_get(is_app=False) + self._status = StatusBase.from_name(s['status'], s['message']) + return self._status + + @status.setter + def status(self, value): + if not isinstance(value, StatusBase): + raise InvalidStatusError(f'invalid value provided for unit {self} status: {value}') + + if not self._is_our_unit: + raise RuntimeError(f'cannot set status for a remote unit {self}') + + self._backend.status_set(value.name, value.message, is_app=False) + self._status = value + + def __repr__(self): + return f'<{type(self).__module__}.{type(self).__name__} {self.name}>' + + def is_leader(self): + if self._is_our_unit: + # This value is not cached as it is not guaranteed to persist for the whole duration + # of a hook execution. + return self._backend.is_leader() + else: + raise RuntimeError(f"cannot determine leadership status for remote applications: {self}") + + +class LazyMapping(Mapping, ABC): + + _lazy_data = None + + @abstractmethod + def _load(self): + raise NotImplementedError() + + @property + def _data(self): + data = self._lazy_data + if data is None: + data = self._lazy_data = self._load() + return data + + def __contains__(self, key): + return key in self._data + + def __len__(self): + return len(self._data) + + def __iter__(self): + return iter(self._data) + + def __getitem__(self, key): + return self._data[key] + + +class RelationMapping(Mapping): + """Map of relation names to lists of Relation instances.""" + + def __init__(self, relations_meta, our_unit, backend, cache): + self._peers = set() + for name, relation_meta in relations_meta.items(): + if relation_meta.role == 'peers': + self._peers.add(name) + self._our_unit = our_unit + self._backend = backend + self._cache = cache + self._data = {relation_name: None for relation_name in relations_meta} + + def __contains__(self, key): + return key in self._data + + def __len__(self): + return len(self._data) + + def __iter__(self): + return iter(self._data) + + def __getitem__(self, relation_name): + is_peer = relation_name in self._peers + relation_list = self._data[relation_name] + if relation_list is None: + relation_list = self._data[relation_name] = [] + for rid in self._backend.relation_ids(relation_name): + relation = Relation(relation_name, rid, is_peer, self._our_unit, self._backend, self._cache) + relation_list.append(relation) + return relation_list + + def _get_unique(self, relation_name, relation_id=None): + if relation_id is not None: + if not isinstance(relation_id, int): + raise ModelError(f'relation name {relation_id} must be int or None not {type(relation_id).__name__}') + for relation in self[relation_name]: + if relation.id == relation_id: + return relation + else: + # The relation may be dead, but it is not forgotten. + is_peer = relation_name in self._peers + return Relation(relation_name, relation_id, is_peer, self._our_unit, self._backend, self._cache) + num_related = len(self[relation_name]) + if num_related == 0: + return None + elif num_related == 1: + return self[relation_name][0] + else: + # TODO: We need something in the framework to catch and gracefully handle + # errors, ideally integrating the error catching with Juju's mechanisms. + raise TooManyRelatedAppsError(relation_name, num_related, 1) + + +class Relation: + def __init__(self, relation_name, relation_id, is_peer, our_unit, backend, cache): + self.name = relation_name + self.id = relation_id + self.app = None + self.units = set() + + # For peer relations, both the remote and the local app are the same. + if is_peer: + self.app = our_unit.app + try: + for unit_name in backend.relation_list(self.id): + unit = cache.get(Unit, unit_name) + self.units.add(unit) + if self.app is None: + self.app = unit.app + except RelationNotFoundError: + # If the relation is dead, just treat it as if it has no remote units. + pass + self.data = RelationData(self, our_unit, backend) + + def __repr__(self): + return f'<{type(self).__module__}.{type(self).__name__} {self.name}:{self.id}>' + + +class RelationData(Mapping): + def __init__(self, relation, our_unit, backend): + self.relation = weakref.proxy(relation) + self._data = {our_unit: RelationDataContent(self.relation, our_unit, backend)} + self._data.update({our_unit.app: RelationDataContent(self.relation, our_unit.app, backend)}) + self._data.update({unit: RelationDataContent(self.relation, unit, backend) for unit in self.relation.units}) + # The relation might be dead so avoid a None key here. + if self.relation.app: + self._data.update({self.relation.app: RelationDataContent(self.relation, self.relation.app, backend)}) + + def __contains__(self, key): + return key in self._data + + def __len__(self): + return len(self._data) + + def __iter__(self): + return iter(self._data) + + def __getitem__(self, key): + return self._data[key] + + +# We mix in MutableMapping here to get some convenience implementations, but whether it's actually +# mutable or not is controlled by the flag. +class RelationDataContent(LazyMapping, MutableMapping): + + def __init__(self, relation, entity, backend): + self.relation = relation + self._entity = entity + self._backend = backend + self._is_app = isinstance(entity, Application) + + def _load(self): + try: + return self._backend.relation_get(self.relation.id, self._entity.name, self._is_app) + except RelationNotFoundError: + # Dead relations tell no tales (and have no data). + return {} + + def _is_mutable(self): + if self._is_app: + is_our_app = self._backend.app_name == self._entity.name + if not is_our_app: + return False + # Whether the application data bag is mutable or not depends on whether this unit is a leader or not, + # but this is not guaranteed to be always true during the same hook execution. + return self._backend.is_leader() + else: + is_our_unit = self._backend.unit_name == self._entity.name + if is_our_unit: + return True + return False + + def __setitem__(self, key, value): + if not self._is_mutable(): + raise RelationDataError(f'cannot set relation data for {self._entity.name}') + if not isinstance(value, str): + raise RelationDataError('relation data values must be strings') + + self._backend.relation_set(self.relation.id, key, value, self._is_app) + + # Don't load data unnecessarily if we're only updating. + if self._lazy_data is not None: + if value == '': + # Match the behavior of Juju, which is that setting the value to an empty string will + # remove the key entirely from the relation data. + del self._data[key] + else: + self._data[key] = value + + def __delitem__(self, key): + # Match the behavior of Juju, which is that setting the value to an empty string will + # remove the key entirely from the relation data. + self.__setitem__(key, '') + + +class ConfigData(LazyMapping): + + def __init__(self, backend): + self._backend = backend + + def _load(self): + return self._backend.config_get() + + +class StatusBase: + """Status values specific to applications and units.""" + + _statuses = {} + + def __init__(self, message): + self.message = message + + def __new__(cls, *args, **kwargs): + if cls is StatusBase: + raise TypeError("cannot instantiate a base class") + cls._statuses[cls.name] = cls + return super().__new__(cls) + + @classmethod + def from_name(cls, name, message): + return cls._statuses[name](message) + + +class ActiveStatus(StatusBase): + """The unit is ready. + + The unit believes it is correctly offering all the services it has been asked to offer. + """ + name = 'active' + + def __init__(self, message=None): + super().__init__(message or '') + + +class BlockedStatus(StatusBase): + """The unit requires manual intervention. + + An operator has to manually intervene to unblock the unit and let it proceed. + """ + name = 'blocked' + + +class MaintenanceStatus(StatusBase): + """The unit is performing maintenance tasks. + + The unit is not yet providing services, but is actively doing work in preparation for providing those services. + This is a "spinning" state, not an error state. It reflects activity on the unit itself, not on peers or related units. + """ + name = 'maintenance' + + +class UnknownStatus(StatusBase): + """The unit status is unknown. + + A unit-agent has finished calling install, config-changed and start, but the charm has not called status-set yet. + """ + name = 'unknown' + + def __init__(self): + # Unknown status cannot be set and does not have a message associated with it. + super().__init__('') + + +class WaitingStatus(StatusBase): + """A unit is unable to progress. + + The unit is unable to progress to an active state because an application to which it is related is not running. + """ + name = 'waiting' + + +class Resources: + """Object representing resources for the charm. + """ + + def __init__(self, names, backend): + self._backend = backend + self._paths = {name: None for name in names} + + def fetch(self, name): + """Fetch the resource from the controller or store. + + If successfully fetched, this returns a Path object to where the resource is stored + on disk, otherwise it raises a ModelError. + """ + if name not in self._paths: + raise RuntimeError(f'invalid resource name: {name}') + if self._paths[name] is None: + self._paths[name] = Path(self._backend.resource_get(name)) + return self._paths[name] + + +class Pod: + def __init__(self, backend): + self._backend = backend + + def set_spec(self, spec, k8s_resources=None): + if not self._backend.is_leader(): + raise ModelError('cannot set a pod spec as this unit is not a leader') + self._backend.pod_spec_set(spec, k8s_resources) + + +class StorageMapping(Mapping): + """Map of storage names to lists of Storage instances.""" + + def __init__(self, storage_names, backend): + self._backend = backend + self._storage_map = {storage_name: None for storage_name in storage_names} + + def __contains__(self, key): + return key in self._storage_map + + def __len__(self): + return len(self._storage_map) + + def __iter__(self): + return iter(self._storage_map) + + def __getitem__(self, storage_name): + storage_list = self._storage_map[storage_name] + if storage_list is None: + storage_list = self._storage_map[storage_name] = [] + for storage_id in self._backend.storage_list(storage_name): + storage_list.append(Storage(storage_name, storage_id, self._backend)) + return storage_list + + def request(self, storage_name, count=1): + """Requests new storage instances of a given name. + + Uses storage-add tool to request additional storage. Juju will notify the unit + via -storage-attached events when it becomes available. + """ + if storage_name not in self._storage_map: + raise ModelError(f'cannot add storage with {storage_name} as it is not present in the charm metadata') + self._backend.storage_add(storage_name, count) + + +class Storage: + + def __init__(self, storage_name, storage_id, backend): + self.name = storage_name + self.id = storage_id + self._backend = backend + self._location = None + + @property + def location(self): + if self._location is None: + self._location = Path(self._backend.storage_get(f'{self.name}/{self.id}', "location")) + return self._location + + +class ModelError(Exception): + pass + + +class TooManyRelatedAppsError(ModelError): + def __init__(self, relation_name, num_related, max_supported): + super().__init__(f'Too many remote applications on {relation_name} ({num_related} > {max_supported})') + self.relation_name = relation_name + self.num_related = num_related + self.max_supported = max_supported + + +class RelationDataError(ModelError): + pass + + +class RelationNotFoundError(ModelError): + pass + + +class InvalidStatusError(ModelError): + pass + + +class ModelBackend: + + LEASE_RENEWAL_PERIOD = datetime.timedelta(seconds=30) + + def __init__(self): + self.unit_name = os.environ['JUJU_UNIT_NAME'] + self.app_name = self.unit_name.split('/')[0] + + self._is_leader = None + self._leader_check_time = 0 + + def _run(self, *args, return_output=False, use_json=False): + kwargs = dict(stdout=PIPE, stderr=PIPE) + if use_json: + args += ('--format=json',) + try: + result = run(args, check=True, **kwargs) + except CalledProcessError as e: + raise ModelError(e.stderr) + if return_output: + if result.stdout is None: + return '' + else: + text = result.stdout.decode('utf8') + if use_json: + return json.loads(text) + else: + return text + + def relation_ids(self, relation_name): + relation_ids = self._run('relation-ids', relation_name, return_output=True, use_json=True) + return [int(relation_id.split(':')[-1]) for relation_id in relation_ids] + + def relation_list(self, relation_id): + try: + return self._run('relation-list', '-r', str(relation_id), return_output=True, use_json=True) + except ModelError as e: + if 'relation not found' in str(e): + raise RelationNotFoundError() from e + raise + + def relation_get(self, relation_id, member_name, is_app): + if not isinstance(is_app, bool): + raise TypeError('is_app parameter to relation_get must be a boolean') + + try: + return self._run('relation-get', '-r', str(relation_id), '-', member_name, f'--app={is_app}', return_output=True, use_json=True) + except ModelError as e: + if 'relation not found' in str(e): + raise RelationNotFoundError() from e + raise + + def relation_set(self, relation_id, key, value, is_app): + if not isinstance(is_app, bool): + raise TypeError('is_app parameter to relation_set must be a boolean') + + try: + return self._run('relation-set', '-r', str(relation_id), f'{key}={value}', f'--app={is_app}') + except ModelError as e: + if 'relation not found' in str(e): + raise RelationNotFoundError() from e + raise + + def config_get(self): + return self._run('config-get', return_output=True, use_json=True) + + def is_leader(self): + """Obtain the current leadership status for the unit the charm code is executing on. + + The value is cached for the duration of a lease which is 30s in Juju. + """ + now = time.monotonic() + time_since_check = datetime.timedelta(seconds=now - self._leader_check_time) + if time_since_check > self.LEASE_RENEWAL_PERIOD or self._is_leader is None: + # Current time MUST be saved before running is-leader to ensure the cache + # is only used inside the window that is-leader itself asserts. + self._leader_check_time = now + self._is_leader = self._run('is-leader', return_output=True, use_json=True) + + return self._is_leader + + def resource_get(self, resource_name): + return self._run('resource-get', resource_name, return_output=True).strip() + + def pod_spec_set(self, spec, k8s_resources): + tmpdir = Path(tempfile.mkdtemp('-pod-spec-set')) + try: + spec_path = tmpdir / 'spec.json' + spec_path.write_text(json.dumps(spec)) + args = ['--file', str(spec_path)] + if k8s_resources: + k8s_res_path = tmpdir / 'k8s-resources.json' + k8s_res_path.write_text(json.dumps(k8s_resources)) + args.extend(['--k8s-resources', str(k8s_res_path)]) + self._run('pod-spec-set', *args) + finally: + shutil.rmtree(tmpdir) + + def status_get(self, *, is_app=False): + """Get a status of a unit or an application. + app -- A boolean indicating whether the status should be retrieved for a unit or an application. + """ + return self._run('status-get', '--include-data', f'--application={is_app}') + + def status_set(self, status, message='', *, is_app=False): + """Set a status of a unit or an application. + app -- A boolean indicating whether the status should be set for a unit or an application. + """ + if not isinstance(is_app, bool): + raise TypeError('is_app parameter must be boolean') + return self._run('status-set', f'--application={is_app}', status, message) + + def storage_list(self, name): + return [int(s.split('/')[1]) for s in self._run('storage-list', name, return_output=True, use_json=True)] + + def storage_get(self, storage_name_id, attribute): + return self._run('storage-get', '-s', storage_name_id, attribute, return_output=True, use_json=True) + + def storage_add(self, name, count=1): + if not isinstance(count, int) or isinstance(count, bool): + raise TypeError(f'storage count must be integer, got: {count} ({type(count)})') + self._run('storage-add', f'{name}={count}') + + def action_get(self): + return self._run(f'action-get', return_output=True, use_json=True) + + def action_set(self, results): + self._run(f'action-set', *[f"{k}={v}" for k, v in results.items()]) + + def action_log(self, message): + self._run(f'action-log', f"{message}") + + def action_fail(self, message=''): + self._run(f'action-fail', f"{message}") + + def network_get(self, endpoint_name, relation_id=None): + """Return network info provided by network-get for a given endpoint. + + endpoint_name -- A name of an endpoint (relation name or extra-binding name). + relation_id -- An optional relation id to get network info for. + """ + cmd = ['network-get', endpoint_name] + if relation_id is not None: + cmd.extend(['-r', str(relation_id)]) + try: + return self._run(*cmd, return_output=True, use_json=True) + except ModelError as e: + if 'relation not found' in str(e): + raise RelationNotFoundError() from e + raise diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/metadata.yaml b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/metadata.yaml new file mode 100644 index 00000000..3b3aed87 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/metadata.yaml @@ -0,0 +1,26 @@ +name: main +summary: A charm used for testing the basic operation of the entrypoint code. +maintainer: Dmitrii Shcherbakov +description: A charm used for testing the basic operation of the entrypoint code. +tags: + - misc +series: + - bionic + - cosmic + - disco +min-juju-version: 2.7.1 +provides: + db: + interface: db +requires: + mon: + interface: monitoring +peers: + ha: + interface: cluster +subordinate: false +storage: + disks: + type: block + multiple: + range: 0- diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/src/charm.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/src/charm.py new file mode 100755 index 00000000..c20ae783 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/charms/test_main/src/charm.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +# Copyright 2019 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import base64 +import pickle +import sys +sys.path.append('lib') # noqa + +from ops.charm import CharmBase +from ops.main import main + +import logging + +logger = logging.getLogger() + + +class Charm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + + # This environment variable controls the test charm behavior. + charm_config = os.environ.get('CHARM_CONFIG') + if charm_config is not None: + self._charm_config = pickle.loads(base64.b64decode(charm_config)) + else: + self._charm_config = {} + + self._state_file = self._charm_config.get('STATE_FILE') + self._state = {} + + self._state['on_install'] = [] + self._state['on_start'] = [] + self._state['on_config_changed'] = [] + self._state['on_update_status'] = [] + self._state['on_leader_settings_changed'] = [] + self._state['on_db_relation_joined'] = [] + self._state['on_mon_relation_changed'] = [] + self._state['on_mon_relation_departed'] = [] + self._state['on_ha_relation_broken'] = [] + self._state['on_foo_bar_action'] = [] + self._state['on_start_action'] = [] + + # Observed event types per invocation. A list is used to preserve the order in which charm handlers have observed the events. + self._state['observed_event_types'] = [] + + self.framework.observe(self.on.install, self) + self.framework.observe(self.on.start, self) + self.framework.observe(self.on.config_changed, self) + self.framework.observe(self.on.update_status, self) + self.framework.observe(self.on.leader_settings_changed, self) + # Test relation events with endpoints from different + # sections (provides, requires, peers) as well. + self.framework.observe(self.on.db_relation_joined, self) + self.framework.observe(self.on.mon_relation_changed, self) + self.framework.observe(self.on.mon_relation_departed, self) + self.framework.observe(self.on.ha_relation_broken, self) + + if self._charm_config.get('USE_ACTIONS'): + self.framework.observe(self.on.start_action, self) + self.framework.observe(self.on.foo_bar_action, self) + + def _write_state(self): + """Write state variables so that the parent process can read them. + + Each invocation will override the previous state which is intentional. + """ + if self._state_file is not None: + with open(self._state_file, 'wb') as f: + pickle.dump(self._state, f) + + def on_install(self, event): + self._state['on_install'].append(type(event)) + self._state['observed_event_types'].append(type(event)) + self._write_state() + + def on_start(self, event): + self._state['on_start'].append(type(event)) + self._state['observed_event_types'].append(type(event)) + self._write_state() + + def on_config_changed(self, event): + self._state['on_config_changed'].append(type(event)) + self._state['observed_event_types'].append(type(event)) + event.defer() + self._write_state() + + def on_update_status(self, event): + self._state['on_update_status'].append(type(event)) + self._state['observed_event_types'].append(type(event)) + self._write_state() + + def on_leader_settings_changed(self, event): + self._state['on_leader_settings_changed'].append(type(event)) + self._state['observed_event_types'].append(type(event)) + self._write_state() + + def on_db_relation_joined(self, event): + assert event.app is not None, 'application name cannot be None for a relation-joined event' + self._state['on_db_relation_joined'].append(type(event)) + self._state['observed_event_types'].append(type(event)) + self._state['db_relation_joined_data'] = event.snapshot() + self._write_state() + + def on_mon_relation_changed(self, event): + assert event.app is not None, 'application name cannot be None for a relation-changed event' + if os.environ.get('JUJU_REMOTE_UNIT'): + assert event.unit is not None, 'a unit name cannot be None for a relation-changed event associated with a remote unit' + self._state['on_mon_relation_changed'].append(type(event)) + self._state['observed_event_types'].append(type(event)) + self._state['mon_relation_changed_data'] = event.snapshot() + self._write_state() + + def on_mon_relation_departed(self, event): + assert event.app is not None, 'application name cannot be None for a relation-departed event' + self._state['on_mon_relation_departed'].append(type(event)) + self._state['observed_event_types'].append(type(event)) + self._state['mon_relation_departed_data'] = event.snapshot() + self._write_state() + + def on_ha_relation_broken(self, event): + assert event.app is None, 'relation-broken events cannot have a reference to a remote application' + assert event.unit is None, 'relation broken events cannot have a reference to a remote unit' + self._state['on_ha_relation_broken'].append(type(event)) + self._state['observed_event_types'].append(type(event)) + self._state['ha_relation_broken_data'] = event.snapshot() + self._write_state() + + def on_start_action(self, event): + assert event.handle.kind == 'start_action', 'event action name cannot be different from the one being handled' + self._state['on_start_action'].append(type(event)) + self._state['observed_event_types'].append(type(event)) + self._write_state() + + def on_foo_bar_action(self, event): + assert event.handle.kind == 'foo_bar_action', 'event action name cannot be different from the one being handled' + self._state['on_foo_bar_action'].append(type(event)) + self._state['observed_event_types'].append(type(event)) + self._write_state() + + +if __name__ == '__main__': + main(Charm) diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_charm.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_charm.py new file mode 100755 index 00000000..26a943c4 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_charm.py @@ -0,0 +1,311 @@ +#!/usr/bin/python3 +# Copyright 2019 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import unittest +import tempfile +import shutil + +from pathlib import Path + +from ops.charm import ( + CharmBase, + CharmMeta, + CharmEvents, +) +from ops.framework import Framework, EventSource, EventBase +from ops.model import Model, ModelBackend + +from .test_helpers import fake_script, fake_script_calls + + +class TestCharm(unittest.TestCase): + + def setUp(self): + def restore_env(env): + os.environ.clear() + os.environ.update(env) + self.addCleanup(restore_env, os.environ.copy()) + + os.environ['PATH'] = f"{str(Path(__file__).parent / 'bin')}:{os.environ['PATH']}" + os.environ['JUJU_UNIT_NAME'] = 'local/0' + + self.tmpdir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmpdir) + self.meta = CharmMeta() + + class CustomEvent(EventBase): + pass + + class TestCharmEvents(CharmEvents): + custom = EventSource(CustomEvent) + + # Relations events are defined dynamically and modify the class attributes. + # We use a subclass temporarily to prevent these side effects from leaking. + CharmBase.on = TestCharmEvents() + + def cleanup(): + CharmBase.on = CharmEvents() + self.addCleanup(cleanup) + + def create_framework(self): + model = Model('local/0', self.meta, ModelBackend()) + framework = Framework(self.tmpdir / "framework.data", self.tmpdir, self.meta, model) + self.addCleanup(framework.close) + return framework + + def test_basic(self): + + class MyCharm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + + self.started = False + framework.observe(self.on.start, self) + + def on_start(self, event): + self.started = True + + events = list(MyCharm.on.events()) + self.assertIn('install', events) + self.assertIn('custom', events) + + framework = self.create_framework() + charm = MyCharm(framework, None) + charm.on.start.emit() + + self.assertEqual(charm.started, True) + + def test_relation_events(self): + + class MyCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + self.seen = [] + for rel in ('req1', 'req-2', 'pro1', 'pro-2', 'peer1', 'peer-2'): + # Hook up relation events to generic handler. + self.framework.observe(self.on[rel].relation_joined, self.on_any_relation) + self.framework.observe(self.on[rel].relation_changed, self.on_any_relation) + self.framework.observe(self.on[rel].relation_departed, self.on_any_relation) + self.framework.observe(self.on[rel].relation_broken, self.on_any_relation) + + def on_any_relation(self, event): + assert event.relation.name == 'req1' + assert event.relation.app.name == 'remote' + self.seen.append(type(event).__name__) + + # language=YAML + self.meta = CharmMeta.from_yaml(metadata=''' +name: my-charm +requires: + req1: + interface: req1 + req-2: + interface: req2 +provides: + pro1: + interface: pro1 + pro-2: + interface: pro2 +peers: + peer1: + interface: peer1 + peer-2: + interface: peer2 +''') + + charm = MyCharm(self.create_framework(), None) + + rel = charm.framework.model.get_relation('req1', 1) + unit = charm.framework.model.get_unit('remote/0') + charm.on['req1'].relation_joined.emit(rel, unit) + charm.on['req1'].relation_changed.emit(rel, unit) + charm.on['req-2'].relation_changed.emit(rel, unit) + charm.on['pro1'].relation_departed.emit(rel, unit) + charm.on['pro-2'].relation_departed.emit(rel, unit) + charm.on['peer1'].relation_broken.emit(rel) + charm.on['peer-2'].relation_broken.emit(rel) + + self.assertEqual(charm.seen, [ + 'RelationJoinedEvent', + 'RelationChangedEvent', + 'RelationChangedEvent', + 'RelationDepartedEvent', + 'RelationDepartedEvent', + 'RelationBrokenEvent', + 'RelationBrokenEvent', + ]) + + def test_storage_events(self): + + class MyCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + self.seen = [] + self.framework.observe(self.on['stor1'].storage_attached, self) + self.framework.observe(self.on['stor2'].storage_detaching, self) + self.framework.observe(self.on['stor3'].storage_attached, self) + self.framework.observe(self.on['stor-4'].storage_attached, self) + + def on_stor1_storage_attached(self, event): + self.seen.append(f'{type(event).__name__}') + + def on_stor2_storage_detaching(self, event): + self.seen.append(f'{type(event).__name__}') + + def on_stor3_storage_attached(self, event): + self.seen.append(f'{type(event).__name__}') + + def on_stor_4_storage_attached(self, event): + self.seen.append(f'{type(event).__name__}') + + # language=YAML + self.meta = CharmMeta.from_yaml(''' +name: my-charm +storage: + stor-4: + multiple: + range: 2-4 + type: filesystem + stor1: + type: filesystem + stor2: + multiple: + range: "2" + type: filesystem + stor3: + multiple: + range: 2- + type: filesystem +''') + + self.assertIsNone(self.meta.storages['stor1'].multiple_range) + self.assertEqual(self.meta.storages['stor2'].multiple_range, (2, 2)) + self.assertEqual(self.meta.storages['stor3'].multiple_range, (2, None)) + self.assertEqual(self.meta.storages['stor-4'].multiple_range, (2, 4)) + + charm = MyCharm(self.create_framework(), None) + + charm.on['stor1'].storage_attached.emit() + charm.on['stor2'].storage_detaching.emit() + charm.on['stor3'].storage_attached.emit() + charm.on['stor-4'].storage_attached.emit() + + self.assertEqual(charm.seen, [ + 'StorageAttachedEvent', + 'StorageDetachingEvent', + 'StorageAttachedEvent', + 'StorageAttachedEvent', + ]) + + @classmethod + def _get_action_test_meta(cls): + # language=YAML + return CharmMeta.from_yaml(metadata=''' +name: my-charm +''', actions=''' +foo-bar: + description: "Foos the bar." + params: + foo-name: + description: "A foo name to bar" + type: string + silent: + default: false + description: "" + type: boolean + required: foo-bar + title: foo-bar +start: + description: "Start the unit." +''') + + def _test_action_events(self, cmd_type): + + class MyCharm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + framework.observe(self.on.foo_bar_action, self) + framework.observe(self.on.start_action, self) + + def on_foo_bar_action(self, event): + self.seen_action_params = event.params + event.log('test-log') + event.set_results({'res': 'val with spaces'}) + event.fail('test-fail') + + def on_start_action(self, event): + pass + + fake_script(self, f'{cmd_type}-get', """echo '{"foo-name": "name", "silent": true}'""") + fake_script(self, f'{cmd_type}-set', "") + fake_script(self, f'{cmd_type}-log', "") + fake_script(self, f'{cmd_type}-fail', "") + self.meta = self._get_action_test_meta() + + os.environ[f'JUJU_{cmd_type.upper()}_NAME'] = 'foo-bar' + framework = self.create_framework() + charm = MyCharm(framework, None) + + events = list(MyCharm.on.events()) + self.assertIn('foo_bar_action', events) + self.assertIn('start_action', events) + + charm.on.foo_bar_action.emit() + self.assertEqual(charm.seen_action_params, {"foo-name": "name", "silent": True}) + self.assertEqual(fake_script_calls(self), [ + [f'{cmd_type}-get', '--format=json'], + [f'{cmd_type}-log', "test-log"], + [f'{cmd_type}-set', "res=val with spaces"], + [f'{cmd_type}-fail', "test-fail"], + ]) + + # Make sure that action events that do not match the current context are + # not possible to emit by hand. + with self.assertRaises(RuntimeError): + charm.on.start_action.emit() + + def test_action_events(self): + self._test_action_events('action') + + def _test_action_event_defer_fails(self, cmd_type): + + class MyCharm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + framework.observe(self.on.start_action, self) + + def on_start_action(self, event): + event.defer() + + fake_script(self, f'{cmd_type}-get', """echo '{"foo-name": "name", "silent": true}'""") + self.meta = self._get_action_test_meta() + + os.environ[f'JUJU_{cmd_type.upper()}_NAME'] = 'start' + framework = self.create_framework() + charm = MyCharm(framework, None) + + with self.assertRaises(RuntimeError): + charm.on.start_action.emit() + + def test_action_event_defer_fails(self): + self._test_action_event_defer_fails('action') + + +if __name__ == "__main__": + unittest.main() diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_framework.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_framework.py new file mode 100755 index 00000000..fc364ea8 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_framework.py @@ -0,0 +1,1200 @@ +#!/usr/bin/python3 +# Copyright 2019 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +import tempfile +import shutil +import gc +import datetime + +from pathlib import Path + +from ops.framework import ( + Framework, Handle, EventSource, EventsBase, EventBase, Object, PreCommitEvent, CommitEvent, + NoSnapshotError, StoredState, StoredList, BoundStoredState, StoredStateData, SQLiteStorage +) + + +class TestFramework(unittest.TestCase): + + def setUp(self): + self.tmpdir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmpdir) + default_timeout = SQLiteStorage.DB_LOCK_TIMEOUT + + def timeout_cleanup(): + SQLiteStorage.DB_LOCK_TIMEOUT = default_timeout + SQLiteStorage.DB_LOCK_TIMEOUT = datetime.timedelta(0) + self.addCleanup(timeout_cleanup) + + def create_framework(self): + framework = Framework(self.tmpdir / "framework.data", self.tmpdir, None, None) + self.addCleanup(framework.close) + return framework + + def test_handle_path(self): + cases = [ + (Handle(None, "root", None), "root"), + (Handle(None, "root", "1"), "root[1]"), + (Handle(Handle(None, "root", None), "child", None), "root/child"), + (Handle(Handle(None, "root", "1"), "child", "2"), "root[1]/child[2]"), + ] + for handle, path in cases: + self.assertEqual(str(handle), path) + self.assertEqual(Handle.from_path(path), handle) + + def test_handle_attrs_readonly(self): + handle = Handle(None, 'kind', 'key') + with self.assertRaises(AttributeError): + handle.parent = 'foo' + with self.assertRaises(AttributeError): + handle.kind = 'foo' + with self.assertRaises(AttributeError): + handle.key = 'foo' + with self.assertRaises(AttributeError): + handle.path = 'foo' + + def test_restore_unknown(self): + framework = self.create_framework() + + class Foo(Object): + pass + + handle = Handle(None, "a_foo", "some_key") + + framework.register_type(Foo, None, handle.kind) + + try: + framework.load_snapshot(handle) + except NoSnapshotError as e: + self.assertEqual(e.handle_path, str(handle)) + self.assertEqual(str(e), "no snapshot data found for a_foo[some_key] object") + else: + self.fail("exception NoSnapshotError not raised") + + def test_snapshot_roundtrip(self): + class Foo: + def __init__(self, handle, n): + self.handle = handle + self.my_n = n + + def snapshot(self): + return {"My N!": self.my_n} + + def restore(self, snapshot): + self.my_n = snapshot["My N!"] + 1 + + handle = Handle(None, "a_foo", "some_key") + event = Foo(handle, 1) + + framework1 = self.create_framework() + framework1.register_type(Foo, None, handle.kind) + framework1.save_snapshot(event) + framework1.commit() + framework1.close() + + framework2 = self.create_framework() + framework2.register_type(Foo, None, handle.kind) + event2 = framework2.load_snapshot(handle) + self.assertEqual(event2.my_n, 2) + + framework2.save_snapshot(event2) + del event2 + gc.collect() + event3 = framework2.load_snapshot(handle) + self.assertEqual(event3.my_n, 3) + + framework2.drop_snapshot(event.handle) + framework2.commit() + framework2.close() + + framework3 = self.create_framework() + framework3.register_type(Foo, None, handle.kind) + + self.assertRaises(NoSnapshotError, framework3.load_snapshot, handle) + + def test_simple_event_observer(self): + framework = self.create_framework() + + class MyEvent(EventBase): + pass + + class MyNotifier(Object): + foo = EventSource(MyEvent) + bar = EventSource(MyEvent) + baz = EventSource(MyEvent) + + class MyObserver(Object): + def __init__(self, parent, key): + super().__init__(parent, key) + self.seen = [] + + def on_any(self, event): + self.seen.append("on_any:" + event.handle.kind) + + def on_foo(self, event): + self.seen.append("on_foo:" + event.handle.kind) + + pub = MyNotifier(framework, "1") + obs = MyObserver(framework, "1") + + framework.observe(pub.foo, obs.on_any) + framework.observe(pub.bar, obs.on_any) + framework.observe(pub.foo, obs) # Method name defaults to on_. + + try: + framework.observe(pub.baz, obs) + except RuntimeError as e: + self.assertEqual(str(e), 'Observer method not provided explicitly and MyObserver type has no "on_baz" method') + else: + self.fail("RuntimeError not raised") + + pub.foo.emit() + pub.bar.emit() + + self.assertEqual(obs.seen, ["on_any:foo", "on_foo:foo", "on_any:bar"]) + + def test_bad_sig_observer(self): + + class MyEvent(EventBase): + pass + + class MyNotifier(Object): + foo = EventSource(MyEvent) + bar = EventSource(MyEvent) + baz = EventSource(MyEvent) + qux = EventSource(MyEvent) + + class MyObserver(Object): + def on_foo(self): + assert False, 'should not be reached' + + def on_bar(self, event, extra): + assert False, 'should not be reached' + + def on_baz(self, event, extra=None, *, k): + assert False, 'should not be reached' + + def on_qux(self, event, extra=None): + assert False, 'should not be reached' + + framework = self.create_framework() + pub = MyNotifier(framework, "pub") + obs = MyObserver(framework, "obs") + + with self.assertRaises(TypeError): + framework.observe(pub.foo, obs) + with self.assertRaises(TypeError): + framework.observe(pub.bar, obs) + with self.assertRaises(TypeError): + framework.observe(pub.baz, obs) + framework.observe(pub.qux, obs) + + def test_on_pre_commit_emitted(self): + framework = self.create_framework() + + class PreCommitObserver(Object): + + state = StoredState() + + def __init__(self, parent, key): + super().__init__(parent, key) + self.seen = [] + self.state.myinitdata = 40 + + def on_pre_commit(self, event): + self.state.myinitdata = 41 + self.state.mydata = 42 + self.seen.append(type(event)) + + def on_commit(self, event): + # Modifications made here will not be persisted. + self.state.myinitdata = 42 + self.state.mydata = 43 + self.state.myotherdata = 43 + self.seen.append(type(event)) + + obs = PreCommitObserver(framework, None) + + framework.observe(framework.on.pre_commit, obs.on_pre_commit) + + framework.commit() + + self.assertEqual(obs.state.myinitdata, 41) + self.assertEqual(obs.state.mydata, 42) + self.assertTrue(obs.seen, [PreCommitEvent, CommitEvent]) + framework.close() + + other_framework = self.create_framework() + + new_obs = PreCommitObserver(other_framework, None) + + self.assertEqual(obs.state.myinitdata, 41) + self.assertEqual(new_obs.state.mydata, 42) + + with self.assertRaises(AttributeError): + new_obs.state.myotherdata + + def test_defer_and_reemit(self): + framework = self.create_framework() + + class MyEvent(EventBase): + pass + + class MyNotifier1(Object): + a = EventSource(MyEvent) + b = EventSource(MyEvent) + + class MyNotifier2(Object): + c = EventSource(MyEvent) + + class MyObserver(Object): + def __init__(self, parent, key): + super().__init__(parent, key) + self.seen = [] + self.done = {} + + def on_any(self, event): + self.seen.append(event.handle.kind) + if not self.done.get(event.handle.kind): + event.defer() + + pub1 = MyNotifier1(framework, "1") + pub2 = MyNotifier2(framework, "1") + obs1 = MyObserver(framework, "1") + obs2 = MyObserver(framework, "2") + + framework.observe(pub1.a, obs1.on_any) + framework.observe(pub1.b, obs1.on_any) + framework.observe(pub1.a, obs2.on_any) + framework.observe(pub1.b, obs2.on_any) + framework.observe(pub2.c, obs2.on_any) + + pub1.a.emit() + pub1.b.emit() + pub2.c.emit() + + # Events remain stored because they were deferred. + ev_a_handle = Handle(pub1, "a", "1") + framework.load_snapshot(ev_a_handle) + ev_b_handle = Handle(pub1, "b", "2") + framework.load_snapshot(ev_b_handle) + ev_c_handle = Handle(pub2, "c", "3") + framework.load_snapshot(ev_c_handle) + # make sure the objects are gone before we reemit them + gc.collect() + + framework.reemit() + obs1.done["a"] = True + obs2.done["b"] = True + framework.reemit() + framework.reemit() + obs1.done["b"] = True + obs2.done["a"] = True + framework.reemit() + obs2.done["c"] = True + framework.reemit() + framework.reemit() + framework.reemit() + + self.assertEqual(" ".join(obs1.seen), "a b a b a b b b") + self.assertEqual(" ".join(obs2.seen), "a b c a b c a b c a c a c c") + + # Now the event objects must all be gone from storage. + self.assertRaises(NoSnapshotError, framework.load_snapshot, ev_a_handle) + self.assertRaises(NoSnapshotError, framework.load_snapshot, ev_b_handle) + self.assertRaises(NoSnapshotError, framework.load_snapshot, ev_c_handle) + + def test_custom_event_data(self): + framework = self.create_framework() + + class MyEvent(EventBase): + def __init__(self, handle, n): + super().__init__(handle) + self.my_n = n + + def snapshot(self): + return {"My N!": self.my_n} + + def restore(self, snapshot): + super().restore(snapshot) + self.my_n = snapshot["My N!"] + 1 + + class MyNotifier(Object): + foo = EventSource(MyEvent) + + class MyObserver(Object): + def __init__(self, parent, key): + super().__init__(parent, key) + self.seen = [] + + def on_foo(self, event): + self.seen.append(f"on_foo:{event.handle.kind}={event.my_n}") + event.defer() + + pub = MyNotifier(framework, "1") + obs = MyObserver(framework, "1") + + framework.observe(pub.foo, obs) + + pub.foo.emit(1) + + framework.reemit() + + # Two things being checked here: + # + # 1. There's a restore roundtrip before the event is first observed. + # That means the data is safe before it's ever seen, and the + # roundtrip logic is tested under normal circumstances. + # + # 2. The renotification restores from the pristine event, not + # from the one modified during the first restore (otherwise + # we'd get a foo=3). + # + self.assertEqual(obs.seen, ["on_foo:foo=2", "on_foo:foo=2"]) + + def test_weak_observer(self): + framework = self.create_framework() + + observed_events = [] + + class MyEvent(EventBase): + pass + + class MyEvents(EventsBase): + foo = EventSource(MyEvent) + + class MyNotifier(Object): + on = MyEvents() + + class MyObserver(Object): + def on_foo(self, event): + observed_events.append("foo") + + pub = MyNotifier(framework, "1") + obs = MyObserver(framework, "2") + + framework.observe(pub.on.foo, obs) + pub.on.foo.emit() + self.assertEqual(observed_events, ["foo"]) + # Now delete the observer, and note that when we emit the event, it + # doesn't update the local slice again + del obs + gc.collect() + pub.on.foo.emit() + self.assertEqual(observed_events, ["foo"]) + + def test_forget_and_multiple_objects(self): + framework = self.create_framework() + + class MyObject(Object): + pass + + o1 = MyObject(framework, "path") + # Creating a second object at the same path should fail with RuntimeError + with self.assertRaises(RuntimeError): + o2 = MyObject(framework, "path") + # Unless we _forget the object first + framework._forget(o1) + o2 = MyObject(framework, "path") + self.assertEqual(o1.handle.path, o2.handle.path) + # Deleting the tracked object should also work + del o2 + gc.collect() + o3 = MyObject(framework, "path") + self.assertEqual(o1.handle.path, o3.handle.path) + framework.close() + # Or using a second framework + framework_copy = self.create_framework() + o_copy = MyObject(framework_copy, "path") + self.assertEqual(o1.handle.path, o_copy.handle.path) + + def test_forget_and_multiple_objects_with_load_snapshot(self): + framework = self.create_framework() + + class MyObject(Object): + def __init__(self, parent, name): + super().__init__(parent, name) + self.value = name + + def snapshot(self): + return self.value + + def restore(self, value): + self.value = value + + framework.register_type(MyObject, None, MyObject.handle_kind) + o1 = MyObject(framework, "path") + framework.save_snapshot(o1) + framework.commit() + o_handle = o1.handle + del o1 + gc.collect() + o2 = framework.load_snapshot(o_handle) + # Trying to load_snapshot a second object at the same path should fail with RuntimeError + with self.assertRaises(RuntimeError): + framework.load_snapshot(o_handle) + # Unless we _forget the object first + framework._forget(o2) + o3 = framework.load_snapshot(o_handle) + self.assertEqual(o2.value, o3.value) + # A loaded object also prevents direct creation of an object + with self.assertRaises(RuntimeError): + MyObject(framework, "path") + framework.close() + # But we can create an object, or load a snapshot in a copy of the framework + framework_copy1 = self.create_framework() + o_copy1 = MyObject(framework_copy1, "path") + self.assertEqual(o_copy1.value, "path") + framework_copy1.close() + framework_copy2 = self.create_framework() + framework_copy2.register_type(MyObject, None, MyObject.handle_kind) + o_copy2 = framework_copy2.load_snapshot(o_handle) + self.assertEqual(o_copy2.value, "path") + + def test_events_base(self): + framework = self.create_framework() + + class MyEvent(EventBase): + pass + + class MyEvents(EventsBase): + foo = EventSource(MyEvent) + bar = EventSource(MyEvent) + + class MyNotifier(Object): + on = MyEvents() + + class MyObserver(Object): + def __init__(self, parent, key): + super().__init__(parent, key) + self.seen = [] + + def on_foo(self, event): + self.seen.append(f"on_foo:{event.handle.kind}") + event.defer() + + def on_bar(self, event): + self.seen.append(f"on_bar:{event.handle.kind}") + + pub = MyNotifier(framework, "1") + obs = MyObserver(framework, "1") + + # Confirm that temporary persistence of BoundEvents doesn't cause errors, + # and that events can be observed. + for bound_event in [pub.on.foo, pub.on.bar]: + framework.observe(bound_event, obs) + + # Confirm that events can be emitted and seen. + pub.on.foo.emit() + + self.assertEqual(obs.seen, ["on_foo:foo"]) + + def test_conflicting_event_attributes(self): + class MyEvent(EventBase): + pass + + event = EventSource(MyEvent) + + class MyEvents(EventsBase): + foo = event + + with self.assertRaises(RuntimeError) as cm: + class OtherEvents(EventsBase): + foo = event + self.assertEqual( + str(cm.exception.__cause__), + "EventSource(MyEvent) reused as MyEvents.foo and OtherEvents.foo") + + with self.assertRaises(RuntimeError) as cm: + class MyNotifier(Object): + on = MyEvents() + bar = event + self.assertEqual( + str(cm.exception.__cause__), + "EventSource(MyEvent) reused as MyEvents.foo and MyNotifier.bar") + + def test_reemit_ignores_unknown_event_type(self): + # The event type may have been gone for good, and nobody cares, + # so this shouldn't be an error scenario. + + framework = self.create_framework() + + class MyEvent(EventBase): + pass + + class MyNotifier(Object): + foo = EventSource(MyEvent) + + class MyObserver(Object): + def __init__(self, parent, key): + super().__init__(parent, key) + self.seen = [] + + def on_foo(self, event): + self.seen.append(event.handle) + event.defer() + + pub = MyNotifier(framework, "1") + obs = MyObserver(framework, "1") + + framework.observe(pub.foo, obs) + pub.foo.emit() + + event_handle = obs.seen[0] + self.assertEqual(event_handle.kind, "foo") + + framework.commit() + framework.close() + + framework_copy = self.create_framework() + + # No errors on missing event types here. + framework_copy.reemit() + + # Register the type and check that the event is gone from storage. + framework_copy.register_type(MyEvent, event_handle.parent, event_handle.kind) + self.assertRaises(NoSnapshotError, framework_copy.load_snapshot, event_handle) + + def test_auto_register_event_types(self): + framework = self.create_framework() + + class MyFoo(EventBase): + pass + + class MyBar(EventBase): + pass + + class MyEvents(EventsBase): + foo = EventSource(MyFoo) + + class MyNotifier(Object): + on = MyEvents() + bar = EventSource(MyBar) + + class MyObserver(Object): + def __init__(self, parent, key): + super().__init__(parent, key) + self.seen = [] + + def on_foo(self, event): + self.seen.append(f"on_foo:{type(event).__name__}:{event.handle.kind}") + event.defer() + + def on_bar(self, event): + self.seen.append(f"on_bar:{type(event).__name__}:{event.handle.kind}") + event.defer() + + pub = MyNotifier(framework, "1") + obs = MyObserver(framework, "1") + + pub.on.foo.emit() + pub.bar.emit() + + framework.observe(pub.on.foo, obs) + framework.observe(pub.bar, obs) + + pub.on.foo.emit() + pub.bar.emit() + + self.assertEqual(obs.seen, ["on_foo:MyFoo:foo", "on_bar:MyBar:bar"]) + + def test_dynamic_event_types(self): + framework = self.create_framework() + + class MyEventsA(EventsBase): + handle_kind = 'on_a' + + class MyEventsB(EventsBase): + handle_kind = 'on_b' + + class MyNotifier(Object): + on_a = MyEventsA() + on_b = MyEventsB() + + class MyObserver(Object): + def __init__(self, parent, key): + super().__init__(parent, key) + self.seen = [] + + def on_foo(self, event): + self.seen.append(f"on_foo:{type(event).__name__}:{event.handle.kind}") + event.defer() + + def on_bar(self, event): + self.seen.append(f"on_bar:{type(event).__name__}:{event.handle.kind}") + event.defer() + + pub = MyNotifier(framework, "1") + obs = MyObserver(framework, "1") + + class MyFoo(EventBase): + pass + + class MyBar(EventBase): + pass + + class DeadBeefEvent(EventBase): + pass + + class NoneEvent(EventBase): + pass + + pub.on_a.define_event("foo", MyFoo) + pub.on_b.define_event("bar", MyBar) + + framework.observe(pub.on_a.foo, obs) + framework.observe(pub.on_b.bar, obs) + + pub.on_a.foo.emit() + pub.on_b.bar.emit() + + self.assertEqual(obs.seen, ["on_foo:MyFoo:foo", "on_bar:MyBar:bar"]) + + # Definitions remained local to the specific type. + self.assertRaises(AttributeError, lambda: pub.on_a.bar) + self.assertRaises(AttributeError, lambda: pub.on_b.foo) + + # Try to use an event name which is not a valid python identifier. + with self.assertRaises(RuntimeError): + pub.on_a.define_event("dead-beef", DeadBeefEvent) + + # Try to use a python keyword for an event name. + with self.assertRaises(RuntimeError): + pub.on_a.define_event("None", NoneEvent) + + # Try to override an existing attribute. + with self.assertRaises(RuntimeError): + pub.on_a.define_event("foo", MyFoo) + + def test_event_key_roundtrip(self): + class MyEvent(EventBase): + def __init__(self, handle, value): + super().__init__(handle) + self.value = value + + def snapshot(self): + return self.value + + def restore(self, value): + self.value = value + + class MyNotifier(Object): + foo = EventSource(MyEvent) + + class MyObserver(Object): + has_deferred = False + + def __init__(self, parent, key): + super().__init__(parent, key) + self.seen = [] + + def on_foo(self, event): + self.seen.append((event.handle.key, event.value)) + # Only defer the first event and once. + if not MyObserver.has_deferred: + event.defer() + MyObserver.has_deferred = True + + framework1 = self.create_framework() + pub1 = MyNotifier(framework1, "pub") + obs1 = MyObserver(framework1, "obs") + framework1.observe(pub1.foo, obs1) + pub1.foo.emit('first') + self.assertEqual(obs1.seen, [('1', 'first')]) + + framework1.commit() + framework1.close() + del framework1 + + framework2 = self.create_framework() + pub2 = MyNotifier(framework2, "pub") + obs2 = MyObserver(framework2, "obs") + framework2.observe(pub2.foo, obs2) + pub2.foo.emit('second') + framework2.reemit() + + # First observer didn't get updated, since framework it was bound to is gone. + self.assertEqual(obs1.seen, [('1', 'first')]) + # Second observer saw the new event plus the reemit of the first event. + # (The event key goes up by 2 due to the pre-commit and commit events.) + self.assertEqual(obs2.seen, [('4', 'second'), ('1', 'first')]) + + def test_helper_properties(self): + framework = self.create_framework() + framework.model = 'test-model' + framework.meta = 'test-meta' + + my_obj = Object(framework, 'my_obj') + self.assertEqual(my_obj.model, framework.model) + self.assertEqual(my_obj.meta, framework.meta) + self.assertEqual(my_obj.charm_dir, framework.charm_dir) + + def test_ban_concurrent_frameworks(self): + f = self.create_framework() + with self.assertRaises(Exception) as cm: + self.create_framework() + self.assertIn('database is locked', str(cm.exception)) + f.close() + + +class TestStoredState(unittest.TestCase): + + def setUp(self): + self.tmpdir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmpdir) + + def create_framework(self, cls=Framework): + framework = cls(self.tmpdir / "framework.data", self.tmpdir, None, None) + self.addCleanup(framework.close) + return framework + + def test_basic_state_storage(self): + framework = self.create_framework() + + class SomeObject(Object): + state = StoredState() + + obj = SomeObject(framework, "1") + + try: + obj.state.foo + except AttributeError as e: + self.assertEqual(str(e), "attribute 'foo' is not stored") + else: + self.fail("AttributeError not raised") + + try: + obj.state.on = "nonono" + except AttributeError as e: + self.assertEqual(str(e), "attribute 'on' is reserved and cannot be set") + else: + self.fail("AttributeError not raised") + + obj.state.foo = 41 + obj.state.foo = 42 + obj.state.bar = "s" + + self.assertEqual(obj.state.foo, 42) + + framework.commit() + + # This won't be committed, and should not be seen. + obj.state.foo = 43 + + framework.close() + + # Since this has the same absolute object handle, it will get its state back. + framework_copy = self.create_framework() + obj_copy = SomeObject(framework_copy, "1") + self.assertEqual(obj_copy.state.foo, 42) + self.assertEqual(obj_copy.state.bar, "s") + + def test_mutable_types_invalid(self): + framework = self.create_framework() + + class SomeObject(Object): + state = StoredState() + + obj = SomeObject(framework, '1') + try: + class CustomObject: + pass + obj.state.foo = CustomObject() + except AttributeError as e: + self.assertEqual(str(e), "attribute 'foo' cannot be set to CustomObject: must be int/dict/list/etc") + else: + self.fail('AttributeError not raised') + + framework.commit() + + def test_mutable_types(self): + # Test and validation functions in a list of 2-tuples. + # Assignment and keywords like del are not supported in lambdas so functions are used instead. + test_operations = [( + lambda: {}, # Operand A. + None, # Operand B. + {}, # Expected result. + lambda a, b: None, # Operation to perform. + lambda res, expected_res: self.assertEqual(res, expected_res) # Validation to perform. + ), ( + lambda: {}, + {'a': {}}, + {'a': {}}, + lambda a, b: a.update(b), + lambda res, expected_res: self.assertEqual(res, expected_res) + ), ( + lambda: {'a': {}}, + {'b': 'c'}, + {'a': {'b': 'c'}}, + lambda a, b: a['a'].update(b), + lambda res, expected_res: self.assertEqual(res, expected_res) + ), ( + lambda: {'a': {'b': 'c'}}, + {'d': 'e'}, + {'a': {'b': 'c', 'd': 'e'}}, + lambda a, b: a['a'].update(b), + lambda res, expected_res: self.assertEqual(res, expected_res) + ), ( + lambda: {'a': {'b': 'c', 'd': 'e'}}, + 'd', + {'a': {'b': 'c'}}, + lambda a, b: a['a'].pop(b), + lambda res, expected_res: self.assertEqual(res, expected_res) + ), ( + lambda: {'s': set()}, + 'a', + {'s': {'a'}}, + lambda a, b: a['s'].add(b), + lambda res, expected_res: self.assertEqual(res, expected_res) + ), ( + lambda: {'s': {'a'}}, + 'a', + {'s': set()}, + lambda a, b: a['s'].discard(b), + lambda res, expected_res: self.assertEqual(res, expected_res) + ), ( + lambda: [], + None, + [], + lambda a, b: None, + lambda res, expected_res: self.assertEqual(res, expected_res) + ), ( + lambda: [], + 'a', + ['a'], + lambda a, b: a.append(b), + lambda res, expected_res: self.assertEqual(res, expected_res) + ), ( + lambda: ['a'], + ['c'], + ['a', ['c']], + lambda a, b: a.append(b), + lambda res, expected_res: ( + self.assertEqual(res, expected_res), + self.assertIsInstance(res[1], StoredList), + ) + ), ( + lambda: ['a', ['c']], + 'b', + ['b', 'a', ['c']], + lambda a, b: a.insert(0, b), + lambda res, expected_res: self.assertEqual(res, expected_res) + ), ( + lambda: ['b', 'a', ['c']], + ['d'], + ['b', ['d'], 'a', ['c']], + lambda a, b: a.insert(1, b), + lambda res, expected_res: ( + self.assertEqual(res, expected_res), + self.assertIsInstance(res[1], StoredList) + ), + ), ( + lambda: ['b', 'a', ['c']], + ['d'], + ['b', ['d'], ['c']], + # a[1] = b + lambda a, b: a.__setitem__(1, b), + lambda res, expected_res: ( + self.assertEqual(res, expected_res), + self.assertIsInstance(res[1], StoredList) + ), + ), ( + lambda: ['b', ['d'], 'a', ['c']], + 0, + [['d'], 'a', ['c']], + lambda a, b: a.pop(b), + lambda res, expected_res: self.assertEqual(res, expected_res) + ), ( + lambda: [['d'], 'a', ['c']], + ['d'], + ['a', ['c']], + lambda a, b: a.remove(b), + lambda res, expected_res: self.assertEqual(res, expected_res) + ), ( + lambda: ['a', ['c']], + 'd', + ['a', ['c', 'd']], + lambda a, b: a[1].append(b), + lambda res, expected_res: self.assertEqual(res, expected_res) + ), ( + lambda: ['a', ['c', 'd']], + 1, + ['a', ['c']], + lambda a, b: a[1].pop(b), + lambda res, expected_res: self.assertEqual(res, expected_res) + ), ( + lambda: ['a', ['c']], + 'd', + ['a', ['c', 'd']], + lambda a, b: a[1].insert(1, b), + lambda res, expected_res: self.assertEqual(res, expected_res) + ), ( + lambda: ['a', ['c', 'd']], + 'd', + ['a', ['c']], + lambda a, b: a[1].remove(b), + lambda res, expected_res: self.assertEqual(res, expected_res) + ), ( + lambda: set(), + None, + set(), + lambda a, b: None, + lambda res, expected_res: self.assertEqual(res, expected_res) + ), ( + lambda: set(), + 'a', + set(['a']), + lambda a, b: a.add(b), + lambda res, expected_res: self.assertEqual(res, expected_res) + ), ( + lambda: set(['a']), + 'a', + set(), + lambda a, b: a.discard(b), + lambda res, expected_res: self.assertEqual(res, expected_res) + ), ( + lambda: set(), + {'a'}, + set(), + # Nested sets are not allowed as sets themselves are not hashable. + lambda a, b: self.assertRaises(TypeError, a.add, b), + lambda res, expected_res: self.assertEqual(res, expected_res) + )] + + class SomeObject(Object): + state = StoredState() + + class WrappedFramework(Framework): + def __init__(self, data_path, charm_dir, meta, model): + super().__init__(data_path, charm_dir, meta, model) + self.snapshots = [] + + def save_snapshot(self, value): + if value.handle.path == 'SomeObject[1]/StoredStateData[state]': + self.snapshots.append((type(value), value.snapshot())) + return super().save_snapshot(value) + + # Validate correctness of modification operations. + for get_a, b, expected_res, op, validate_op in test_operations: + framework = self.create_framework(cls=WrappedFramework) + obj = SomeObject(framework, '1') + + obj.state.a = get_a() + self.assertTrue(isinstance(obj.state, BoundStoredState)) + + op(obj.state.a, b) + validate_op(obj.state.a, expected_res) + + obj.state.a = get_a() + framework.commit() + # We should see an update for initializing a + self.assertEqual(framework.snapshots, [ + (StoredStateData, {'a': get_a()}), + ]) + del obj + gc.collect() + obj_copy1 = SomeObject(framework, '1') + self.assertEqual(obj_copy1.state.a, get_a()) + + op(obj_copy1.state.a, b) + validate_op(obj_copy1.state.a, expected_res) + framework.commit() + framework.close() + + framework_copy = self.create_framework(cls=WrappedFramework) + + obj_copy2 = SomeObject(framework_copy, '1') + + validate_op(obj_copy2.state.a, expected_res) + + # Commit saves the pre-commit and commit events, and the framework event counter, but shouldn't update the stored state of my object + framework.snapshots.clear() + framework_copy.commit() + self.assertEqual(framework_copy.snapshots, []) + framework_copy.close() + + def test_comparison_operations(self): + test_operations = [( + {"1"}, # Operand A. + {"1", "2"}, # Operand B. + lambda a, b: a < b, # Operation to test. + True, # Result of op(A, B). + False, # Result of op(B, A). + ), ( + {"1"}, + {"1", "2"}, + lambda a, b: a > b, + False, + True + ), ( + # Empty set comparison. + set(), + set(), + lambda a, b: a == b, + True, + True + ), ( + {"a", "c"}, + {"c", "a"}, + lambda a, b: a == b, + True, + True + ), ( + dict(), + dict(), + lambda a, b: a == b, + True, + True + ), ( + {"1": "2"}, + {"1": "2"}, + lambda a, b: a == b, + True, + True + ), ( + {"1": "2"}, + {"1": "3"}, + lambda a, b: a == b, + False, + False + ), ( + [], + [], + lambda a, b: a == b, + True, + True + ), ( + [1, 2], + [1, 2], + lambda a, b: a == b, + True, + True + ), ( + [1, 2, 5, 6], + [1, 2, 5, 8, 10], + lambda a, b: a <= b, + True, + False + ), ( + [1, 2, 5, 6], + [1, 2, 5, 8, 10], + lambda a, b: a < b, + True, + False + ), ( + [1, 2, 5, 8], + [1, 2, 5, 6, 10], + lambda a, b: a > b, + True, + False + ), ( + [1, 2, 5, 8], + [1, 2, 5, 6, 10], + lambda a, b: a >= b, + True, + False + )] + + class SomeObject(Object): + state = StoredState() + + framework = self.create_framework() + + for i, (a, b, op, op_ab, op_ba) in enumerate(test_operations): + obj = SomeObject(framework, str(i)) + obj.state.a = a + self.assertEqual(op(obj.state.a, b), op_ab) + self.assertEqual(op(b, obj.state.a), op_ba) + + def test_set_operations(self): + test_operations = [( + {"1"}, # A set to test an operation against (other_set). + lambda a, b: a | b, # An operation to test. + {"1", "a", "b"}, # The expected result of operation(obj.state.set, other_set). + {"1", "a", "b"} # The expected result of operation(other_set, obj.state.set). + ), ( + {"a", "c"}, + lambda a, b: a - b, + {"b"}, + {"c"} + ), ( + {"a", "c"}, + lambda a, b: a & b, + {"a"}, + {"a"} + ), ( + {"a", "c", "d"}, + lambda a, b: a ^ b, + {"b", "c", "d"}, + {"b", "c", "d"} + ), ( + set(), + lambda a, b: set(a), + {"a", "b"}, + set() + )] + + class SomeObject(Object): + state = StoredState() + + framework = self.create_framework() + + # Validate that operations between StoredSet and built-in sets only result in built-in sets being returned. + # Make sure that commutativity is preserved and that the original sets are not changed or used as a result. + for i, (variable_operand, operation, ab_res, ba_res) in enumerate(test_operations): + obj = SomeObject(framework, str(i)) + obj.state.set = {"a", "b"} + + for a, b, expected in [(obj.state.set, variable_operand, ab_res), (variable_operand, obj.state.set, ba_res)]: + old_a = set(a) + old_b = set(b) + + result = operation(a, b) + self.assertEqual(result, expected) + + # Common sanity checks + self.assertIsNot(obj.state.set._under, result) + self.assertIsNot(result, a) + self.assertIsNot(result, b) + self.assertEqual(a, old_a) + self.assertEqual(b, old_b) + + def test_set_default(self): + framework = self.create_framework() + + class StatefulObject(Object): + state = StoredState() + parent = StatefulObject(framework, 'key') + parent.state.set_default(foo=1) + self.assertEqual(parent.state.foo, 1) + parent.state.set_default(foo=2) + # foo was already set, so it doesn't get replaced + self.assertEqual(parent.state.foo, 1) + parent.state.set_default(foo=3, bar=4) + self.assertEqual(parent.state.foo, 1) + self.assertEqual(parent.state.bar, 4) + # reloading the state still leaves things at the default values + framework.commit() + del parent + parent = StatefulObject(framework, 'key') + parent.state.set_default(foo=5, bar=6) + self.assertEqual(parent.state.foo, 1) + self.assertEqual(parent.state.bar, 4) + # TODO(jam) 2020-01-30: is there a clean way to tell that parent.state._data.dirty is False? + + +if __name__ == "__main__": + unittest.main() diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_helpers.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_helpers.py new file mode 100644 index 00000000..7d7379fe --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_helpers.py @@ -0,0 +1,76 @@ +# Copyright 2019 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import pathlib +import subprocess +import shutil +import tempfile +import unittest + + +def fake_script(test_case, name, content): + if not hasattr(test_case, 'fake_script_path'): + fake_script_path = tempfile.mkdtemp('-fake_script') + os.environ['PATH'] = f'{fake_script_path}:{os.environ["PATH"]}' + + def cleanup(): + shutil.rmtree(fake_script_path) + os.environ['PATH'] = os.environ['PATH'].replace(fake_script_path + ':', '') + + test_case.addCleanup(cleanup) + test_case.fake_script_path = pathlib.Path(fake_script_path) + + with open(test_case.fake_script_path / name, "w") as f: + # Before executing the provided script, dump the provided arguments in calls.txt. + f.write('#!/bin/bash\n{ echo -n $(basename $0); for s in "$@"; do echo -n \\;$s; done; echo; } >> $(dirname $0)/calls.txt\n' + content) + os.chmod(test_case.fake_script_path / name, 0o755) + + +def fake_script_calls(test_case, clear=False): + with open(test_case.fake_script_path / 'calls.txt', 'r+') as f: + calls = [line.split(';') for line in f.read().splitlines()] + if clear: + f.truncate(0) + return calls + + +class FakeScriptTest(unittest.TestCase): + + def test_fake_script_works(self): + fake_script(self, 'foo', 'echo foo runs') + fake_script(self, 'bar', 'echo bar runs') + output = subprocess.getoutput('foo a "b c"; bar "d e" f') + self.assertEqual(output, 'foo runs\nbar runs') + self.assertEqual(fake_script_calls(self), [ + ['foo', 'a', 'b c'], + ['bar', 'd e', 'f'], + ]) + + def test_fake_script_clear(self): + fake_script(self, 'foo', 'echo foo runs') + + output = subprocess.getoutput('foo a "b c"') + self.assertEqual(output, 'foo runs') + + self.assertEqual(fake_script_calls(self, clear=True), [['foo', 'a', 'b c']]) + + fake_script(self, 'bar', 'echo bar runs') + + output = subprocess.getoutput('bar "d e" f') + self.assertEqual(output, 'bar runs') + + self.assertEqual(fake_script_calls(self, clear=True), [['bar', 'd e', 'f']]) + + self.assertEqual(fake_script_calls(self, clear=True), []) diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_jujuversion.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_jujuversion.py new file mode 100755 index 00000000..d19fd600 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_jujuversion.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +# Copyright 2019 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +from ops.jujuversion import JujuVersion + + +class TestJujuVersion(unittest.TestCase): + + def test_parsing(self): + test_cases = [ + ("0.0.0", 0, 0, '', 0, 0), + ("0.0.2", 0, 0, '', 2, 0), + ("0.1.0", 0, 1, '', 0, 0), + ("0.2.3", 0, 2, '', 3, 0), + ("10.234.3456", 10, 234, '', 3456, 0), + ("10.234.3456.1", 10, 234, '', 3456, 1), + ("1.21-alpha12", 1, 21, 'alpha', 12, 0), + ("1.21-alpha1.34", 1, 21, 'alpha', 1, 34), + ("2.7", 2, 7, '', 0, 0) + ] + + for vs, major, minor, tag, patch, build in test_cases: + v = JujuVersion(vs) + self.assertEqual(v.major, major) + self.assertEqual(v.minor, minor) + self.assertEqual(v.tag, tag) + self.assertEqual(v.patch, patch) + self.assertEqual(v.build, build) + + def test_parsing_errors(self): + invalid_versions = [ + "xyz", + "foo.bar", + "foo.bar.baz", + "dead.beef.ca.fe", + "1234567890.2.1", # The major version is too long. + "0.2..1", # Two periods next to each other. + "1.21.alpha1", # Tag comes after period. + "1.21-alpha", # No patch number but a tag is present. + "1.21-alpha1beta", # Non-numeric string after the patch number. + "1.21-alpha-dev", # Tag duplication. + "1.21-alpha_dev3", # Underscore in a tag. + "1.21-alpha123dev3", # Non-numeric string after the patch number. + ] + for v in invalid_versions: + with self.assertRaises(RuntimeError): + JujuVersion(v) + + def test_equality(self): + test_cases = [ + ("1.0.0", "1.0.0", True), + ("01.0.0", "1.0.0", True), + ("10.0.0", "9.0.0", False), + ("1.0.0", "1.0.1", False), + ("1.0.1", "1.0.0", False), + ("1.0.0", "1.1.0", False), + ("1.1.0", "1.0.0", False), + ("1.0.0", "2.0.0", False), + ("1.2-alpha1", "1.2.0", False), + ("1.2-alpha2", "1.2-alpha1", False), + ("1.2-alpha2.1", "1.2-alpha2", False), + ("1.2-alpha2.2", "1.2-alpha2.1", False), + ("1.2-beta1", "1.2-alpha1", False), + ("1.2-beta1", "1.2-alpha2.1", False), + ("1.2-beta1", "1.2.0", False), + ("1.2.1", "1.2.0", False), + ("2.0.0", "1.0.0", False), + ("2.0.0.0", "2.0.0", True), + ("2.0.0.0", "2.0.0.0", True), + ("2.0.0.1", "2.0.0.0", False), + ("2.0.1.10", "2.0.0.0", False), + ] + + for a, b, expected in test_cases: + self.assertEqual(JujuVersion(a) == JujuVersion(b), expected) + self.assertEqual(JujuVersion(a) == b, expected) + + def test_comparison(self): + test_cases = [ + ("1.0.0", "1.0.0", False, True), + ("01.0.0", "1.0.0", False, True), + ("10.0.0", "9.0.0", False, False), + ("1.0.0", "1.0.1", True, True), + ("1.0.1", "1.0.0", False, False), + ("1.0.0", "1.1.0", True, True), + ("1.1.0", "1.0.0", False, False), + ("1.0.0", "2.0.0", True, True), + ("1.2-alpha1", "1.2.0", True, True), + ("1.2-alpha2", "1.2-alpha1", False, False), + ("1.2-alpha2.1", "1.2-alpha2", False, False), + ("1.2-alpha2.2", "1.2-alpha2.1", False, False), + ("1.2-beta1", "1.2-alpha1", False, False), + ("1.2-beta1", "1.2-alpha2.1", False, False), + ("1.2-beta1", "1.2.0", True, True), + ("1.2.1", "1.2.0", False, False), + ("2.0.0", "1.0.0", False, False), + ("2.0.0.0", "2.0.0", False, True), + ("2.0.0.0", "2.0.0.0", False, True), + ("2.0.0.1", "2.0.0.0", False, False), + ("2.0.1.10", "2.0.0.0", False, False), + ] + + for a, b, expected_strict, expected_weak in test_cases: + self.assertEqual(JujuVersion(a) < JujuVersion(b), expected_strict) + self.assertEqual(JujuVersion(a) <= JujuVersion(b), expected_weak) + self.assertEqual(JujuVersion(b) > JujuVersion(a), expected_strict) + self.assertEqual(JujuVersion(b) >= JujuVersion(a), expected_weak) + # Implicit conversion. + self.assertEqual(JujuVersion(a) < b, expected_strict) + self.assertEqual(JujuVersion(a) <= b, expected_weak) + self.assertEqual(b > JujuVersion(a), expected_strict) + self.assertEqual(b >= JujuVersion(a), expected_weak) + + +if __name__ == "__main__": + unittest.main() diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_main.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_main.py new file mode 100755 index 00000000..a4ced948 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_main.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +# Copyright 2019 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +import logging +import os +import sys +import subprocess +import pickle +import base64 +import tempfile +import shutil + +import importlib.util + +from pathlib import Path + +from ops.charm import ( + CharmBase, + CharmEvents, + HookEvent, + InstallEvent, + StartEvent, + ConfigChangedEvent, + UpgradeCharmEvent, + UpdateStatusEvent, + LeaderSettingsChangedEvent, + RelationJoinedEvent, + RelationChangedEvent, + RelationDepartedEvent, + RelationBrokenEvent, + RelationEvent, + StorageAttachedEvent, + ActionEvent, +) + +from .test_helpers import fake_script + +# This relies on the expected repository structure to find a path to source of the charm under test. +TEST_CHARM_DIR = Path(f'{__file__}/../charms/test_main').resolve() + +logger = logging.getLogger(__name__) + + +class SymlinkTargetError(Exception): + pass + + +class EventSpec: + def __init__(self, event_type, event_name, env_var=None, relation_id=None, remote_app=None, remote_unit=None, + charm_config=None): + self.event_type = event_type + self.event_name = event_name + self.env_var = env_var + self.relation_id = relation_id + self.remote_app = remote_app + self.remote_unit = remote_unit + self.charm_config = charm_config + + +class TestMain(unittest.TestCase): + + def setUp(self): + self._setup_charm_dir() + + _, tmp_file = tempfile.mkstemp() + self._state_file = Path(tmp_file) + self.addCleanup(self._state_file.unlink) + + # Relations events are defined dynamically and modify the class attributes. + # We use a subclass temporarily to prevent these side effects from leaking. + class TestCharmEvents(CharmEvents): + pass + CharmBase.on = TestCharmEvents() + + def cleanup(): + shutil.rmtree(self.JUJU_CHARM_DIR) + CharmBase.on = CharmEvents() + self.addCleanup(cleanup) + + def _setup_charm_dir(self): + self.JUJU_CHARM_DIR = Path(tempfile.mkdtemp()) / 'test_main' + self.hooks_dir = self.JUJU_CHARM_DIR / 'hooks' + self.charm_exec_path = os.path.relpath(self.JUJU_CHARM_DIR / 'src/charm.py', self.hooks_dir) + shutil.copytree(TEST_CHARM_DIR, self.JUJU_CHARM_DIR) + + charm_spec = importlib.util.spec_from_file_location("charm", str(self.JUJU_CHARM_DIR / 'src/charm.py')) + self.charm_module = importlib.util.module_from_spec(charm_spec) + charm_spec.loader.exec_module(self.charm_module) + + self._prepare_initial_hooks() + + def _prepare_initial_hooks(self): + initial_hooks = ('install', 'start', 'upgrade-charm', 'disks-storage-attached') + self.hooks_dir.mkdir() + for hook in initial_hooks: + hook_path = self.hooks_dir / hook + hook_path.symlink_to(self.charm_exec_path) + + def _prepare_actions(self): + actions_meta = ''' +foo-bar: + description: Foos the bar. + title: foo-bar + params: + foo-name: + type: string + description: A foo name to bar. + silent: + type: boolean + description: + default: false + required: + - foo-name +start: + description: Start the unit.''' + actions_dir_name = 'actions' + actions_meta_file = 'actions.yaml' + + with open(self.JUJU_CHARM_DIR / actions_meta_file, 'w+') as f: + f.write(actions_meta) + actions_dir = self.JUJU_CHARM_DIR / actions_dir_name + actions_dir.mkdir() + for action_name in ('start', 'foo-bar'): + action_path = actions_dir / action_name + action_path.symlink_to(self.charm_exec_path) + + def _read_and_clear_state(self): + state = None + if self._state_file.stat().st_size: + with open(self._state_file, 'r+b') as state_file: + state = pickle.load(state_file) + state_file.truncate() + return state + + def _simulate_event(self, event_spec): + env = { + 'PATH': f"{str(Path(__file__).parent / 'bin')}:{os.environ['PATH']}", + 'JUJU_CHARM_DIR': self.JUJU_CHARM_DIR, + 'JUJU_UNIT_NAME': 'test_main/0', + 'CHARM_CONFIG': event_spec.charm_config, + } + if issubclass(event_spec.event_type, RelationEvent): + rel_name = event_spec.event_name.split('_')[0] + env.update({ + 'JUJU_RELATION': rel_name, + 'JUJU_RELATION_ID': str(event_spec.relation_id), + }) + remote_app = event_spec.remote_app + # For juju < 2.7 app name is extracted from JUJU_REMOTE_UNIT. + if remote_app is not None: + env['JUJU_REMOTE_APP'] = remote_app + + remote_unit = event_spec.remote_unit + if remote_unit is None: + remote_unit = '' + + env['JUJU_REMOTE_UNIT'] = remote_unit + else: + env.update({ + 'JUJU_REMOTE_UNIT': '', + 'JUJU_REMOTE_APP': '', + }) + if issubclass(event_spec.event_type, ActionEvent): + event_filename = event_spec.event_name[:-len('_action')].replace('_', '-') + env.update({ + event_spec.env_var: event_filename, + }) + if event_spec.env_var == 'JUJU_ACTION_NAME': + event_dir = 'actions' + else: + raise RuntimeError('invalid envar name specified for a action event') + else: + event_filename = event_spec.event_name.replace('_', '-') + event_dir = 'hooks' + event_file = self.JUJU_CHARM_DIR / event_dir / event_filename + # Note that sys.executable is used to make sure we are using the same + # interpreter for the child process to support virtual environments. + subprocess.check_call([sys.executable, event_file], env=env, cwd=self.JUJU_CHARM_DIR) + return self._read_and_clear_state() + + def test_event_reemitted(self): + # base64 encoding is used to avoid null bytes. + charm_config = base64.b64encode(pickle.dumps({ + 'STATE_FILE': self._state_file, + })) + + # First run "install" to make sure all hooks are set up. + state = self._simulate_event(EventSpec(InstallEvent, 'install', charm_config=charm_config)) + self.assertEqual(state['observed_event_types'], [InstallEvent]) + + state = self._simulate_event(EventSpec(ConfigChangedEvent, 'config-changed', charm_config=charm_config)) + self.assertEqual(state['observed_event_types'], [ConfigChangedEvent]) + + # Re-emit should pick the deferred config-changed. + state = self._simulate_event(EventSpec(UpdateStatusEvent, 'update-status', charm_config=charm_config)) + self.assertEqual(state['observed_event_types'], [ConfigChangedEvent, UpdateStatusEvent]) + + def test_multiple_events_handled(self): + self._prepare_actions() + + charm_config = base64.b64encode(pickle.dumps({ + 'STATE_FILE': self._state_file, + })) + actions_charm_config = base64.b64encode(pickle.dumps({ + 'STATE_FILE': self._state_file, + 'USE_ACTIONS': True, + })) + + fake_script(self, 'action-get', "echo '{}'") + + # Sample events with a different amount of dashes used + # and with endpoints from different sections of metadata.yaml + events_under_test = [( + EventSpec(InstallEvent, 'install', charm_config=charm_config), + {}, + ), ( + EventSpec(StartEvent, 'start', charm_config=charm_config), + {}, + ), ( + EventSpec(UpdateStatusEvent, 'update_status', charm_config=charm_config), + {}, + ), ( + EventSpec(LeaderSettingsChangedEvent, 'leader_settings_changed', charm_config=charm_config), + {}, + ), ( + EventSpec(RelationJoinedEvent, 'db_relation_joined', relation_id=1, + remote_app='remote', remote_unit='remote/0', charm_config=charm_config), + {'relation_name': 'db', 'relation_id': 1, 'app_name': 'remote', 'unit_name': 'remote/0'}, + ), ( + EventSpec(RelationChangedEvent, 'mon_relation_changed', relation_id=2, + remote_app='remote', remote_unit='remote/0', charm_config=charm_config), + {'relation_name': 'mon', 'relation_id': 2, 'app_name': 'remote', 'unit_name': 'remote/0'}, + ), ( + EventSpec(RelationChangedEvent, 'mon_relation_changed', relation_id=2, + remote_app='remote', remote_unit=None, charm_config=charm_config), + {'relation_name': 'mon', 'relation_id': 2, 'app_name': 'remote', 'unit_name': None}, + ), ( + EventSpec(RelationDepartedEvent, 'mon_relation_departed', relation_id=2, + remote_app='remote', remote_unit='remote/0', charm_config=charm_config), + {'relation_name': 'mon', 'relation_id': 2, 'app_name': 'remote', 'unit_name': 'remote/0'}, + ), ( + EventSpec(RelationBrokenEvent, 'ha_relation_broken', relation_id=3, + charm_config=charm_config), + {'relation_name': 'ha', 'relation_id': 3}, + ), ( + # Events without a remote app specified (for Juju < 2.7). + EventSpec(RelationJoinedEvent, 'db_relation_joined', relation_id=1, + remote_unit='remote/0', charm_config=charm_config), + {'relation_name': 'db', 'relation_id': 1, 'app_name': 'remote', 'unit_name': 'remote/0'}, + ), ( + EventSpec(RelationChangedEvent, 'mon_relation_changed', relation_id=2, + remote_unit='remote/0', charm_config=charm_config), + {'relation_name': 'mon', 'relation_id': 2, 'app_name': 'remote', 'unit_name': 'remote/0'}, + ), ( + EventSpec(RelationDepartedEvent, 'mon_relation_departed', relation_id=2, + remote_unit='remote/0', charm_config=charm_config), + {'relation_name': 'mon', 'relation_id': 2, 'app_name': 'remote', 'unit_name': 'remote/0'}, + ), ( + EventSpec(ActionEvent, 'start_action', env_var='JUJU_ACTION_NAME', charm_config=actions_charm_config), + {}, + ), ( + EventSpec(ActionEvent, 'foo_bar_action', env_var='JUJU_ACTION_NAME', charm_config=actions_charm_config), + {}, + )] + + logger.debug(f'Expected events {events_under_test}') + + # First run "install" to make sure all hooks are set up. + self._simulate_event(EventSpec(InstallEvent, 'install', charm_config=charm_config)) + + # Simulate hook executions for every event. + for event_spec, expected_event_data in events_under_test: + state = self._simulate_event(event_spec) + + state_key = f'on_{event_spec.event_name}' + handled_events = state.get(state_key, []) + + # Make sure that a handler for that event was called once. + self.assertEqual(len(handled_events), 1) + # Make sure the event handled by the Charm has the right type. + handled_event_type = handled_events[0] + self.assertEqual(handled_event_type, event_spec.event_type) + + self.assertEqual(state['observed_event_types'], [event_spec.event_type]) + + if event_spec.event_name in expected_event_data: + self.assertEqual(state[f'{event_spec.event_name}_data'], expected_event_data[event_spec.event_name]) + + def test_event_not_implemented(self): + """Make sure events without implementation do not cause non-zero exit. + """ + charm_config = base64.b64encode(pickle.dumps({ + 'STATE_FILE': self._state_file, + })) + + # Simulate a scenario where there is a symlink for an event that + # a charm does not know how to handle. + hook_path = self.JUJU_CHARM_DIR / 'hooks/not-implemented-event' + # This will be cleared up in tearDown. + hook_path.symlink_to('install') + + try: + self._simulate_event(EventSpec(HookEvent, 'not-implemented-event', charm_config=charm_config)) + except subprocess.CalledProcessError: + self.fail('Event simulation for an unsupported event' + ' results in a non-zero exit code returned') + + def test_setup_event_links(self): + """Test auto-creation of symlinks caused by initial events. + """ + all_event_hooks = [f'hooks/{e.replace("_", "-")}' for e in self.charm_module.Charm.on.events().keys()] + charm_config = base64.b64encode(pickle.dumps({ + 'STATE_FILE': self._state_file, + })) + initial_events = { + EventSpec(InstallEvent, 'install', charm_config=charm_config), + EventSpec(StorageAttachedEvent, 'disks-storage-attached', charm_config=charm_config), + EventSpec(StartEvent, 'start', charm_config=charm_config), + EventSpec(UpgradeCharmEvent, 'upgrade-charm', charm_config=charm_config), + } + + def _assess_event_links(event_spec): + self.assertTrue(self.hooks_dir / event_spec.event_name in self.hooks_dir.iterdir()) + for event_hook in all_event_hooks: + self.assertTrue((self.JUJU_CHARM_DIR / event_hook).exists(), f'Missing hook: {event_hook}') + self.assertEqual(os.readlink(self.JUJU_CHARM_DIR / event_hook), self.charm_exec_path) + + for initial_event in initial_events: + self._setup_charm_dir() + + self._simulate_event(initial_event) + _assess_event_links(initial_event) + # Make sure it is idempotent. + self._simulate_event(initial_event) + _assess_event_links(initial_event) + + def test_setup_action_links(self): + charm_config = base64.b64encode(pickle.dumps({ + 'STATE_FILE': self._state_file, + })) + actions_yaml = self.JUJU_CHARM_DIR / 'actions.yaml' + actions_yaml.write_text('test: {}') + self._simulate_event(EventSpec(InstallEvent, 'install', charm_config=charm_config)) + action_hook = self.JUJU_CHARM_DIR / 'actions' / 'test' + self.assertTrue(action_hook.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_model.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_model.py new file mode 100755 index 00000000..544f4ff5 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/mod/operator/test/test_model.py @@ -0,0 +1,868 @@ +#!/usr/bin/python3 +# Copyright 2019 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import pathlib +import unittest +import time +import re +import json + +import ops.model +import ops.charm +from ops.charm import RelationMeta + +from test.test_helpers import fake_script, fake_script_calls + + +class TestModel(unittest.TestCase): + + def setUp(self): + def restore_env(env): + os.environ.clear() + os.environ.update(env) + self.addCleanup(restore_env, os.environ.copy()) + + os.environ['JUJU_UNIT_NAME'] = 'myapp/0' + + self.backend = ops.model.ModelBackend() + meta = ops.charm.CharmMeta() + meta.relations = { + 'db0': RelationMeta('provides', 'db0', {'interface': 'db0', 'scope': 'global'}), + 'db1': RelationMeta('requires', 'db1', {'interface': 'db1', 'scope': 'global'}), + 'db2': RelationMeta('peers', 'db2', {'interface': 'db2', 'scope': 'global'}), + } + self.model = ops.model.Model('myapp/0', meta, self.backend) + + def test_model(self): + self.assertIs(self.model.app, self.model.unit.app) + + def test_relations_keys(self): + fake_script(self, 'relation-ids', + """[ "$1" = db2 ] && echo '["db2:5", "db2:6"]' || echo '[]'""") + fake_script(self, 'relation-list', + """([ "$2" = 5 ] && echo '["remoteapp1/0", "remoteapp1/1"]') || ([ "$2" = 6 ] && echo '["remoteapp2/0"]') || exit 2""") + + for relation in self.model.relations['db2']: + self.assertIn(self.model.unit, relation.data) + unit_from_rel = next(filter(lambda u: u.name == 'myapp/0', relation.data.keys())) + self.assertIs(self.model.unit, unit_from_rel) + + self.assertEqual(fake_script_calls(self), [ + ['relation-ids', 'db2', '--format=json'], + ['relation-list', '-r', '5', '--format=json'], + ['relation-list', '-r', '6', '--format=json'] + ]) + + def test_get_relation(self): + err_msg = 'ERROR invalid value "$2" for option -r: relation not found' + + fake_script(self, 'relation-ids', + """([ "$1" = db1 ] && echo '["db1:4"]') || ([ "$1" = db2 ] && echo '["db2:5", "db2:6"]') || echo '[]'""") + fake_script(self, 'relation-list', + f"""([ "$2" = 4 ] && echo '["remoteapp1/0"]') || (echo {err_msg} >&2 ; exit 2)""") + fake_script(self, 'relation-get', + f"""echo {err_msg} >&2 ; exit 2""") + + with self.assertRaises(ops.model.ModelError): + self.model.get_relation('db1', 'db1:4') + db1_4 = self.model.get_relation('db1', 4) + self.assertIsInstance(db1_4, ops.model.Relation) + dead_rel = self.model.get_relation('db1', 7) + self.assertIsInstance(dead_rel, ops.model.Relation) + self.assertEqual(list(dead_rel.data.keys()), [self.model.unit, self.model.unit.app]) + self.assertEqual(dead_rel.data[self.model.unit], {}) + self.assertIsNone(self.model.get_relation('db0')) + self.assertIs(self.model.get_relation('db1'), db1_4) + with self.assertRaises(ops.model.TooManyRelatedAppsError): + self.model.get_relation('db2') + + self.assertEqual(fake_script_calls(self), [ + ['relation-ids', 'db1', '--format=json'], + ['relation-list', '-r', '4', '--format=json'], + ['relation-list', '-r', '7', '--format=json'], + ['relation-get', '-r', '7', '-', 'myapp/0', '--app=False', '--format=json'], + ['relation-ids', 'db0', '--format=json'], + ['relation-ids', 'db2', '--format=json'], + ['relation-list', '-r', '5', '--format=json'], + ['relation-list', '-r', '6', '--format=json'] + ]) + + def test_peer_relation_app(self): + meta = ops.charm.CharmMeta() + meta.relations = {'dbpeer': RelationMeta('peers', 'dbpeer', {'interface': 'dbpeer', 'scope': 'global'})} + self.model = ops.model.Model('myapp/0', meta, self.backend) + + err_msg = 'ERROR invalid value "$2" for option -r: relation not found' + fake_script(self, 'relation-ids', + '''([ "$1" = dbpeer ] && echo '["dbpeer:0"]') || echo "[]"''') + fake_script(self, 'relation-list', + f'''([ "$2" = 0 ] && echo "[]") || (echo {err_msg} >&2 ; exit 2)''') + + db1_4 = self.model.get_relation('dbpeer') + self.assertIs(db1_4.app, self.model.app) + + def test_remote_units_is_our(self): + fake_script(self, 'relation-ids', + """[ "$1" = db1 ] && echo '["db1:4"]' || echo '[]'""") + fake_script(self, 'relation-list', + """[ "$2" = 4 ] && echo '["remoteapp1/0", "remoteapp1/1"]' || exit 2""") + + for u in self.model.get_relation('db1').units: + self.assertFalse(u._is_our_unit) + self.assertFalse(u.app._is_our_app) + + self.assertEqual(fake_script_calls(self), [ + ['relation-ids', 'db1', '--format=json'], + ['relation-list', '-r', '4', '--format=json'] + ]) + + def test_our_unit_is_our(self): + self.assertTrue(self.model.unit._is_our_unit) + self.assertTrue(self.model.unit.app._is_our_app) + + def test_unit_relation_data(self): + fake_script(self, 'relation-ids', """[ "$1" = db1 ] && echo '["db1:4"]' || echo '[]'""") + fake_script(self, 'relation-list', """[ "$2" = 4 ] && echo '["remoteapp1/0"]' || exit 2""") + fake_script(self, 'relation-get', """([ "$2" = 4 ] && [ "$4" = "remoteapp1/0" ]) && echo '{"host": "remoteapp1-0"}' || exit 2""") + + random_unit = self.model._cache.get(ops.model.Unit, 'randomunit/0') + with self.assertRaises(KeyError): + self.model.get_relation('db1').data[random_unit] + remoteapp1_0 = next(filter(lambda u: u.name == 'remoteapp1/0', self.model.get_relation('db1').units)) + self.assertEqual(self.model.get_relation('db1').data[remoteapp1_0], {'host': 'remoteapp1-0'}) + + self.assertEqual(fake_script_calls(self), [ + ['relation-ids', 'db1', '--format=json'], + ['relation-list', '-r', '4', '--format=json'], + ['relation-get', '-r', '4', '-', 'remoteapp1/0', '--app=False', '--format=json'] + ]) + + def test_remote_app_relation_data(self): + fake_script(self, 'relation-ids', """[ "$1" = db1 ] && echo '["db1:4"]' || echo '[]'""") + fake_script(self, 'relation-list', """[ "$2" = 4 ] && echo '["remoteapp1/0", "remoteapp1/1"]' || exit 2""") + fake_script(self, 'relation-get', """[ "$2" = 4 ] && [ "$4" = remoteapp1 ] && echo '{"secret": "cafedeadbeef"}' || exit 2""") + + # Try to get relation data for an invalid remote application. + random_app = self.model._cache.get(ops.model.Application, 'randomapp') + with self.assertRaises(KeyError): + self.model.get_relation('db1').data[random_app] + + remoteapp1 = self.model.get_relation('db1').app + self.assertEqual(self.model.get_relation('db1').data[remoteapp1], {'secret': 'cafedeadbeef'}) + + self.assertEqual(fake_script_calls(self), [ + ['relation-ids', 'db1', '--format=json'], + ['relation-list', '-r', '4', '--format=json'], + ['relation-get', '-r', '4', '-', 'remoteapp1', '--app=True', '--format=json'], + ]) + + def test_relation_data_modify_remote(self): + fake_script(self, 'relation-ids', """[ "$1" = db1 ] && echo '["db1:4"]' || echo '[]'""") + fake_script(self, 'relation-list', """[ "$2" = 4 ] && echo '["remoteapp1/0"]' || exit 2""") + fake_script(self, 'relation-get', """([ "$2" = 4 ] && [ "$4" = "remoteapp1/0" ]) && echo '{"host": "remoteapp1-0"}' || exit 2""") + + rel_db1 = self.model.get_relation('db1') + remoteapp1_0 = next(filter(lambda u: u.name == 'remoteapp1/0', self.model.get_relation('db1').units)) + # Force memory cache to be loaded. + self.assertIn('host', rel_db1.data[remoteapp1_0]) + with self.assertRaises(ops.model.RelationDataError): + rel_db1.data[remoteapp1_0]['foo'] = 'bar' + self.assertNotIn('foo', rel_db1.data[remoteapp1_0]) + + self.assertEqual(fake_script_calls(self), [ + ['relation-ids', 'db1', '--format=json'], + ['relation-list', '-r', '4', '--format=json'], + ['relation-get', '-r', '4', '-', 'remoteapp1/0', '--app=False', '--format=json'] + ]) + + def test_relation_data_modify_our(self): + fake_script(self, 'relation-ids', """[ "$1" = db1 ] && echo '["db1:4"]' || echo '[]'""") + fake_script(self, 'relation-list', """[ "$2" = 4 ] && echo '["remoteapp1/0"]' || exit 2""") + fake_script(self, 'relation-set', '''[ "$2" = 4 ] && exit 0 || exit 2''') + fake_script(self, 'relation-get', """([ "$2" = 4 ] && [ "$4" = "myapp/0" ]) && echo '{"host": "bar"}' || exit 2""") + + rel_db1 = self.model.get_relation('db1') + # Force memory cache to be loaded. + self.assertIn('host', rel_db1.data[self.model.unit]) + rel_db1.data[self.model.unit]['host'] = 'bar' + self.assertEqual(rel_db1.data[self.model.unit]['host'], 'bar') + + self.assertEqual(fake_script_calls(self), [ + ['relation-ids', 'db1', '--format=json'], + ['relation-list', '-r', '4', '--format=json'], + ['relation-get', '-r', '4', '-', 'myapp/0', '--app=False', '--format=json'], + ['relation-set', '-r', '4', 'host=bar', '--app=False'] + ]) + + def test_app_relation_data_modify_local_as_leader(self): + fake_script(self, 'relation-ids', """[ "$1" = db1 ] && echo '["db1:4"]' || echo '[]'""") + fake_script(self, 'relation-list', """[ "$2" = 4 ] && echo '["remoteapp1/0", "remoteapp1/1"]' || exit 2""") + fake_script(self, 'relation-get', """[ "$2" = 4 ] && [ "$4" = myapp ] && echo '{"password": "deadbeefcafe"}' || exit 2""") + fake_script(self, 'relation-set', """[ "$2" = 4 ] && exit 0 || exit 2""") + fake_script(self, 'is-leader', 'echo true') + + local_app = self.model.unit.app + + rel_db1 = self.model.get_relation('db1') + self.assertEqual(rel_db1.data[local_app], {'password': 'deadbeefcafe'}) + + rel_db1.data[local_app]['password'] = 'foo' + + self.assertEqual(rel_db1.data[local_app]['password'], 'foo') + + self.assertEqual(fake_script_calls(self), [ + ['relation-ids', 'db1', '--format=json'], + ['relation-list', '-r', '4', '--format=json'], + ['relation-get', '-r', '4', '-', 'myapp', '--app=True', '--format=json'], + ['is-leader', '--format=json'], + ['relation-set', '-r', '4', 'password=foo', '--app=True'], + ]) + + def test_app_relation_data_modify_local_as_minion(self): + fake_script(self, 'relation-ids', """[ "$1" = db1 ] && echo '["db1:4"]' || echo '[]'""") + fake_script(self, 'relation-list', """[ "$2" = 4 ] && echo '["remoteapp1/0", "remoteapp1/1"]' || exit 2""") + fake_script(self, 'relation-get', """[ "$2" = 4 ] && [ "$4" = myapp ] && echo '{"password": "deadbeefcafe"}' || exit 2""") + fake_script(self, 'is-leader', 'echo false') + + local_app = self.model.unit.app + + rel_db1 = self.model.get_relation('db1') + self.assertEqual(rel_db1.data[local_app], {'password': 'deadbeefcafe'}) + + with self.assertRaises(ops.model.RelationDataError): + rel_db1.data[local_app]['password'] = 'foobar' + + self.assertEqual(fake_script_calls(self), [ + ['relation-ids', 'db1', '--format=json'], + ['relation-list', '-r', '4', '--format=json'], + ['relation-get', '-r', '4', '-', 'myapp', '--app=True', '--format=json'], + ['is-leader', '--format=json'], + ]) + + def test_relation_data_del_key(self): + fake_script(self, 'relation-ids', """[ "$1" = db1 ] && echo '["db1:4"]' || echo '[]'""") + fake_script(self, 'relation-list', """[ "$2" = 4 ] && echo '["remoteapp1/0"]' || exit 2""") + fake_script(self, 'relation-set', '''[ "$2" = 4 ] && exit 0 || exit 2''') + fake_script(self, 'relation-get', """([ "$2" = 4 ] && [ "$4" = "myapp/0" ]) && echo '{"host": "bar"}' || exit 2""") + + rel_db1 = self.model.get_relation('db1') + # Force memory cache to be loaded. + self.assertIn('host', rel_db1.data[self.model.unit]) + del rel_db1.data[self.model.unit]['host'] + fake_script(self, 'relation-get', """([ "$2" = 4 ] && [ "$4" = "myapp/0" ]) && echo '{}' || exit 2""") + self.assertNotIn('host', rel_db1.data[self.model.unit]) + + self.assertEqual(fake_script_calls(self), [ + ['relation-ids', 'db1', '--format=json'], + ['relation-list', '-r', '4', '--format=json'], + ['relation-get', '-r', '4', '-', 'myapp/0', '--app=False', '--format=json'], + ['relation-set', '-r', '4', 'host=', '--app=False'] + ]) + + def test_relation_set_fail(self): + fake_script(self, 'relation-ids', """[ "$1" = db2 ] && echo '["db2:5"]' || echo '[]'""") + fake_script(self, 'relation-list', + """[ "$2" = 5 ] && echo '["remoteapp1/0"]' || exit 2""") + fake_script(self, 'relation-get', """([ "$2" = 5 ] && [ "$4" = "myapp/0" ]) && echo '{"host": "myapp-0"}' || exit 2""") + fake_script(self, 'relation-set', 'exit 2') + + rel_db2 = self.model.relations['db2'][0] + # Force memory cache to be loaded. + self.assertIn('host', rel_db2.data[self.model.unit]) + with self.assertRaises(ops.model.ModelError): + rel_db2.data[self.model.unit]['host'] = 'bar' + self.assertEqual(rel_db2.data[self.model.unit]['host'], 'myapp-0') + with self.assertRaises(ops.model.ModelError): + del rel_db2.data[self.model.unit]['host'] + self.assertIn('host', rel_db2.data[self.model.unit]) + + self.assertEqual(fake_script_calls(self), [ + ['relation-ids', 'db2', '--format=json'], + ['relation-list', '-r', '5', '--format=json'], + ['relation-get', '-r', '5', '-', 'myapp/0', '--app=False', '--format=json'], + ['relation-set', '-r', '5', 'host=bar', '--app=False'], + ['relation-set', '-r', '5', 'host=', '--app=False'] + ]) + + def test_relation_get_set_is_app_arg(self): + self.backend = ops.model.ModelBackend() + + # No is_app provided. + with self.assertRaises(TypeError): + self.backend.relation_set(1, 'fookey', 'barval') + + with self.assertRaises(TypeError): + self.backend.relation_get(1, 'fooentity') + + # Invalid types for is_app. + for is_app_v in [None, 1, 2.0, 'a', b'beef']: + with self.assertRaises(TypeError): + self.backend.relation_set(1, 'fookey', 'barval', is_app=is_app_v) + + with self.assertRaises(TypeError): + self.backend.relation_get(1, 'fooentity', is_app=is_app_v) + + def test_relation_data_type_check(self): + fake_script(self, 'relation-ids', """[ "$1" = db1 ] && echo '["db1:4"]' || echo '[]'""") + fake_script(self, 'relation-list', + """[ "$2" = 4 ] && echo '["remoteapp1/0"]' || exit 2""") + fake_script(self, 'relation-get', """([ "$2" = 4 ] && [ "$4" = "myapp/0" ]) && echo '{"host": "myapp-0"}' || exit 2""") + + rel_db1 = self.model.get_relation('db1') + with self.assertRaises(ops.model.RelationDataError): + rel_db1.data[self.model.unit]['foo'] = 1 + with self.assertRaises(ops.model.RelationDataError): + rel_db1.data[self.model.unit]['foo'] = {'foo': 'bar'} + with self.assertRaises(ops.model.RelationDataError): + rel_db1.data[self.model.unit]['foo'] = None + + self.assertEqual(fake_script_calls(self), [ + ['relation-ids', 'db1', '--format=json'], + ['relation-list', '-r', '4', '--format=json'] + ]) + + def test_config(self): + fake_script(self, 'config-get', """echo '{"foo":"foo","bar":1,"qux":true}'""") + self.assertEqual(self.model.config, { + 'foo': 'foo', + 'bar': 1, + 'qux': True, + }) + with self.assertRaises(TypeError): + # Confirm that we cannot modify config values. + self.model.config['foo'] = 'bar' + + self.assertEqual(fake_script_calls(self), [['config-get', '--format=json']]) + + def test_is_leader(self): + def check_remote_units(): + fake_script(self, 'relation-ids', + """[ "$1" = db1 ] && echo '["db1:4"]' || echo '[]'""") + + fake_script(self, 'relation-list', + """[ "$2" = 4 ] && echo '["remoteapp1/0", "remoteapp1/1"]' || exit 2""") + + # Cannot determine leadership for remote units. + for u in self.model.get_relation('db1').units: + with self.assertRaises(RuntimeError): + u.is_leader() + + fake_script(self, 'is-leader', 'echo true') + self.assertTrue(self.model.unit.is_leader()) + + check_remote_units() + + # Create a new model and backend to drop a cached is-leader output. + self.backend = ops.model.ModelBackend() + meta = ops.charm.CharmMeta() + meta.relations = { + 'db0': RelationMeta('provides', 'db0', {'interface': 'db0', 'scope': 'global'}), + 'db1': RelationMeta('requires', 'db1', {'interface': 'db1', 'scope': 'global'}), + 'db2': RelationMeta('peers', 'db2', {'interface': 'db2', 'scope': 'global'}), + } + self.model = ops.model.Model('myapp/0', meta, self.backend) + + fake_script(self, 'is-leader', 'echo false') + self.assertFalse(self.model.unit.is_leader()) + + check_remote_units() + + self.assertEqual(fake_script_calls(self), [ + ['is-leader', '--format=json'], + ['relation-ids', 'db1', '--format=json'], + ['relation-list', '-r', '4', '--format=json'], + ['is-leader', '--format=json'], + ['relation-ids', 'db1', '--format=json'], + ['relation-list', '-r', '4', '--format=json'], + ]) + + def test_is_leader_refresh(self): + # A sanity check. + self.assertGreater(time.monotonic(), ops.model.ModelBackend.LEASE_RENEWAL_PERIOD.total_seconds()) + + fake_script(self, 'is-leader', 'echo false') + self.assertFalse(self.model.unit.is_leader()) + + # Change the leadership status and force a recheck. + fake_script(self, 'is-leader', 'echo true') + self.backend._leader_check_time = 0 + self.assertTrue(self.model.unit.is_leader()) + + # Force a recheck without changing the leadership status. + fake_script(self, 'is-leader', 'echo true') + self.backend._leader_check_time = 0 + self.assertTrue(self.model.unit.is_leader()) + + def test_resources(self): + meta = ops.charm.CharmMeta() + meta.resources = {'foo': None, 'bar': None} + model = ops.model.Model('myapp/0', meta, self.backend) + + with self.assertRaises(RuntimeError): + model.resources.fetch('qux') + + fake_script(self, 'resource-get', 'exit 1') + with self.assertRaises(ops.model.ModelError): + model.resources.fetch('foo') + + fake_script(self, 'resource-get', 'echo /var/lib/juju/agents/unit-test-0/resources/$1/$1.tgz') + self.assertEqual(model.resources.fetch('foo').name, 'foo.tgz') + self.assertEqual(model.resources.fetch('bar').name, 'bar.tgz') + + def test_pod_spec(self): + fake_script(self, 'pod-spec-set', """ + cat $2 > $(dirname $0)/spec.json + [[ -n $4 ]] && cat $4 > $(dirname $0)/k8s_res.json || true + """) + fake_script(self, 'is-leader', 'echo true') + spec_path = self.fake_script_path / 'spec.json' + k8s_res_path = self.fake_script_path / 'k8s_res.json' + + def check_calls(calls): + # There may 1 or 2 calls because of is-leader. + self.assertLessEqual(len(fake_calls), 2) + pod_spec_call = next(filter(lambda c: c[0] == 'pod-spec-set', calls)) + self.assertEqual(pod_spec_call[:2], ['pod-spec-set', '--file']) + # 8 bytes are used as of python 3.4.0, see Python bug #12015. + # Other characters are from POSIX 3.282 (Portable Filename Character Set) a subset of which Python's mkdtemp uses. + self.assertTrue(re.match('/tmp/tmp[A-Za-z0-9._-]{8}-pod-spec-set', pod_spec_call[2])) + + self.model.pod.set_spec({'foo': 'bar'}) + self.assertEqual(spec_path.read_text(), '{"foo": "bar"}') + self.assertFalse(k8s_res_path.exists()) + + fake_calls = fake_script_calls(self, clear=True) + check_calls(fake_calls) + + self.model.pod.set_spec({'bar': 'foo'}, {'qux': 'baz'}) + self.assertEqual(spec_path.read_text(), '{"bar": "foo"}') + self.assertEqual(k8s_res_path.read_text(), '{"qux": "baz"}') + + fake_calls = fake_script_calls(self, clear=True) + check_calls(fake_calls) + + # Create a new model to drop is-leader caching result. + self.backend = ops.model.ModelBackend() + meta = ops.charm.CharmMeta() + self.model = ops.model.Model('myapp/0', meta, self.backend) + fake_script(self, 'is-leader', 'echo false') + with self.assertRaises(ops.model.ModelError): + self.model.pod.set_spec({'foo': 'bar'}) + + def test_base_status_instance_raises(self): + with self.assertRaises(TypeError): + ops.model.StatusBase('test') + + def test_active_message_default(self): + self.assertEqual(ops.model.ActiveStatus().message, '') + + def test_local_set_valid_unit_status(self): + test_cases = [( + ops.model.ActiveStatus('Green'), + lambda: fake_script(self, 'status-set', 'exit 0'), + lambda: self.assertEqual(fake_script_calls(self, True), [['status-set', '--application=False', 'active', 'Green']]), + ), ( + ops.model.MaintenanceStatus('Yellow'), + lambda: fake_script(self, 'status-set', 'exit 0'), + lambda: self.assertEqual(fake_script_calls(self, True), [['status-set', '--application=False', 'maintenance', 'Yellow']]), + ), ( + ops.model.BlockedStatus('Red'), + lambda: fake_script(self, 'status-set', 'exit 0'), + lambda: self.assertEqual(fake_script_calls(self, True), [['status-set', '--application=False', 'blocked', 'Red']]), + ), ( + ops.model.WaitingStatus('White'), + lambda: fake_script(self, 'status-set', 'exit 0'), + lambda: self.assertEqual(fake_script_calls(self, True), [['status-set', '--application=False', 'waiting', 'White']]), + )] + + for target_status, setup_tools, check_tool_calls in test_cases: + setup_tools() + + self.model.unit.status = target_status + + self.assertEqual(self.model.unit.status, target_status) + + check_tool_calls() + + def test_local_set_valid_app_status(self): + fake_script(self, 'is-leader', 'echo true') + test_cases = [( + ops.model.ActiveStatus('Green'), + lambda: fake_script(self, 'status-set', 'exit 0'), + lambda: self.assertIn(['status-set', '--application=True', 'active', 'Green'], fake_script_calls(self, True)), + ), ( + ops.model.MaintenanceStatus('Yellow'), + lambda: fake_script(self, 'status-set', 'exit 0'), + lambda: self.assertIn(['status-set', '--application=True', 'maintenance', 'Yellow'], fake_script_calls(self, True)), + ), ( + ops.model.BlockedStatus('Red'), + lambda: fake_script(self, 'status-set', 'exit 0'), + lambda: self.assertIn(['status-set', '--application=True', 'blocked', 'Red'], fake_script_calls(self, True)), + ), ( + ops.model.WaitingStatus('White'), + lambda: fake_script(self, 'status-set', 'exit 0'), + lambda: self.assertIn(['status-set', '--application=True', 'waiting', 'White'], fake_script_calls(self, True)), + )] + + for target_status, setup_tools, check_tool_calls in test_cases: + setup_tools() + + self.model.app.status = target_status + + self.assertEqual(self.model.app.status, target_status) + + check_tool_calls() + + def test_set_app_status_non_leader_raises(self): + fake_script(self, 'is-leader', 'echo false') + + with self.assertRaises(RuntimeError): + self.model.app.status + + with self.assertRaises(RuntimeError): + self.model.app.status = ops.model.ActiveStatus() + + def test_local_set_invalid_status(self): + fake_script(self, 'status-set', 'exit 1') + fake_script(self, 'is-leader', 'echo true') + + with self.assertRaises(ops.model.ModelError): + self.model.unit.status = ops.model.UnknownStatus() + + self.assertEqual(fake_script_calls(self, True), [ + ['status-set', '--application=False', 'unknown', ''], + ]) + + with self.assertRaises(ops.model.ModelError): + self.model.app.status = ops.model.UnknownStatus() + + # A leadership check is needed for application status. + self.assertEqual(fake_script_calls(self, True), [ + ['is-leader', '--format=json'], + ['status-set', '--application=True', 'unknown', ''], + ]) + + def test_status_set_is_app_not_bool_raises(self): + self.backend = ops.model.ModelBackend() + + for is_app_v in [None, 1, 2.0, 'a', b'beef', object]: + with self.assertRaises(TypeError): + self.backend.status_set(ops.model.ActiveStatus, is_app=is_app_v) + + def test_remote_unit_status(self): + fake_script(self, 'relation-ids', """[ "$1" = db1 ] && echo '["db1:4"]' || echo '[]'""") + fake_script(self, 'relation-list', """[ "$2" = 4 ] && echo '["remoteapp1/0", "remoteapp1/1"]' || exit 2""") + + remote_unit = next(filter(lambda u: u.name == 'remoteapp1/0', self.model.get_relation('db1').units)) + + test_statuses = ( + ops.model.UnknownStatus(), + ops.model.ActiveStatus('Green'), + ops.model.MaintenanceStatus('Yellow'), + ops.model.BlockedStatus('Red'), + ops.model.WaitingStatus('White'), + ) + + for target_status in test_statuses: + with self.assertRaises(RuntimeError): + remote_unit.status = target_status + + def test_remote_app_status(self): + fake_script(self, 'relation-ids', """[ "$1" = db1 ] && echo '["db1:4"]' || echo '[]'""") + fake_script(self, 'relation-list', """[ "$2" = 4 ] && echo '["remoteapp1/0", "remoteapp1/1"]' || exit 2""") + + remoteapp1 = self.model.get_relation('db1').app + + # Remote application status is always unknown. + self.assertIsInstance(remoteapp1.status, ops.model.UnknownStatus) + + test_statuses = ( + ops.model.UnknownStatus(), + ops.model.ActiveStatus(), + ops.model.MaintenanceStatus('Upgrading software'), + ops.model.BlockedStatus('Awaiting manual resolution'), + ops.model.WaitingStatus('Awaiting related app updates'), + ) + for target_status in test_statuses: + with self.assertRaises(RuntimeError): + remoteapp1.status = target_status + + self.assertEqual(fake_script_calls(self, clear=True), [ + ['relation-ids', 'db1', '--format=json'], + ['relation-list', '-r', '4', '--format=json'], + ]) + + def test_storage(self): + meta = ops.charm.CharmMeta() + meta.storages = {'disks': None, 'data': None} + self.model = ops.model.Model('myapp/0', meta, self.backend) + + fake_script(self, 'storage-list', """[ "$1" = disks ] && echo '["disks/0", "disks/1"]' || echo '[]'""") + fake_script(self, 'storage-get', + """ + if [ "$2" = disks/0 ]; then + echo '"/var/srv/disks/0"' + elif [ "$2" = disks/1 ]; then + echo '"/var/srv/disks/1"' + else + exit 2 + fi + """) + fake_script(self, 'storage-add', '') + + self.assertEqual(len(self.model.storages), 2) + self.assertEqual(self.model.storages.keys(), meta.storages.keys()) + self.assertIn('disks', self.model.storages) + test_cases = { + 0: {'name': 'disks', 'location': pathlib.Path('/var/srv/disks/0')}, + 1: {'name': 'disks', 'location': pathlib.Path('/var/srv/disks/1')}, + } + for storage in self.model.storages['disks']: + self.assertEqual(storage.name, 'disks') + self.assertIn(storage.id, test_cases) + self.assertEqual(storage.name, test_cases[storage.id]['name']) + self.assertEqual(storage.location, test_cases[storage.id]['location']) + + self.assertEqual(fake_script_calls(self, clear=True), [ + ['storage-list', 'disks', '--format=json'], + ['storage-get', '-s', 'disks/0', 'location', '--format=json'], + ['storage-get', '-s', 'disks/1', 'location', '--format=json'], + ]) + + self.assertSequenceEqual(self.model.storages['data'], []) + self.model.storages.request('data', count=3) + self.assertEqual(fake_script_calls(self), [ + ['storage-list', 'data', '--format=json'], + ['storage-add', 'data=3'], + ]) + + # Try to add storage not present in charm metadata. + with self.assertRaises(ops.model.ModelError): + self.model.storages.request('deadbeef') + + # Invalid count parameter types. + for count_v in [None, False, 2.0, 'a', b'beef', object]: + with self.assertRaises(TypeError): + self.model.storages.request('data', count_v) + + +class TestModelBackend(unittest.TestCase): + + def setUp(self): + os.environ['JUJU_UNIT_NAME'] = 'myapp/0' + self.addCleanup(os.environ.pop, 'JUJU_UNIT_NAME') + + self._backend = None + + @property + def backend(self): + if self._backend is None: + self._backend = ops.model.ModelBackend() + return self._backend + + def test_relation_tool_errors(self): + err_msg = 'ERROR invalid value "$2" for option -r: relation not found' + + test_cases = [( + lambda: fake_script(self, 'relation-list', f'echo fooerror >&2 ; exit 1'), + lambda: self.backend.relation_list(3), + ops.model.ModelError, + [['relation-list', '-r', '3', '--format=json']], + ), ( + lambda: fake_script(self, 'relation-list', f'echo {err_msg} >&2 ; exit 2'), + lambda: self.backend.relation_list(3), + ops.model.RelationNotFoundError, + [['relation-list', '-r', '3', '--format=json']], + ), ( + lambda: fake_script(self, 'relation-set', f'echo fooerror >&2 ; exit 1'), + lambda: self.backend.relation_set(3, 'foo', 'bar', is_app=False), + ops.model.ModelError, + [['relation-set', '-r', '3', 'foo=bar', '--app=False']], + ), ( + lambda: fake_script(self, 'relation-set', f'echo {err_msg} >&2 ; exit 2'), + lambda: self.backend.relation_set(3, 'foo', 'bar', is_app=False), + ops.model.RelationNotFoundError, + [['relation-set', '-r', '3', 'foo=bar', '--app=False']], + ), ( + lambda: fake_script(self, 'relation-get', f'echo fooerror >&2 ; exit 1'), + lambda: self.backend.relation_get(3, 'remote/0', is_app=False), + ops.model.ModelError, + [['relation-get', '-r', '3', '-', 'remote/0', '--app=False', '--format=json']], + ), ( + lambda: fake_script(self, 'relation-get', f'echo {err_msg} >&2 ; exit 2'), + lambda: self.backend.relation_get(3, 'remote/0', is_app=False), + ops.model.RelationNotFoundError, + [['relation-get', '-r', '3', '-', 'remote/0', '--app=False', '--format=json']], + )] + + for do_fake, run, exception, calls in test_cases: + do_fake() + with self.assertRaises(exception): + run() + self.assertEqual(fake_script_calls(self, clear=True), calls) + + def test_status_is_app_forced_kwargs(self): + fake_script(self, 'status-get', 'exit 1') + fake_script(self, 'status-set', 'exit 1') + + test_cases = ( + lambda: self.backend.status_get(False), + lambda: self.backend.status_get(True), + lambda: self.backend.status_set('active', '', False), + lambda: self.backend.status_set('active', '', True), + ) + + for case in test_cases: + with self.assertRaises(TypeError): + case() + + def test_storage_tool_errors(self): + test_cases = [( + lambda: fake_script(self, 'storage-list', f'echo fooerror >&2 ; exit 1'), + lambda: self.backend.storage_list('foobar'), + ops.model.ModelError, + [['storage-list', 'foobar', '--format=json']], + ), ( + lambda: fake_script(self, 'storage-get', f'echo fooerror >&2 ; exit 1'), + lambda: self.backend.storage_get('foobar', 'someattr'), + ops.model.ModelError, + [['storage-get', '-s', 'foobar', 'someattr', '--format=json']], + ), ( + lambda: fake_script(self, 'storage-add', f'echo fooerror >&2 ; exit 1'), + lambda: self.backend.storage_add('foobar', count=2), + ops.model.ModelError, + [['storage-add', 'foobar=2']], + ), ( + lambda: fake_script(self, 'storage-add', f'echo fooerror >&2 ; exit 1'), + lambda: self.backend.storage_add('foobar', count=object), + TypeError, + [], + ), ( + lambda: fake_script(self, 'storage-add', f'echo fooerror >&2 ; exit 1'), + lambda: self.backend.storage_add('foobar', count=True), + TypeError, + [], + )] + for do_fake, run, exception, calls in test_cases: + do_fake() + with self.assertRaises(exception): + run() + self.assertEqual(fake_script_calls(self, clear=True), calls) + + def test_network_get(self): + network_get_out = '''{ + "bind-addresses": [ + { + "mac-address": "", + "interface-name": "", + "addresses": [ + { + "hostname": "", + "value": "192.0.2.2", + "cidr": "" + } + ] + } + ], + "egress-subnets": [ + "192.0.2.2/32" + ], + "ingress-addresses": [ + "192.0.2.2" + ] +}''' + fake_script(self, 'network-get', f'''[ "$1" = deadbeef ] && echo '{network_get_out}' || exit 1''') + network_info = self.backend.network_get('deadbeef') + self.assertEqual(network_info, json.loads(network_get_out)) + self.assertEqual(fake_script_calls(self, clear=True), [['network-get', 'deadbeef', '--format=json']]) + + network_info = self.backend.network_get('deadbeef', 1) + self.assertEqual(network_info, json.loads(network_get_out)) + self.assertEqual(fake_script_calls(self, clear=True), [['network-get', 'deadbeef', '-r', '1', '--format=json']]) + + def test_network_get_errors(self): + err_no_endpoint = 'ERROR no network config found for binding "$2"' + err_no_rel = 'ERROR invalid value "$3" for option -r: relation not found' + + test_cases = [( + lambda: fake_script(self, 'network-get', f'echo {err_no_endpoint} >&2 ; exit 1'), + lambda: self.backend.network_get("deadbeef"), + ops.model.ModelError, + [['network-get', 'deadbeef', '--format=json']], + ), ( + lambda: fake_script(self, 'network-get', f'echo {err_no_rel} >&2 ; exit 2'), + lambda: self.backend.network_get("deadbeef", 3), + ops.model.RelationNotFoundError, + [['network-get', 'deadbeef', '-r', '3', '--format=json']], + )] + for do_fake, run, exception, calls in test_cases: + do_fake() + with self.assertRaises(exception): + run() + self.assertEqual(fake_script_calls(self, clear=True), calls) + + def test_action_get_error(self): + fake_script(self, 'action-get', '') + fake_script(self, 'action-get', f'echo fooerror >&2 ; exit 1') + with self.assertRaises(ops.model.ModelError): + self.backend.action_get() + calls = [['action-get', '--format=json']] + self.assertEqual(fake_script_calls(self, clear=True), calls) + + def test_action_set_error(self): + fake_script(self, 'action-get', '') + fake_script(self, 'action-set', f'echo fooerror >&2 ; exit 1') + with self.assertRaises(ops.model.ModelError): + self.backend.action_set({'foo': 'bar', 'dead': 'beef cafe'}) + calls = [["action-set", "foo=bar", "dead=beef cafe"]] + self.assertEqual(fake_script_calls(self, clear=True), calls) + + def test_action_log_error(self): + fake_script(self, 'action-get', '') + fake_script(self, 'action-log', f'echo fooerror >&2 ; exit 1') + with self.assertRaises(ops.model.ModelError): + self.backend.action_log('log-message') + calls = [["action-log", "log-message"]] + self.assertEqual(fake_script_calls(self, clear=True), calls) + + def test_action_get(self): + fake_script(self, 'action-get', """echo '{"foo-name": "bar", "silent": false}'""") + params = self.backend.action_get() + self.assertEqual(params['foo-name'], 'bar') + self.assertEqual(params['silent'], False) + self.assertEqual(fake_script_calls(self), [['action-get', '--format=json']]) + + def test_action_set(self): + fake_script(self, 'action-get', 'exit 1') + fake_script(self, 'action-set', 'exit 0') + self.backend.action_set({'x': 'dead beef', 'y': 1}) + self.assertEqual(fake_script_calls(self), [['action-set', 'x=dead beef', 'y=1']]) + + def test_action_fail(self): + fake_script(self, 'action-get', 'exit 1') + fake_script(self, 'action-fail', 'exit 0') + self.backend.action_fail('error 42') + self.assertEqual(fake_script_calls(self), [['action-fail', 'error 42']]) + + def test_action_log(self): + fake_script(self, 'action-get', 'exit 1') + fake_script(self, 'action-log', 'exit 0') + self.backend.action_log('progress: 42%') + self.assertEqual(fake_script_calls(self), [['action-log', 'progress: 42%']]) + + +if __name__ == "__main__": + unittest.main() diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/requirements.txt b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/requirements.txt new file mode 100644 index 00000000..8608c1b0 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/requirements.txt @@ -0,0 +1 @@ +paramiko diff --git a/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/src/charm.py b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/src/charm.py new file mode 100755 index 00000000..eb61f692 --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/charms/charm-simple-k8s/src/charm.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 + +import sys + +sys.path.append("lib") + +from ops.charm import CharmBase +from ops.framework import StoredState +from ops.main import main +from ops.model import ( + ActiveStatus, + BlockedStatus, + MaintenanceStatus, + WaitingStatus, + ModelError, +) +import os +import subprocess + + +def install_dependencies(): + # Make sure Python3 + PIP are available + if not os.path.exists("/usr/bin/python3") or not os.path.exists("/usr/bin/pip3"): + # This is needed when running as a k8s charm, as the ubuntu:latest + # image doesn't include either package. + + # Update the apt cache + subprocess.check_call(["apt-get", "update"]) + + # Install the Python3 package + subprocess.check_call(["apt-get", "install", "-y", "python3", "python3-pip"],) + + + # Install the build dependencies for our requirements (paramiko) + subprocess.check_call(["apt-get", "install", "-y", "libffi-dev", "libssl-dev"],) + + REQUIREMENTS_TXT = "{}/requirements.txt".format(os.environ["JUJU_CHARM_DIR"]) + if os.path.exists(REQUIREMENTS_TXT): + subprocess.check_call( + ["apt-get", "install", "-y", "python3-paramiko", "openssh-client"], + ) + + +try: + from charms.osm.sshproxy import SSHProxy +except Exception as ex: + install_dependencies() + from charms.osm.sshproxy import SSHProxy + + +class SimpleProxyCharm(CharmBase): + state = StoredState() + + def __init__(self, *args): + super().__init__(*args) + + # An example of setting charm state + # that's persistent across events + self.state.set_default(is_started=False) + + if not self.state.is_started: + self.state.is_started = True + + # Register all of the events we want to observe + for event in ( + # Charm events + self.on.config_changed, + self.on.start, + self.on.upgrade_charm, + # Charm actions (primitives) + self.on.touch_action, + # OSM actions (primitives) + self.on.start_action, + self.on.stop_action, + self.on.restart_action, + self.on.reboot_action, + self.on.upgrade_action, + # SSH Proxy actions (primitives) + self.on.generate_ssh_key_action, + self.on.get_ssh_public_key_action, + self.on.run_action, + self.on.verify_ssh_credentials_action, + ): + self.framework.observe(event, self) + + def get_ssh_proxy(self): + """Get the SSHProxy instance""" + proxy = SSHProxy( + hostname=self.model.config["ssh-hostname"], + username=self.model.config["ssh-username"], + password=self.model.config["ssh-password"], + ) + return proxy + + def on_config_changed(self, event): + """Handle changes in configuration""" + unit = self.model.unit + + # Unit should go into a waiting state until verify_ssh_credentials is successful + unit.status = WaitingStatus("Waiting for SSH credentials") + proxy = self.get_ssh_proxy() + + verified = proxy.verify_credentials() + if verified: + unit.status = ActiveStatus() + else: + unit.status = BlockedStatus("Invalid SSH credentials.") + + def on_start(self, event): + """Called when the charm is being started""" + unit = self.model.unit + + if not SSHProxy.has_ssh_key(): + unit.status = MaintenanceStatus("Generating SSH keys...") + + print("Generating SSH Keys") + SSHProxy.generate_ssh_key() + + unit.status = ActiveStatus() + + def on_touch_action(self, event): + """Touch a file.""" + try: + filename = event.params["filename"] + proxy = self.get_ssh_proxy() + + stdout, stderr = proxy.run("touch {}".format(filename)) + event.set_results({"output": stdout}) + except Exception as ex: + event.fail(ex) + + def on_upgrade_charm(self, event): + """Upgrade the charm.""" + unit = self.model.unit + + # Mark the unit as under Maintenance. + unit.status = MaintenanceStatus("Upgrading charm") + + self.on_install(event) + + # When maintenance is done, return to an Active state + unit.status = ActiveStatus() + + ############### + # OSM methods # + ############### + def on_start_action(self, event): + """Start the VNF service on the VM.""" + pass + + def on_stop_action(self, event): + """Stop the VNF service on the VM.""" + pass + + def on_restart_action(self, event): + """Restart the VNF service on the VM.""" + pass + + def on_reboot_action(self, event): + """Reboot the VM.""" + proxy = self.get_ssh_proxy() + stdout, stderr = proxy.run("sudo reboot") + + if len(stderr): + event.fail(stderr) + + def on_upgrade_action(self, event): + """Upgrade the VNF service on the VM.""" + pass + + ##################### + # SSH Proxy methods # + ##################### + def on_generate_ssh_key_action(self, event): + """Generate a new SSH keypair for this unit.""" + + if not SSHProxy.generate_ssh_key(): + event.fail("Unable to generate ssh key") + + def on_get_ssh_public_key_action(self, event): + """Get the SSH public key for this unit.""" + + pubkey = SSHProxy.get_ssh_public_key() + + event.set_results({"pubkey": SSHProxy.get_ssh_public_key()}) + + def on_run_action(self, event): + """Run an arbitrary command on the remote host.""" + + cmd = event.params["command"] + + proxy = self.get_ssh_proxy() + stdout, stderr = proxy.run(cmd) + + event.set_results({"output": stdout}) + + if len(stderr): + event.fail(stderr) + + def on_verify_ssh_credentials_action(self, event): + """Verify the SSH credentials for this unit.""" + + proxy = self.get_ssh_proxy() + + verified, stderr = proxy.verify_credentials() + if verified: + print("Verified!") + event.set_results({"verified": True}) + else: + print("Verification failed!") + event.set_results({"verified": False}) + event.fail(stderr) + + +if __name__ == "__main__": + main(SimpleProxyCharm) diff --git a/hackfest_k8sproxycharm_vnf/cloud_init/cloud-config.txt b/hackfest_k8sproxycharm_vnf/cloud_init/cloud-config.txt new file mode 100755 index 00000000..36c8d1bf --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/cloud_init/cloud-config.txt @@ -0,0 +1,12 @@ +#cloud-config +password: osm4u +chpasswd: { expire: False } +ssh_pwauth: True + +write_files: +- content: | + # My new helloworld file + + owner: root:root + permissions: '0644' + path: /root/helloworld.txt diff --git a/hackfest_k8sproxycharm_vnf/hackfest_k8sproxycharm_vnfd.yaml b/hackfest_k8sproxycharm_vnf/hackfest_k8sproxycharm_vnfd.yaml new file mode 100644 index 00000000..831a404e --- /dev/null +++ b/hackfest_k8sproxycharm_vnf/hackfest_k8sproxycharm_vnfd.yaml @@ -0,0 +1,69 @@ +vnfd:vnfd-catalog: + vnfd: + - id: hackfest_k8sproxycharm-vnf + name: hackfest_k8sproxycharm-vnf + short-name: hackfest_k8sproxycharm-vnf + version: '1.0' + description: A VNF consisting of 1 VDU connected to two external VL, and one for data and another one for management + logo: osm.png + connection-point: + - id: vnf-mgmt + name: vnf-mgmt + short-name: vnf-mgmt + type: VPORT + - id: vnf-data + name: vnf-data + short-name: vnf-data + type: VPORT + mgmt-interface: + cp: vnf-mgmt + vdu: + - id: mgmtVM + name: mgmtVM + image: "bionic" + count: '1' + vm-flavor: + vcpu-count: '1' + memory-mb: '1024' + storage-gb: '10' + interface: + - name: mgmtVM-eth0 + position: '1' + type: EXTERNAL + virtual-interface: + type: VIRTIO + external-connection-point-ref: vnf-mgmt + - name: dataVM-xe0 + position: '2' + type: EXTERNAL + virtual-interface: + type: VIRTIO + external-connection-point-ref: vnf-data + cloud-init-file: cloud-config.txt + vnf-configuration: + juju: + charm: charm-simple-k8s + proxy: True + cloud: k8s + initial-config-primitive: + - seq: '1' + name: config + parameter: + - name: ssh-hostname + value: + - name: ssh-username + value: ubuntu + - name: ssh-password + value: osm4u + - seq: '2' + name: touch + parameter: + - name: filename + data-type: STRING + value: '/home/ubuntu/first-touch' + config-primitive: + - name: touch + parameter: + - name: filename + data-type: STRING + default-value: '/home/ubuntu/touched' diff --git a/hackfest_k8sproxycharm_vnf/icons/osm.png b/hackfest_k8sproxycharm_vnf/icons/osm.png new file mode 100644 index 0000000000000000000000000000000000000000..62012d2a2b491bdcd536d62c3c3c863c0d8c1b33 GIT binary patch literal 55888 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
      duScHe0;pydzZXYcj6S9VzJG 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 Mr>mdKI;Vst09ar5X8-^I literal 0 HcmV?d00001 -- GitLab