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..6f01414 --- /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 = 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) -> 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