From 5d0bd0a445274e95faffd6886c6c75829004a599 Mon Sep 17 00:00:00 2001 From: Mariyan Dimitrov Date: Fri, 7 Jun 2024 11:18:49 +0300 Subject: [PATCH] Initial commit --- .github/ISSUE_TEMPLATE/bug_report.yml | 57 +++++ .../ISSUE_TEMPLATE/enhancement_proposal.yml | 17 ++ .github/pull_request_template.yaml | 32 +++ .github/workflows/auto_update_libs.yaml | 10 + .github/workflows/bot_pr_approval.yaml | 10 + .github/workflows/comment.yaml | 12 ++ .github/workflows/integration_test.yaml | 22 ++ .github/workflows/issues.yaml | 11 + .github/workflows/load_test.yaml | 13 ++ .github/workflows/promote_charm.yaml | 26 +++ .github/workflows/publish_charm.yaml | 14 ++ .github/workflows/test.yaml | 12 ++ .gitignore | 12 ++ .jujuignore | 4 + .licenserc.yaml | 23 ++ CODEOWNERS | 1 + CONTRIBUTING.md | 42 ++++ LICENSE | 202 ++++++++++++++++++ README.md | 26 +++ charmcraft.yaml | 13 ++ config.yaml | 16 ++ generate-src-docs.sh | 6 + metadata.yaml | 50 +++++ pyproject.toml | 79 +++++++ renovate.json | 32 +++ requirements.txt | 1 + src/charm.py | 117 ++++++++++ tests/conftest.py | 13 ++ tests/integration/__init__.py | 2 + tests/integration/test_charm.py | 39 ++++ tests/unit/__init__.py | 2 + tests/unit/test_base.py | 75 +++++++ tox.ini | 122 +++++++++++ trivy.yaml | 3 + zap_rules.tsv | 0 35 files changed, 1116 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/enhancement_proposal.yml create mode 100644 .github/pull_request_template.yaml create mode 100644 .github/workflows/auto_update_libs.yaml create mode 100644 .github/workflows/bot_pr_approval.yaml create mode 100644 .github/workflows/comment.yaml create mode 100644 .github/workflows/integration_test.yaml create mode 100644 .github/workflows/issues.yaml create mode 100644 .github/workflows/load_test.yaml create mode 100644 .github/workflows/promote_charm.yaml create mode 100644 .github/workflows/publish_charm.yaml create mode 100644 .github/workflows/test.yaml create mode 100644 .gitignore create mode 100644 .jujuignore create mode 100644 .licenserc.yaml create mode 100644 CODEOWNERS create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 charmcraft.yaml create mode 100644 config.yaml create mode 100644 generate-src-docs.sh create mode 100644 metadata.yaml create mode 100644 pyproject.toml create mode 100644 renovate.json create mode 100644 requirements.txt create mode 100755 src/charm.py create mode 100644 tests/conftest.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_charm.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_base.py create mode 100644 tox.ini create mode 100644 trivy.yaml create mode 100644 zap_rules.tsv diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..466be6c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,57 @@ +name: Bug Report +description: File a bug report +labels: ["Type: Bug", "Status: Triage"] +body: + - type: markdown + attributes: + value: > + Thanks for taking the time to fill out this bug report! Before submitting your issue, please make + sure you are using the latest version of the charm. If not, please switch to this image prior to + posting your report to make sure it's not already solved. + - type: textarea + id: bug-description + attributes: + label: Bug Description + description: > + If applicable, add screenshots to help explain the problem you are facing. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: To Reproduce + description: > + Please provide a step-by-step instruction of how to reproduce the behavior. + placeholder: | + 1. `juju deploy ...` + 2. `juju relate ...` + 3. `juju status --relations` + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: > + We need to know a bit more about the context in which you run the charm. + - Are you running Juju locally, on lxd, in multipass or on some other platform? + - What track and channel you deployed the charm from (i.e. `latest/edge` or similar). + - Version of any applicable components, like the juju snap, the model controller, lxd, microk8s, and/or multipass. + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant log output + description: > + Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + Fetch the logs using `juju debug-log --replay` and `kubectl logs ...`. Additional details available in the juju docs + at https://juju.is/docs/olm/juju-logs + render: shell + validations: + required: true + - type: textarea + id: additional-context + attributes: + label: Additional context + diff --git a/.github/ISSUE_TEMPLATE/enhancement_proposal.yml b/.github/ISSUE_TEMPLATE/enhancement_proposal.yml new file mode 100644 index 0000000..b2348b9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/enhancement_proposal.yml @@ -0,0 +1,17 @@ +name: Enhancement Proposal +description: File an enhancement proposal +labels: ["Type: Enhancement", "Status: Triage"] +body: + - type: markdown + attributes: + value: > + Thanks for taking the time to fill out this enhancement proposal! Before submitting your issue, please make + sure there isn't already a prior issue concerning this. If there is, please join that discussion instead. + - type: textarea + id: enhancement-proposal + attributes: + label: Enhancement Proposal + description: > + Describe the enhancement you would like to see in as much detail as needed. + validations: + required: true diff --git a/.github/pull_request_template.yaml b/.github/pull_request_template.yaml new file mode 100644 index 0000000..5ce31d9 --- /dev/null +++ b/.github/pull_request_template.yaml @@ -0,0 +1,32 @@ +Applicable spec: + +### Overview + + + +### Rationale + + + +### Juju Events Changes + + + +### Module Changes + + + +### Library Changes + + + +### Checklist + +- [ ] The [charm style guide](https://juju.is/docs/sdk/styleguide) was applied +- [ ] The [contributing guide](https://github.com/canonical/is-charms-contributing-guide) was applied +- [ ] The changes are compliant with [ISD054 - Managing Charm Complexity](https://discourse.charmhub.io/t/specification-isd014-managing-charm-complexity/11619) +- [ ] The documentation is generated using `src-docs` +- [ ] The documentation for charmhub is updated +- [ ] The PR is tagged with appropriate label (`urgent`, `trivial`, `complex`) + + diff --git a/.github/workflows/auto_update_libs.yaml b/.github/workflows/auto_update_libs.yaml new file mode 100644 index 0000000..02b7b20 --- /dev/null +++ b/.github/workflows/auto_update_libs.yaml @@ -0,0 +1,10 @@ +name: Auto-update charm libraries + +on: + schedule: + - cron: "0 1 * * *" + +jobs: + auto-update-libs: + uses: canonical/operator-workflows/.github/workflows/auto_update_charm_libs.yaml@main + secrets: inherit diff --git a/.github/workflows/bot_pr_approval.yaml b/.github/workflows/bot_pr_approval.yaml new file mode 100644 index 0000000..f284fd7 --- /dev/null +++ b/.github/workflows/bot_pr_approval.yaml @@ -0,0 +1,10 @@ +name: Provide approval for bot PRs + +on: + pull_request: + +jobs: + bot_pr_approval: + uses: canonical/operator-workflows/.github/workflows/bot_pr_approval.yaml@main + secrets: inherit + diff --git a/.github/workflows/comment.yaml b/.github/workflows/comment.yaml new file mode 100644 index 0000000..26ac226 --- /dev/null +++ b/.github/workflows/comment.yaml @@ -0,0 +1,12 @@ +name: Comment on the pull request + +on: + workflow_run: + workflows: ["Tests"] + types: + - completed + +jobs: + comment-on-pr: + uses: canonical/operator-workflows/.github/workflows/comment.yaml@main + secrets: inherit diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml new file mode 100644 index 0000000..2f4fe63 --- /dev/null +++ b/.github/workflows/integration_test.yaml @@ -0,0 +1,22 @@ +name: Integration tests + +on: + pull_request: + +jobs: + integration-tests: + uses: canonical/operator-workflows/.github/workflows/integration_test.yaml@main + secrets: inherit + with: + load-test-enabled: false + load-test-run-args: "-e LOAD_TEST_HOST=localhost" + zap-before-command: "curl -H \"Host: indico.local\" http://localhost/bootstrap --data-raw 'csrf_token=00000000-0000-0000-0000-000000000000&first_name=admin&last_name=admin&email=admin%40admin.com&username=admin&password=lunarlobster&confirm_password=lunarlobster&affiliation=Canonical'" + zap-enabled: true + zap-cmd-options: '-T 60 -z "-addoninstall jython" --hook "/zap/wrk/tests/zap/hook.py"' + zap-target: localhost + zap-target-port: 80 + zap-rules-file-name: "zap_rules.tsv" + trivy-fs-enabled: true + trivy-image-config: "trivy.yaml" + self-hosted-runner: true + self-hosted-runner-label: "edge" diff --git a/.github/workflows/issues.yaml b/.github/workflows/issues.yaml new file mode 100644 index 0000000..138fe82 --- /dev/null +++ b/.github/workflows/issues.yaml @@ -0,0 +1,11 @@ +name: Sync issues to Jira + +on: + issues: + # available via github.event.action + types: [opened, reopened, closed] + +jobs: + issues-to-jira: + uses: canonical/operator-workflows/.github/workflows/jira.yaml@main + secrets: inherit diff --git a/.github/workflows/load_test.yaml b/.github/workflows/load_test.yaml new file mode 100644 index 0000000..de6f27f --- /dev/null +++ b/.github/workflows/load_test.yaml @@ -0,0 +1,13 @@ +name: Load tests + +on: + schedule: + - cron: "0 12 * * 0" + +jobs: + load-tests: + uses: canonical/operator-workflows/.github/workflows/integration_test.yaml@main + with: + load-test-enabled: true + load-test-run-args: "-e LOAD_TEST_HOST=localhost" + secrets: inherit diff --git a/.github/workflows/promote_charm.yaml b/.github/workflows/promote_charm.yaml new file mode 100644 index 0000000..66649de --- /dev/null +++ b/.github/workflows/promote_charm.yaml @@ -0,0 +1,26 @@ +name: Promote charm + +on: + workflow_dispatch: + inputs: + origin-channel: + type: choice + description: 'Origin Channel' + options: + - latest/edge + destination-channel: + type: choice + description: 'Destination Channel' + options: + - latest/stable + secrets: + CHARMHUB_TOKEN: + required: true + +jobs: + promote-charm: + uses: canonical/operator-workflows/.github/workflows/promote_charm.yaml@main + with: + origin-channel: ${{ github.event.inputs.origin-channel }} + destination-channel: ${{ github.event.inputs.destination-channel }} + secrets: inherit diff --git a/.github/workflows/publish_charm.yaml b/.github/workflows/publish_charm.yaml new file mode 100644 index 0000000..e14e332 --- /dev/null +++ b/.github/workflows/publish_charm.yaml @@ -0,0 +1,14 @@ +name: Publish to edge + +on: + workflow_dispatch: + push: + branches: + - main + +jobs: + publish-to-edge: + uses: canonical/operator-workflows/.github/workflows/publish_charm.yaml@main + secrets: inherit + with: + channel: latest/edge diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..bd1426c --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,12 @@ +name: Tests + +on: + pull_request: + +jobs: + unit-tests: + uses: canonical/operator-workflows/.github/workflows/test.yaml@main + secrets: inherit + with: + self-hosted-runner: true + self-hosted-runner-label: "edge" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8461f41 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +venv/ +build/ +*.charm +.tox/ +.coverage +__pycache__/ +*.py[cod] +.idea +.vscode +.mypy_cache +*.egg-info/ +*/*.rock diff --git a/.jujuignore b/.jujuignore new file mode 100644 index 0000000..65f4410 --- /dev/null +++ b/.jujuignore @@ -0,0 +1,4 @@ +/venv +*.py[cod] +*.charm +/.github diff --git a/.licenserc.yaml b/.licenserc.yaml new file mode 100644 index 0000000..ef7164e --- /dev/null +++ b/.licenserc.yaml @@ -0,0 +1,23 @@ +header: + license: + spdx-id: Apache-2.0 + copyright-owner: Canonical Ltd. + content: | + Copyright [year] [owner] + See LICENSE file for licensing details. + paths: + - '**' + paths-ignore: + - '.github/**' + - '**/*.json' + - '**/*.md' + - '**/*.txt' + - '.jujuignore' + - '.gitignore' + - '.licenserc.yaml' + - 'CODEOWNERS' + - 'LICENSE' + - 'trivy.yaml' + - 'pyproject.toml' + - 'zap_rules.tsv' + comment: on-failure diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..e3e8c01 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @canonical/is-charms \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..97dbb61 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,42 @@ +# 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 +``` + +## Generating src docs for every commit + +Run the following command: + +```bash +echo -e "tox -e src-docs\ngit add src-docs\n" >> .git/hooks/pre-commit +chmod +x .git/hooks/pre-commit +``` + +## 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 unit # unit tests +tox run -e integration # integration tests +tox # runs 'format', 'lint', and 'unit' environments +``` + +## Build the charm + +Build the charm in this git repository using: + +```shell +charmcraft pack +``` + + + +# is-charms-template + +Charmhub package name: operator-template +More information: https://charmhub.io/is-charms-template + +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/charmcraft.yaml b/charmcraft.yaml new file mode 100644 index 0000000..df6fdcb --- /dev/null +++ b/charmcraft.yaml @@ -0,0 +1,13 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. +# This file configures Charmcraft. +# See https://juju.is/docs/sdk/charmcraft-config for guidance. + +type: charm +bases: + - build-on: + - name: ubuntu + channel: "22.04" + run-on: + - name: ubuntu + channel: "22.04" diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..dd4dcdd --- /dev/null +++ b/config.yaml @@ -0,0 +1,16 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. +# This file defines charm config options, and populates the Configure tab on Charmhub. +# If your charm does not require configuration options, delete this file entirely. +# +# See https://juju.is/docs/config for guidance. + +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 diff --git a/generate-src-docs.sh b/generate-src-docs.sh new file mode 100644 index 0000000..d13066a --- /dev/null +++ b/generate-src-docs.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +lazydocs --no-watermark --output-path src-docs src/* diff --git a/metadata.yaml b/metadata.yaml new file mode 100644 index 0000000..19585a7 --- /dev/null +++ b/metadata.yaml @@ -0,0 +1,50 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. +# This file populates the Overview on Charmhub. +# See https://juju.is/docs/sdk/metadata-reference for a checklist and guidance. + +# The charm package name, no spaces (required) +# See https://juju.is/docs/sdk/naming#heading--naming-charms for guidance. +name: is-charms-template + +# The following metadata are human-readable and will be published prominently on Charmhub. + +# (Recommended) +display-name: Charm Template + +# (Required) +summary: A very short one-line summary of the charm. +docs: https://discourse.charmhub.io +issues: https://github.com/canonical/is-charms-template-repo/issues +maintainers: + - https://launchpad.net/~canonical-is-devops +source: https://github.com/canonical/is-charms-template-repo + +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. + +# The containers and resources metadata apply to Kubernetes charms only. +# 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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0fce3f3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,79 @@ +[tool.bandit] +exclude_dirs = ["/venv/"] +[tool.bandit.assert_used] +skips = ["*/*test.py", "*/test_*.py", "*tests/*.py"] + +# Testing tools configuration +[tool.coverage.run] +branch = true + +# Formatting tools configuration +[tool.black] +line-length = 99 +target-version = ["py38"] + +[tool.coverage.report] +show_missing = true + +# Linting tools configuration +[tool.flake8] +max-line-length = 99 +max-doc-length = 99 +max-complexity = 10 +exclude = [".git", "__pycache__", ".tox", "build", "dist", "*.egg_info", "venv"] +select = ["E", "W", "F", "C", "N", "R", "D", "H"] +# Ignore W503, E501 because using black creates errors with this +# Ignore D107 Missing docstring in __init__ +ignore = ["W503", "E501", "D107"] +# D100, D101, D102, D103: Ignore missing docstrings in tests +per-file-ignores = ["tests/*:D100,D101,D102,D103,D104,D205,D212,D415"] +docstring-convention = "google" + +[tool.isort] +line_length = 99 +profile = "black" + +[tool.mypy] +check_untyped_defs = true +disallow_untyped_defs = true +explicit_package_bases = true +ignore_missing_imports = true +namespace_packages = true + +[[tool.mypy.overrides]] +disallow_untyped_defs = false +module = "tests.*" + +[tool.pylint] +disable = "wrong-import-order" + +[tool.pytest.ini_options] +minversion = "6.0" +log_cli_level = "INFO" + +# Linting tools configuration +[tool.ruff] +line-length = 99 +select = ["E", "W", "F", "C", "N", "D", "I001"] +extend-ignore = [ + "D203", + "D204", + "D213", + "D215", + "D400", + "D404", + "D406", + "D407", + "D408", + "D409", + "D413", +] +ignore = ["E501", "D107"] +extend-exclude = ["__pycache__", "*.egg_info"] +per-file-ignores = {"tests/*" = ["D100","D101","D102","D103","D104"]} + +[tool.ruff.mccabe] +max-complexity = 10 + +[tool.codespell] +skip = "build,lib,venv,icon.svg,.tox,.git,.mypy_cache,.ruff_cache,.coverage" diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..e6c587e --- /dev/null +++ b/renovate.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base" + ], + "regexManagers": [ + { + "fileMatch": ["(^|/)rockcraft.yaml$"], + "description": "Update base image references", + "matchStringsStrategy": "any", + "matchStrings": ["# renovate: build-base:\\s+(?[^:]*):(?[^\\s@]*)(@(?sha256:[0-9a-f]*))?", + "# renovate: base:\\s+(?[^:]*):(?[^\\s@]*)(@(?sha256:[0-9a-f]*))?"], + "datasourceTemplate": "docker", + "versioningTemplate": "ubuntu" + } + ], + "packageRules": [ + { + "enabled": true, + "matchDatasources": [ + "docker" + ], + "pinDigests": true + }, + { + "matchFiles": ["rockcraft.yaml"], + "matchUpdateTypes": ["major", "minor", "patch"], + "enabled": false + } + ] +} + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..aaa16b1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +ops >= 2.2.0 diff --git a/src/charm.py b/src/charm.py new file mode 100755 index 0000000..e9b21ed --- /dev/null +++ b/src/charm.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 + +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +# Learn more at: https://juju.is/docs/sdk + +"""Charm the service. + +Refer to the following post for a quick-start guide that will help you +develop a new k8s charm using the Operator Framework: + +https://discourse.charmhub.io/t/4208 +""" + +import logging +import typing + +import ops +from ops import pebble + +# Log messages can be retrieved using juju debug-log +logger = logging.getLogger(__name__) + +VALID_LOG_LEVELS = ["info", "debug", "warning", "error", "critical"] + + +class IsCharmsTemplateCharm(ops.CharmBase): + """Charm the service.""" + + def __init__(self, *args: typing.Any): + """Construct. + + Args: + args: Arguments passed to the CharmBase parent constructor. + """ + super().__init__(*args) + self.framework.observe(self.on.httpbin_pebble_ready, self._on_httpbin_pebble_ready) + self.framework.observe(self.on.config_changed, self._on_config_changed) + + def _on_httpbin_pebble_ready(self, event: ops.PebbleReadyEvent) -> None: + """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. + + Args: + event: event triggering the handler. + """ + # 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) -> None: + """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 + + Args: + event: event triggering the handler. + """ + # Fetch the new config value + log_level = 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) -> 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.main(IsCharmsTemplateCharm) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ad7716b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Fixtures for charm tests.""" + + +def pytest_addoption(parser): + """Parse additional pytest options. + + Args: + parser: Pytest parser. + """ + parser.addoption("--charm-file", action="store") diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e3979c0 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py new file mode 100644 index 0000000..f212ec1 --- /dev/null +++ b/tests/integration/test_charm.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 + +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Integration tests.""" + +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("./metadata.yaml").read_text(encoding="utf-8")) +APP_NAME = METADATA["name"] + + +@pytest.mark.abort_on_fail +async def test_build_and_deploy(ops_test: OpsTest, pytestconfig: pytest.Config): + """Deploy the charm together with related charms. + + Assert on the unit status before any relations/configurations take place. + """ + # Deploy the charm and wait for active/idle status + charm = pytestconfig.getoption("--charm-file") + resources = {"httpbin-image": METADATA["resources"]["httpbin-image"]["upstream-source"]} + assert ops_test.model + await asyncio.gather( + ops_test.model.deploy( + f"./{charm}", resources=resources, application_name=APP_NAME, series="jammy" + ), + ops_test.model.wait_for_idle( + apps=[APP_NAME], status="active", raise_on_blocked=True, timeout=1000 + ), + ) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e3979c0 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. diff --git a/tests/unit/test_base.py b/tests/unit/test_base.py new file mode 100644 index 0000000..c1ce697 --- /dev/null +++ b/tests/unit/test_base.py @@ -0,0 +1,75 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +# Learn more about testing at: https://juju.is/docs/sdk/testing + +# pylint: disable=duplicate-code,missing-function-docstring +"""Unit tests.""" + +import unittest + +import ops +import ops.testing + +from charm import IsCharmsTemplateCharm + + +class TestCharm(unittest.TestCase): + """Test class.""" + + def setUp(self): + """Set up the testing environment.""" + self.harness = ops.testing.Harness(IsCharmsTemplateCharm) + self.addCleanup(self.harness.cleanup) + self.harness.begin() + + def test_httpbin_pebble_ready(self): + # 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 + self.harness.container_pebble_ready("httpbin") + # Get the plan now we've run PebbleReady + updated_plan = self.harness.get_container_pebble_plan("httpbin").to_dict() + # Check we've got the plan we expected + self.assertEqual(expected_plan, updated_plan) + # Check the service was started + service = self.harness.model.unit.get_container("httpbin").get_service("httpbin") + self.assertTrue(service.is_running()) + # Ensure we set an ActiveStatus with no message + self.assertEqual(self.harness.model.unit.status, ops.ActiveStatus()) + + def test_config_changed_valid_can_connect(self): + # Ensure the simulated Pebble API is reachable + self.harness.set_can_connect("httpbin", True) + # Trigger a config-changed event with an updated value + self.harness.update_config({"log-level": "debug"}) + # Get the plan now we've run PebbleReady + updated_plan = self.harness.get_container_pebble_plan("httpbin").to_dict() + updated_env = updated_plan["services"]["httpbin"]["environment"] + # Check the config change was effective + self.assertEqual(updated_env, {"GUNICORN_CMD_ARGS": "--log-level debug"}) + self.assertEqual(self.harness.model.unit.status, ops.ActiveStatus()) + + def test_config_changed_valid_cannot_connect(self): + # Trigger a config-changed event with an updated value + self.harness.update_config({"log-level": "debug"}) + # Check the charm is in WaitingStatus + self.assertIsInstance(self.harness.model.unit.status, ops.WaitingStatus) + + def test_config_changed_invalid(self): + # Ensure the simulated Pebble API is reachable + self.harness.set_can_connect("httpbin", True) + # Trigger a config-changed event with an updated value + self.harness.update_config({"log-level": "foobar"}) + # Check the charm is in BlockedStatus + self.assertIsInstance(self.harness.model.unit.status, ops.BlockedStatus) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..202340f --- /dev/null +++ b/tox.ini @@ -0,0 +1,122 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +[tox] +skipsdist=True +skip_missing_interpreters = True +envlist = lint, unit, static, coverage-report + +[vars] +src_path = {toxinidir}/src/ +tst_path = {toxinidir}/tests/ +;lib_path = {toxinidir}/lib/charms/operator_name_with_underscores +all_path = {[vars]src_path} {[vars]tst_path} + +[testenv] +setenv = + PYTHONPATH = {toxinidir}:{toxinidir}/lib:{[vars]src_path} + PYTHONBREAKPOINT=ipdb.set_trace + PY_COLORS=1 +passenv = + PYTHONPATH + CHARM_BUILD_DIR + MODEL_SETTINGS + +[testenv:fmt] +description = Apply coding style standards to code +deps = + black + isort +commands = + isort {[vars]all_path} + black {[vars]all_path} + +[testenv:lint] +description = Check code against coding style standards +deps = + black + codespell + flake8<6.0.0 + flake8-builtins + flake8-copyright<6.0.0 + flake8-docstrings>=1.6.0 + flake8-docstrings-complete>=1.0.3 + flake8-test-docs>=1.0 + isort + mypy + pep8-naming + pydocstyle>=2.10 + pylint + pyproject-flake8<6.0.0 + pytest + pytest-asyncio + pytest-operator + requests + types-PyYAML + types-requests + -r{toxinidir}/requirements.txt +commands = + pydocstyle {[vars]src_path} + # uncomment the following line if this charm owns a lib + # codespell {[vars]lib_path} + codespell {toxinidir} --skip {toxinidir}/.git --skip {toxinidir}/.tox \ + --skip {toxinidir}/build --skip {toxinidir}/lib --skip {toxinidir}/venv \ + --skip {toxinidir}/.mypy_cache --skip {toxinidir}/icon.svg + # pflake8 wrapper supports config from pyproject.toml + pflake8 {[vars]all_path} --ignore=W503 + isort --check-only --diff {[vars]all_path} + black --check --diff {[vars]all_path} + mypy {[vars]all_path} + pylint {[vars]all_path} + +[testenv:unit] +description = Run unit tests +deps = + coverage[toml] + pytest + -r{toxinidir}/requirements.txt +commands = + coverage run --source={[vars]src_path} \ + -m pytest --ignore={[vars]tst_path}integration -v --tb native -s {posargs} + coverage report + +[testenv:coverage-report] +description = Create test coverage report +deps = + coverage[toml] + pytest + -r{toxinidir}/requirements.txt +commands = + coverage report + +[testenv:static] +description = Run static analysis tests +deps = + bandit[toml] + -r{toxinidir}/requirements.txt +commands = + bandit -c {toxinidir}/pyproject.toml -r {[vars]src_path} {[vars]tst_path} + +[testenv:integration] +description = Run integration tests +deps = + # Last compatible version with Juju 2.9 + juju==3.0.4 + pytest + pytest-asyncio + pytest-operator + -r{toxinidir}/requirements.txt +commands = + pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} + +[testenv:src-docs] +allowlist_externals=sh +setenv = + PYTHONPATH = {toxinidir}:{toxinidir}/lib:{[vars]src_path} +description = Generate documentation for src +deps = + lazydocs + -r{toxinidir}/requirements.txt +commands = + ; can't run lazydocs directly due to needing to run it on src/* which produces an invocation error in tox + sh generate-src-docs.sh diff --git a/trivy.yaml b/trivy.yaml new file mode 100644 index 0000000..c895d69 --- /dev/null +++ b/trivy.yaml @@ -0,0 +1,3 @@ +timeout: 20m +scan: + offline-scan: true diff --git a/zap_rules.tsv b/zap_rules.tsv new file mode 100644 index 0000000..e69de29