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