From 1ce63e353969a74db644ffb866b3371f521f44c2 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Tue, 12 Nov 2024 19:41:30 +0100 Subject: [PATCH] Add charm code (#5) * Add charm code Signed-off-by: Marcelo Henrique Neppel * Parse charm code Signed-off-by: Marcelo Henrique Neppel --------- Signed-off-by: Marcelo Henrique Neppel --- charm/.gitignore | 9 + charm/CONTRIBUTING.md | 34 +++ charm/LICENSE | 202 ++++++++++++++++++ charm/README.md | 26 +++ charm/charmcraft.yaml | 85 ++++++++ charm/pyproject.toml | 40 ++++ charm/requirements.txt | 1 + charm/spread.yaml | 152 +++++++++++++ charm/src/charm.py | 104 +++++++++ charm/tests/integration/test_charm.py | 35 +++ .../spread/general/integration/task.yaml | 4 + charm/tests/spread/lib/cloud-config.yaml | 10 + charm/tests/spread/lib/test-helpers.sh | 86 ++++++++ charm/tests/spread/lib/tools/retry | 158 ++++++++++++++ charm/tests/unit/test_charm.py | 72 +++++++ charm/tox.ini | 84 ++++++++ src/main.rs | 12 +- 17 files changed, 1110 insertions(+), 4 deletions(-) create mode 100644 charm/.gitignore create mode 100644 charm/CONTRIBUTING.md create mode 100644 charm/LICENSE create mode 100644 charm/README.md create mode 100644 charm/charmcraft.yaml create mode 100644 charm/pyproject.toml create mode 100644 charm/requirements.txt create mode 100644 charm/spread.yaml create mode 100755 charm/src/charm.py create mode 100644 charm/tests/integration/test_charm.py create mode 100644 charm/tests/spread/general/integration/task.yaml create mode 100644 charm/tests/spread/lib/cloud-config.yaml create mode 100644 charm/tests/spread/lib/test-helpers.sh create mode 100755 charm/tests/spread/lib/tools/retry create mode 100644 charm/tests/unit/test_charm.py create mode 100644 charm/tox.ini diff --git a/charm/.gitignore b/charm/.gitignore new file mode 100644 index 0000000..a26d707 --- /dev/null +++ b/charm/.gitignore @@ -0,0 +1,9 @@ +venv/ +build/ +*.charm +.tox/ +.coverage +__pycache__/ +*.py[cod] +.idea +.vscode/ diff --git a/charm/CONTRIBUTING.md b/charm/CONTRIBUTING.md new file mode 100644 index 0000000..20e88bc --- /dev/null +++ b/charm/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# Contributing + +To make contributions to this charm, you'll need a working [development setup](https://juju.is/docs/sdk/dev-setup). + +You can create an environment for development with `tox`: + +```shell +tox devenv -e integration +source venv/bin/activate +``` + +## Testing + +This project uses `tox` for managing test environments. There are some pre-configured environments +that can be used for linting and formatting code when you're preparing contributions to the charm: + +```shell +tox run -e format # update your code according to linting rules +tox run -e lint # code style +tox run -e static # static type checking +tox run -e unit # unit tests +tox run -e integration # integration tests +tox # runs 'format', 'lint', 'static', and 'unit' environments +``` + +## Build the charm + +Build the charm in this git repository using: + +```shell +charmcraft pack +``` + + + +# charm + +Charmhub package name: operator-template +More information: https://charmhub.io/charm + +Describe your charm in one or two sentences. + +## Other resources + + + +- [Read more](https://example.com) + +- [Contributing](CONTRIBUTING.md) + +- See the [Juju SDK documentation](https://juju.is/docs/sdk) for more information about developing and improving charms. diff --git a/charm/charmcraft.yaml b/charm/charmcraft.yaml new file mode 100644 index 0000000..f8059ad --- /dev/null +++ b/charm/charmcraft.yaml @@ -0,0 +1,85 @@ +# This file configures Charmcraft. +# See https://juju.is/docs/sdk/charmcraft-config for guidance. + +# (Required) +# The charm package name, no spaces +# See https://juju.is/docs/sdk/naming#heading--naming-charms for guidance. +name: charm + + +# (Required) +# The charm type, either 'charm' or 'bundle'. +type: charm + + +# (Recommended) +title: Charm Template + + +# (Required) +summary: A very short one-line summary of the charm. + + +# (Required) +description: | + A single sentence that says what the charm is, concisely and memorably. + + A paragraph of one to three short sentences, that describe what the charm does. + + A third paragraph that explains what need the charm meets. + + Finally, a paragraph that describes whom the charm is useful for. + + +# (Required for 'charm' type) +# A list of environments (OS version and architecture) where charms must be +# built on and run on. +bases: + - build-on: + - name: ubuntu + channel: "22.04" + run-on: + - name: ubuntu + channel: "22.04" + + +# (Optional) Configuration options for the charm +# This config section defines charm config options, and populates the Configure +# tab on Charmhub. +# More information on this section at https://juju.is/docs/sdk/charmcraft-yaml#heading--config +# General configuration documentation: https://juju.is/docs/sdk/config +config: + options: + # An example config option to customise the log level of the workload + log-level: + description: | + Configures the log level of gunicorn. + + Acceptable values are: "info", "debug", "warning", "error" and "critical" + default: "info" + type: string + + +# The containers and resources metadata apply to Kubernetes charms only. +# See https://juju.is/docs/sdk/metadata-reference for a checklist and guidance. +# Remove them if not required. + + +# Your workload’s containers. +containers: + httpbin: + resource: httpbin-image + + +# This field populates the Resources tab on Charmhub. +resources: + # An OCI image resource for each container listed above. + # You may remove this if your charm will run without a workload sidecar container. + httpbin-image: + type: oci-image + description: OCI image for httpbin + # The upstream-source field is ignored by Juju. It is included here as a + # reference so the integration testing suite knows which image to deploy + # during testing. This field is also used by the 'canonical/charming-actions' + # Github action for automated releasing. + upstream-source: kennethreitz/httpbin diff --git a/charm/pyproject.toml b/charm/pyproject.toml new file mode 100644 index 0000000..ceeab13 --- /dev/null +++ b/charm/pyproject.toml @@ -0,0 +1,40 @@ +# Testing tools configuration +[tool.coverage.run] +branch = true + +[tool.coverage.report] +show_missing = true + +[tool.pytest.ini_options] +minversion = "6.0" +log_cli_level = "INFO" + +# Linting tools configuration +[tool.ruff] +line-length = 99 +lint.select = ["E", "W", "F", "C", "N", "D", "I001"] +lint.extend-ignore = [ + "D203", + "D204", + "D213", + "D215", + "D400", + "D404", + "D406", + "D407", + "D408", + "D409", + "D413", +] +lint.ignore = ["E501", "D107"] +extend-exclude = ["__pycache__", "*.egg_info"] +lint.per-file-ignores = {"tests/*" = ["D100","D101","D102","D103","D104"]} + +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.codespell] +skip = "build,lib,venv,icon.svg,.tox,.git,.mypy_cache,.ruff_cache,.coverage" + +[tool.pyright] +include = ["src/**.py"] diff --git a/charm/requirements.txt b/charm/requirements.txt new file mode 100644 index 0000000..b00d7bc --- /dev/null +++ b/charm/requirements.txt @@ -0,0 +1 @@ +ops ~= 2.8 diff --git a/charm/spread.yaml b/charm/spread.yaml new file mode 100644 index 0000000..ee94979 --- /dev/null +++ b/charm/spread.yaml @@ -0,0 +1,152 @@ +project: charm-k8s-tests + +environment: + PROVIDER: microk8s + CHARMCRAFT_CHANNEL: latest/stable + JUJU_CHANNEL: 3/stable + LXD_CHANNEL: latest/stable + MICROK8S_CHANNEL: 1.28-strict/stable + MICROK8S_ADDONS: hostpath-storage + + JUJU_BOOTSTRAP_OPTIONS: --model-default test-mode=true --model-default automatically-retry-hooks=false --model-default + JUJU_EXTRA_BOOTSTRAP_OPTIONS: "" + JUJU_BOOTSTRAP_CONSTRAINTS: "" + + # important to ensure adhoc and linode/qemu behave the same + SUDO_USER: "" + SUDO_UID: "" + + LANG: "C.UTF-8" + LANGUAGE: "en" + + PROJECT_PATH: /home/spread/proj + CRAFT_TEST_LIB_PATH: /home/spread/proj/tests/spread/lib + +backends: + multipass: + type: adhoc + allocate: | + # Mitigate issues found when launching multiple mutipass instances + # concurrently. See https://github.com/canonical/multipass/issues/3336 + sleep 0.$RANDOM + sleep 0.$RANDOM + sleep 0.$RANDOM + + mkdir -p "$HOME/.spread" + export counter_file="$HOME/.spread/multipass-count" + + # Sequential variable for unique instance names + instance_num=$(flock -x $counter_file bash -c ' + [ -s $counter_file ] || echo 0 > $counter_file + num=$(< $counter_file) + echo $num + echo $(( $num + 1 )) > $counter_file') + + multipass_image=$(echo "${SPREAD_SYSTEM}" | sed -e s/ubuntu-// -e s/-64//) + + system=$(echo "${SPREAD_SYSTEM}" | tr . -) + instance_name="spread-${SPREAD_BACKEND}-${instance_num}-${system}" + + multipass launch -vv --cpus 2 --disk 20G --memory 4G --name "${instance_name}" \ + --cloud-init tests/spread/lib/cloud-config.yaml "${multipass_image}" + + # Get the IP from the instance + ip=$(multipass info --format csv "$instance_name" | tail -1 | cut -d\, -f3) + ADDRESS "$ip" + + discard: | + instance_name=$(multipass list --format csv | grep $SPREAD_SYSTEM_ADDRESS | cut -f1 -d\,) + multipass delete --purge "${instance_name}" + + systems: + - ubuntu-22.04: + username: spread + password: spread + workers: 1 + + - ubuntu-20.04: + username: spread + password: spread + workers: 1 + + github-ci: + type: adhoc + + allocate: | + echo "Allocating ad-hoc $SPREAD_SYSTEM" + if [ -z "${GITHUB_RUN_ID:-}" ]; then + FATAL "this back-end only works inside GitHub CI" + exit 1 + fi + echo 'ubuntu ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/99-spread-users + ADDRESS localhost:22 + + discard: | + echo "Discarding ad-hoc $SPREAD_SYSTEM" + + systems: + - ubuntu-22.04-amd64: + username: ubuntu + password: ubuntu + workers: 1 + + +suites: + tests/spread/general/: + summary: Charm functionality tests + + systems: + - ubuntu-22.04* + + environment: + CHARMCRAFT_CHANNEL/charmcraft_current: latest/stable + # CHARMCRAFT_CHANNEL/charmcraft_next: latest/candidate + + prepare: | + set -e + . "$CRAFT_TEST_LIB_PATH"/test-helpers.sh + apt update -y + apt install -y python3-pip + pip3 install tox + + install_lxd + install_charmcraft + install_juju + install_microk8s + + bootstrap_juju + + juju add-model testing + + restore: | + set -e + . "$CRAFT_TEST_LIB_PATH"/test-helpers.sh + rm -f "$PROJECT_PATH"/*.charm + charmcraft clean -p "$PROJECT_PATH" + + restore_juju + restore_charmcraft + restore_microk8s + restore_lxd + +exclude: + - .git + - .tox + +path: /home/spread/proj + +prepare: | + snap refresh --hold + + if systemctl is-enabled unattended-upgrades.service; then + systemctl stop unattended-upgrades.service + systemctl mask unattended-upgrades.service + fi + +restore: | + apt autoremove -y --purge + rm -Rf "$PROJECT_PATH" + mkdir -p "$PROJECT_PATH" + + +kill-timeout: 1h diff --git a/charm/src/charm.py b/charm/src/charm.py new file mode 100755 index 0000000..45ed5fa --- /dev/null +++ b/charm/src/charm.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# Copyright 2024 Marcelo Henrique Neppel +# See LICENSE file for licensing details. +# +# Learn more at: https://juju.is/docs/sdk + +"""Charm the service. + +Refer to the following tutorial that will help you +develop a new k8s charm using the Operator Framework: + +https://juju.is/docs/sdk/create-a-minimal-kubernetes-charm +""" + +import logging +from typing import cast + +import ops + +# Log messages can be retrieved using juju debug-log +logger = logging.getLogger(__name__) + +VALID_LOG_LEVELS = ["info", "debug", "warning", "error", "critical"] + + +class CharmCharm(ops.CharmBase): + """Charm the service.""" + + def __init__(self, framework: ops.Framework): + super().__init__(framework) + framework.observe(self.on["httpbin"].pebble_ready, self._on_httpbin_pebble_ready) + framework.observe(self.on.config_changed, self._on_config_changed) + + def _on_httpbin_pebble_ready(self, event: ops.PebbleReadyEvent): + """Define and start a workload using the Pebble API. + + Change this example to suit your needs. You'll need to specify the right entrypoint and + environment configuration for your specific workload. + + Learn more about interacting with Pebble at at https://juju.is/docs/sdk/pebble. + """ + # Get a reference the container attribute on the PebbleReadyEvent + container = event.workload + # Add initial Pebble config layer using the Pebble API + container.add_layer("httpbin", self._pebble_layer, combine=True) + # Make Pebble reevaluate its plan, ensuring any services are started if enabled. + container.replan() + # Learn more about statuses in the SDK docs: + # https://juju.is/docs/sdk/constructs#heading--statuses + self.unit.status = ops.ActiveStatus() + + def _on_config_changed(self, event: ops.ConfigChangedEvent): + """Handle changed configuration. + + Change this example to suit your needs. If you don't need to handle config, you can remove + this method. + + Learn more about config at https://juju.is/docs/sdk/config + """ + # Fetch the new config value + log_level = cast(str, self.model.config["log-level"]).lower() + + # Do some validation of the configuration option + if log_level in VALID_LOG_LEVELS: + # The config is good, so update the configuration of the workload + container = self.unit.get_container("httpbin") + # Verify that we can connect to the Pebble API in the workload container + if container.can_connect(): + # Push an updated layer with the new config + container.add_layer("httpbin", self._pebble_layer, combine=True) + container.replan() + + logger.debug("Log level for gunicorn changed to '%s'", log_level) + self.unit.status = ops.ActiveStatus() + else: + # We were unable to connect to the Pebble API, so we defer this event + event.defer() + self.unit.status = ops.WaitingStatus("waiting for Pebble API") + else: + # In this case, the config option is bad, so block the charm and notify the operator. + self.unit.status = ops.BlockedStatus("invalid log level: '{log_level}'") + + @property + def _pebble_layer(self) -> ops.pebble.LayerDict: + """Return a dictionary representing a Pebble layer.""" + return { + "summary": "httpbin layer", + "description": "pebble config layer for httpbin", + "services": { + "httpbin": { + "override": "replace", + "summary": "httpbin", + "command": "gunicorn -b 0.0.0.0:80 httpbin:app -k gevent", + "startup": "enabled", + "environment": { + "GUNICORN_CMD_ARGS": f"--log-level {self.model.config['log-level']}" + }, + } + }, + } + + +if __name__ == "__main__": # pragma: nocover + ops.main(CharmCharm) # type: ignore diff --git a/charm/tests/integration/test_charm.py b/charm/tests/integration/test_charm.py new file mode 100644 index 0000000..6f4c70f --- /dev/null +++ b/charm/tests/integration/test_charm.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# Copyright 2024 Marcelo Henrique Neppel +# See LICENSE file for licensing details. + +import asyncio +import logging +from pathlib import Path + +import pytest +import yaml +from pytest_operator.plugin import OpsTest + +logger = logging.getLogger(__name__) + +METADATA = yaml.safe_load(Path("./charmcraft.yaml").read_text()) +APP_NAME = METADATA["name"] + + +@pytest.mark.abort_on_fail +async def test_build_and_deploy(ops_test: OpsTest): + """Build the charm-under-test and deploy it together with related charms. + + Assert on the unit status before any relations/configurations take place. + """ + # Build and deploy charm from local source folder + charm = await ops_test.build_charm(".") + resources = {"httpbin-image": METADATA["resources"]["httpbin-image"]["upstream-source"]} + + # Deploy the charm and wait for active/idle status + await asyncio.gather( + ops_test.model.deploy(charm, resources=resources, application_name=APP_NAME), + ops_test.model.wait_for_idle( + apps=[APP_NAME], status="active", raise_on_blocked=True, timeout=1000 + ), + ) diff --git a/charm/tests/spread/general/integration/task.yaml b/charm/tests/spread/general/integration/task.yaml new file mode 100644 index 0000000..8440186 --- /dev/null +++ b/charm/tests/spread/general/integration/task.yaml @@ -0,0 +1,4 @@ +summary: Run the charm integration test + +execute: | + tox -e integration diff --git a/charm/tests/spread/lib/cloud-config.yaml b/charm/tests/spread/lib/cloud-config.yaml new file mode 100644 index 0000000..29618c6 --- /dev/null +++ b/charm/tests/spread/lib/cloud-config.yaml @@ -0,0 +1,10 @@ +#cloud-config + +ssh_pwauth: true + +users: + - default + - name: spread + plain_text_passwd: spread + lock_passwd: false + sudo: ALL=(ALL) NOPASSWD:ALL diff --git a/charm/tests/spread/lib/test-helpers.sh b/charm/tests/spread/lib/test-helpers.sh new file mode 100644 index 0000000..544ac30 --- /dev/null +++ b/charm/tests/spread/lib/test-helpers.sh @@ -0,0 +1,86 @@ + +export PATH=/snap/bin:$PROJECT_PATH/tests/spread/lib/tools:$PATH +export CONTROLLER_NAME="craft-test-$PROVIDER" + + +install_lxd() { + snap install lxd --channel "$LXD_CHANNEL" + snap refresh lxd --channel "$LXD_CHANNEL" + lxd waitready + lxd init --auto + chmod a+wr /var/snap/lxd/common/lxd/unix.socket + lxc network set lxdbr0 ipv6.address none + usermod -a -G lxd "$USER" + + # Work-around clash between docker and lxd on jammy + # https://github.com/docker/for-linux/issues/1034 + iptables -F FORWARD + iptables -P FORWARD ACCEPT +} + + +install_microk8s() { + snap install microk8s --channel "$MICROK8S_CHANNEL" + snap refresh microk8s --channel "$MICROK8S_CHANNEL" + microk8s status --wait-ready + + if [ ! -z "$MICROK8S_ADDONS" ]; then + microk8s enable $MICROK8S_ADDONS + fi + + local version=$(snap list microk8s | grep microk8s | awk '{ print $2 }') + + # workarounds for https://bugs.launchpad.net/juju/+bug/1937282 + retry microk8s kubectl -n kube-system rollout status deployment/coredns + retry microk8s kubectl -n kube-system rollout status deployment/hostpath-provisioner + + retry microk8s kubectl auth can-i create pods +} + + +install_charmcraft() { + snap install charmcraft --classic --channel "$CHARMCRAFT_CHANNEL" + snap refresh charmcraft --classic --channel "$CHARMCRAFT_CHANNEL" +} + + +install_juju() { + snap install juju --classic --channel "$JUJU_CHANNEL" + snap refresh juju --classic --channel "$JUJU_CHANNEL" + mkdir -p "$HOME"/.local/share/juju + snap install juju-crashdump --classic +} + + +bootstrap_juju() { + juju bootstrap --verbose "$PROVIDER" "$CONTROLLER_NAME" \ + $JUJU_BOOTSTRAP_OPTIONS $JUJU_EXTRA_BOOTSTRAP_OPTIONS \ + --bootstrap-constraints=$JUJU_BOOTSTRAP_CONSTRAINTS +} + + +restore_charmcraft() { + snap remove --purge charmcraft +} + + +restore_lxd() { + snap stop lxd + snap remove --purge lxd +} + + +restore_microk8s() { + snap stop microk8s + snap remove --purge microk8s +} + + +restore_juju() { + juju controllers --refresh ||: + juju destroy-controller -v --no-prompt --show-log \ + --destroy-storage --destroy-all-models "$CONTROLLER_NAME" + snap stop juju + snap remove --purge juju + snap remove --purge juju-crashdump +} diff --git a/charm/tests/spread/lib/tools/retry b/charm/tests/spread/lib/tools/retry new file mode 100755 index 0000000..233ad37 --- /dev/null +++ b/charm/tests/spread/lib/tools/retry @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import itertools +import os +import subprocess +import sys +import time + + +def envpair(s: str) -> str: + if not "=" in s: + raise argparse.ArgumentTypeError( + "environment variables expected format is 'KEY=VAL' got '{}'".format(s) + ) + return s + + +def _make_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=""" +Retry executes COMMAND at most N times, waiting for SECONDS between each +attempt. On failure the exit code from the final attempt is returned. +""" + ) + parser.add_argument( + "-n", + "--attempts", + metavar="N", + type=int, + default=10, + help="number of attempts (default %(default)s)", + ) + parser.add_argument( + "--wait", + metavar="SECONDS", + type=float, + default=5, + help="grace period between attempts (default %(default)ss)", + ) + parser.add_argument( + "--env", + type=envpair, + metavar="KEY=VAL", + action="append", + default=[], + help="environment variable to use with format KEY=VALUE (no default)", + ) + parser.add_argument( + "--maxmins", + metavar="MINUTES", + type=float, + default=0, + help="number of minutes after which to give up (no default, if set attempts is ignored)", + ) + parser.add_argument( + "--expect-rc", + metavar="RETCODE", + type=int, + default=0, + help="the expected return code to consider the command execution successful (default 0)", + ) + parser.add_argument( + "--quiet", + dest="verbose", + action="store_false", + default=True, + help="refrain from printing any output", + ) + parser.add_argument("cmd", metavar="COMMAND", nargs="...", help="command to execute") + return parser + + +def get_env(env: list[str]) -> dict[str, str]: + new_env = os.environ.copy() + maxsplit = 1 # no keyword support for str.split() in py2 + for key, val in [s.split("=", maxsplit) for s in env]: + new_env[key] = val + return new_env + + +def run_cmd( + cmd: list[str], + n: int, + wait: float, + maxmins: float, + verbose: bool, + env: list[str], + expect_rc: bool, +) -> int: + if maxmins != 0: + attempts = itertools.count(1) + t0 = time.time() + after = "{} minutes".format(maxmins) + of_attempts_suffix = "" + else: + attempts = range(1, n + 1) + after = "{} attempts".format(n) + of_attempts_suffix = " of {}".format(n) + retcode = 0 + i = 0 + new_env = get_env(env) + for i in attempts: + retcode = subprocess.call(cmd, env=new_env) + if retcode == expect_rc: + return 0 + if verbose: + print( + f"retry: command {' '.join(cmd)} unexpected code {retcode}", + file=sys.stderr, + ) + if maxmins != 0: + elapsed = (time.time() - t0) / 60 + if elapsed > maxmins: + break + if i < n or maxmins != 0: + if verbose: + print( + f"retry: next attempt in {wait} second(s) (attempt {i}{of_attempts_suffix})", + file=sys.stderr, + ) + time.sleep(wait) + + if verbose and i > 1: + print( + f"retry: command {' '.join(cmd)} keeps failing after {after}", + file=sys.stderr, + ) + return retcode + + +def main() -> None: + parser = _make_parser() + ns = parser.parse_args() + # The command cannot be empty but it is difficult to express in argparse itself. + if len(ns.cmd) == 0: + parser.print_usage() + parser.exit(0) + # Return the last exit code as the exit code of this process. + try: + retcode = run_cmd( + ns.cmd, ns.attempts, ns.wait, ns.maxmins, ns.verbose, ns.env, ns.expect_rc + ) + except OSError as exc: + if ns.verbose: + print( + "retry: cannot execute command {}: {}".format(" ".join(ns.cmd), exc), + file=sys.stderr, + ) + raise SystemExit(1) + else: + raise SystemExit(retcode) + + +if __name__ == "__main__": + main() diff --git a/charm/tests/unit/test_charm.py b/charm/tests/unit/test_charm.py new file mode 100644 index 0000000..cced9d6 --- /dev/null +++ b/charm/tests/unit/test_charm.py @@ -0,0 +1,72 @@ +# Copyright 2024 Marcelo Henrique Neppel +# See LICENSE file for licensing details. +# +# Learn more about testing at: https://juju.is/docs/sdk/testing + +import ops +import ops.testing +import pytest +from charm import CharmCharm + + +@pytest.fixture +def harness(): + harness = ops.testing.Harness(CharmCharm) + harness.begin() + yield harness + harness.cleanup() + + +def test_httpbin_pebble_ready(harness: ops.testing.Harness[CharmCharm]): + # Expected plan after Pebble ready with default config + expected_plan = { + "services": { + "httpbin": { + "override": "replace", + "summary": "httpbin", + "command": "gunicorn -b 0.0.0.0:80 httpbin:app -k gevent", + "startup": "enabled", + "environment": {"GUNICORN_CMD_ARGS": "--log-level info"}, + } + }, + } + # Simulate the container coming up and emission of pebble-ready event + harness.container_pebble_ready("httpbin") + # Get the plan now we've run PebbleReady + updated_plan = harness.get_container_pebble_plan("httpbin").to_dict() + # Check we've got the plan we expected + assert expected_plan == updated_plan + # Check the service was started + service = harness.model.unit.get_container("httpbin").get_service("httpbin") + assert service.is_running() + # Ensure we set an ActiveStatus with no message + assert harness.model.unit.status == ops.ActiveStatus() + + +def test_config_changed_valid_can_connect(harness: ops.testing.Harness[CharmCharm]): + # Ensure the simulated Pebble API is reachable + harness.set_can_connect("httpbin", True) + # Trigger a config-changed event with an updated value + harness.update_config({"log-level": "debug"}) + # Get the plan now we've run PebbleReady + updated_plan = harness.get_container_pebble_plan("httpbin").to_dict() + updated_env = updated_plan["services"]["httpbin"]["environment"] + # Check the config change was effective + assert updated_env == {"GUNICORN_CMD_ARGS": "--log-level debug"} + assert harness.model.unit.status == ops.ActiveStatus() + + +def test_config_changed_valid_cannot_connect(harness: ops.testing.Harness[CharmCharm]): + # Trigger a config-changed event with an updated value + harness.update_config({"log-level": "debug"}) + # Check the charm is in WaitingStatus + assert isinstance(harness.model.unit.status, ops.WaitingStatus) + + +def test_config_changed_invalid(harness: ops.testing.Harness[CharmCharm]): + # Ensure the simulated Pebble API is reachable + harness.set_can_connect("httpbin", True) + # Trigger a config-changed event with an updated value + harness.update_config({"log-level": "foobar"}) + # Check the charm is in BlockedStatus + assert isinstance(harness.model.unit.status, ops.BlockedStatus) diff --git a/charm/tox.ini b/charm/tox.ini new file mode 100644 index 0000000..1307dfa --- /dev/null +++ b/charm/tox.ini @@ -0,0 +1,84 @@ +# Copyright 2024 Marcelo Henrique Neppel +# See LICENSE file for licensing details. + +[tox] +no_package = True +skip_missing_interpreters = True +env_list = format, lint, static, unit +min_version = 4.0.0 + +[vars] +src_path = {tox_root}/src +tests_path = {tox_root}/tests +;lib_path = {tox_root}/lib/charms/operator_name_with_underscores +all_path = {[vars]src_path} {[vars]tests_path} + +[testenv] +set_env = + PYTHONPATH = {tox_root}/lib:{[vars]src_path} + PYTHONBREAKPOINT=pdb.set_trace + PY_COLORS=1 +pass_env = + PYTHONPATH + CHARM_BUILD_DIR + MODEL_SETTINGS + +[testenv:format] +description = Apply coding style standards to code +deps = + ruff +commands = + ruff format {[vars]all_path} + ruff check --fix {[vars]all_path} + +[testenv:lint] +description = Check code against coding style standards +deps = + ruff + codespell +commands = + # if this charm owns a lib, uncomment "lib_path" variable + # and uncomment the following line + # codespell {[vars]lib_path} + codespell {tox_root} + ruff check {[vars]all_path} + ruff format --check --diff {[vars]all_path} + +[testenv:unit] +description = Run unit tests +deps = + pytest + coverage[toml] + -r {tox_root}/requirements.txt +commands = + coverage run --source={[vars]src_path} \ + -m pytest \ + --tb native \ + -v \ + -s \ + {posargs} \ + {[vars]tests_path}/unit + coverage report + +[testenv:static] +description = Run static type checks +deps = + pyright + -r {tox_root}/requirements.txt +commands = + pyright {posargs} + +[testenv:integration] +description = Run integration tests +deps = + pytest + juju + pytest-operator + -r {tox_root}/requirements.txt +commands = + pytest -v \ + -s \ + --tb native \ + --log-cli-level=INFO \ + {posargs} \ + {[vars]tests_path}/integration diff --git a/src/main.rs b/src/main.rs index a05502e..6399f61 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,12 @@ use rustpython_parser::{ast, Parse}; +use std::fs; fn main() { - let python_source = "print('Hello world')"; - let python_statements = ast::Suite::parse(python_source, "").unwrap(); - let python_expr = ast::Expr::parse(python_source, "").unwrap(); + // let python_source = "print('Hello world')"; + let python_source = + fs::read_to_string("charm/src/charm.py").expect("Should have been able to read the file"); + // println!("{}", python_source); + let python_statements = ast::Suite::parse(&python_source, "").unwrap(); + // let python_expr = ast::Expr::parse(&python_source, "").unwrap(); println!("{:?}", python_statements); - println!("{:?}", python_expr); + // println!("{:?}", python_expr); }