diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..cc3e5e9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,56 @@ +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 (ie. `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/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml new file mode 100644 index 0000000..ba40eec --- /dev/null +++ b/.github/workflows/integration_test.yaml @@ -0,0 +1,16 @@ +name: Integration tests + +on: + pull_request: + +jobs: + integration-tests: + uses: canonical/operator-workflows/.github/workflows/integration_test.yaml@main + secrets: inherit + with: + channel: 1.28-strict/stable + modules: '["test_charm.py"]' + juju-channel: 3.1/stable + self-hosted-runner: true + self-hosted-runner-label: "xlarge" + microk8s-addons: "dns ingress rbac storage metallb:10.15.119.2-10.15.119.4 registry" diff --git a/.github/workflows/promote_charm.yaml b/.github/workflows/promote_charm.yaml new file mode 100644 index 0000000..c738b2b --- /dev/null +++ b/.github/workflows/promote_charm.yaml @@ -0,0 +1,27 @@ +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 }} + doc-automation-disabled: true + secrets: inherit diff --git a/.github/workflows/publish_charm.yaml b/.github/workflows/publish_charm.yaml new file mode 100644 index 0000000..c6e1c3d --- /dev/null +++ b/.github/workflows/publish_charm.yaml @@ -0,0 +1,13 @@ +name: Publish to edge + +on: + push: + branches: + - main + +jobs: + test-and-publish-charm: + 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..b5b4406 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,9 @@ +name: Tests + +on: + pull_request: + +jobs: + unit-tests: + uses: canonical/operator-workflows/.github/workflows/test.yaml@cea2ac306b4f4c1475d73b1a4c766d62e5b1c8a9 + secrets: inherit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a26d707 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +venv/ +build/ +*.charm +.tox/ +.coverage +__pycache__/ +*.py[cod] +.idea +.vscode/ diff --git a/.licenserc.yaml b/.licenserc.yaml new file mode 100644 index 0000000..5aa32de --- /dev/null +++ b/.licenserc.yaml @@ -0,0 +1,37 @@ +header: + license: + spdx-id: Apache-2.0 + copyright-owner: Canonical Ltd. + copyright-year: 2024 + content: | + Copyright [year] [owner] + See LICENSE file for licensing details. + paths: + - '**' + paths-ignore: + - '.github/**' + - '**/.gitkeep' + - '**/*.cfg' + - '**/*.conf' + - '**/*.j2' + - '**/*.json' + - '**/*.md' + - '**/*.rule' + - '**/*.tmpl' + - '**/*.txt' + - '**/*.jinja' + - '.codespellignore' + - '.dockerignore' + - '.flake8' + - '.jujuignore' + - '.gitignore' + - '.licenserc.yaml' + - '.trivyignore' + - '.woke.yaml' + - '.woke.yml' + - 'CODEOWNERS' + - 'icon.svg' + - 'LICENSE' + - 'trivy.yaml' + - 'lib/**' + comment: on-failure diff --git a/.woke.yaml b/.woke.yaml new file mode 100644 index 0000000..ddc3772 --- /dev/null +++ b/.woke.yaml @@ -0,0 +1,7 @@ +ignore_files: + # Ignore ingress charm library as it uses non compliant terminology: + # whitelist. + - lib/charms/nginx_ingress_integrator/v0/nginx_route.py +rules: + # Ignore "master" - the database relation event received from the library. + - name: master diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..97b2583 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing + +To make contributions to this charm, you'll need a working +[development setup](https://juju.is/docs/sdk/dev-setup). + +You can create an environment for development with `tox`: + +```shell +tox devenv -e integration +source venv/bin/activate +``` + +## Testing + +This project uses `tox` for managing test environments. There are some +pre-configured environments that can be used for linting and formatting code +when you're preparing contributions to the charm: + +```shell +tox run -e format # update your code according to linting rules +tox run -e lint # code style +tox run -e static # static type checking +tox run -e unit # unit tests +tox run -e integration # integration tests +tox # runs 'format', 'lint', 'static', and 'unit' environments +``` + +### Deploy + +Please refer to the +[Airbyte server charm documentation](https://github.com/canonical/airbyte-k8s-operator/blob/main/CONTRIBUTING.md) +for instructions about how to deploy the web UI and relate it to the server. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..16bd1a7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Ali + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7494d1 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# Airbyte UI K8s Operator + +This is the Kubernetes Python Operator for the +[Airbyte web UI](https://airbyte.com/). + +## Description + +Airbyte is an open-source data integration platform designed to centralize and +streamline the process of extracting and loading data from various sources into +data warehouses, lakes, or other destinations. + +This operator provides the Airbyte web UI, and consists of Python scripts which +wraps the versions distributed by +[Airbyte](https://hub.docker.com/r/airbyte/webapp). + +## Usage + +Please check the [Airbyte server operator](https://charmhub.io/airbyte-k8s) for +usage instructions. + +## Contributing + +This charm is still in active development. Please see the +[Juju SDK docs](https://juju.is/docs/sdk) for guidelines on enhancements to this +charm following best practice guidelines, and `CONTRIBUTING.md` for developer +guidance. + +## License + +The Charmed Airbyte UI K8s Operator is free software, distributed under the +Apache Software License, version 2.0. See [License](LICENSE) for more details. diff --git a/actions.yaml b/actions.yaml new file mode 100644 index 0000000..990fcb7 --- /dev/null +++ b/actions.yaml @@ -0,0 +1,5 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +restart: + description: Restart the Airbyte Web UI. diff --git a/charmcraft.yaml b/charmcraft.yaml new file mode 100644 index 0000000..a74a813 --- /dev/null +++ b/charmcraft.yaml @@ -0,0 +1,87 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +# This file configures Charmcraft. +# See https://juju.is/docs/sdk/charmcraft-config for guidance. + +name: airbyte-ui-k8s +type: charm +title: Airbyte Web UI +summary: Airbyte Web UI operator +description: | + Airbyte is an open-source data integration platform designed to centralize and + streamline the process of extracting and loading data from various sources into + data warehouses, lakes, or other destinations. + + This charm provides the web UI which can be related to the Airbyte server charm + to view and configure different connections. +links: + documentation: https://discourse.charmhub.io/t/charmed-airbyte-ui-k8s-overview/14529 + +# (Required for 'charm' type) +bases: + - build-on: + - name: ubuntu + channel: "22.04" + run-on: + - name: ubuntu + channel: "22.04" + + +# Metadata +peers: + peer: + interface: airbyte + +requires: + nginx-route: + interface: nginx-route + limit: 1 + + airbyte-server: + interface: airbyte-server + limit: 1 + +# (Optional) Configuration options for the charm +# This config section defines charm config options, and populates the Configure +# tab on Charmhub. +# More information on this section at https://juju.is/docs/sdk/charmcraft-yaml#heading--config +# General configuration documentation: https://juju.is/docs/sdk/config +config: + options: + # An example config option to customise the log level of the workload + log-level: + description: | + Configures the log level of gunicorn. + + Acceptable values are: "info", "debug", "warning", "error" and "critical" + default: "info" + type: string + + external-hostname: + description: | + The DNS listing used for external connections. Will default to the name of the deployed + application. + default: "airbyte-ui-k8s" + type: string + + tls-secret-name: + description: | + Name of the k8s secret which contains the TLS certificate to be used by ingress. + default: "airbyte-tls" + type: string + +# The containers and resources metadata apply to Kubernetes charms only. +# See https://juju.is/docs/sdk/metadata-reference for a checklist and guidance. + +# Your workload’s containers. +containers: + airbyte-webapp: + resource: airbyte-webapp + +# This field populates the Resources tab on Charmhub. +resources: + airbyte-webapp: + type: oci-image + description: OCI image for Airbyte web UI + upstream-source: airbyte/webapp:0.60.0 diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..36e5b14 --- /dev/null +++ b/icon.svg @@ -0,0 +1,79 @@ + +image/svg+xmleclispe-cheeclispe-cheCreated with Sketch. diff --git a/lib/charms/nginx_ingress_integrator/v0/nginx_route.py b/lib/charms/nginx_ingress_integrator/v0/nginx_route.py new file mode 100644 index 0000000..a2ec38e --- /dev/null +++ b/lib/charms/nginx_ingress_integrator/v0/nginx_route.py @@ -0,0 +1,428 @@ +# Copyright 2024 Canonical Ltd. +# Licensed under the Apache2.0. See LICENSE file in charm source for details. +"""Library for the nginx-route relation. + +This library contains the require and provide functions for handling +the nginx-route interface. + +Import `require_nginx_route` in your charm, with four required keyword arguments: +- charm: (the charm itself) +- service_hostname +- service_name +- service_port + +Other optional arguments include: +- additional_hostnames +- backend_protocol +- limit_rps +- limit_whitelist +- max_body_size +- owasp_modsecurity_crs +- owasp_modsecurity_custom_rules +- path_routes +- retry_errors +- rewrite_target +- rewrite_enabled +- service_namespace +- session_cookie_max_age +- tls_secret_name + +See [the config section](https://charmhub.io/nginx-ingress-integrator/configure) for descriptions +of each, along with the required type. + +As an example, add the following to `src/charm.py`: +```python +from charms.nginx_ingress_integrator.v0.nginx_route import NginxRouteRequirer + +# In your charm's `__init__` method (assuming your app is listening on port 8080). +require_nginx_route( + charm=self, + service_hostname=self.app.name, + service_name=self.app.name, + service_port=8080 +) + +``` +And then add the following to `metadata.yaml`: +``` +requires: + nginx-route: + interface: nginx-route +``` +You _must_ require nginx route as part of the `__init__` method +rather than, for instance, a config-changed event handler, for the relation +changed event to be properly handled. + +In the example above we're setting `service_hostname` (which translates to the +external hostname for the application when related to nginx-ingress-integrator) +to `self.app.name` here. This ensures by default the charm will be available on +the name of the deployed juju application, but can be overridden in a +production deployment by setting `service-hostname` on the +nginx-ingress-integrator charm. For example: +```bash +juju deploy nginx-ingress-integrator +juju deploy my-charm +juju relate nginx-ingress-integrator my-charm:nginx-route +# The service is now reachable on the ingress IP(s) of your k8s cluster at +# 'http://my-charm'. +juju config nginx-ingress-integrator service-hostname='my-charm.example.com' +# The service is now reachable on the ingress IP(s) of your k8s cluster at +# 'http://my-charm.example.com'. +``` +""" +import logging +import typing +import weakref + +import ops.charm +import ops.framework +import ops.model + +# The unique Charmhub library identifier, never change it +LIBID = "3c212b6ed3cf43dfbf9f2e322e634beb" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 7 + +__all__ = ["require_nginx_route", "provide_nginx_route"] + +logger = logging.getLogger(__name__) + + +class _NginxRouteAvailableEvent(ops.framework.EventBase): + """NginxRouteAvailableEvent custom event. + + This event indicates the nginx-route provider is available. + """ + + +class _NginxRouteBrokenEvent(ops.charm.RelationBrokenEvent): + """NginxRouteBrokenEvent custom event. + + This event indicates the nginx-route provider is broken. + """ + + +class _NginxRouteCharmEvents(ops.charm.CharmEvents): + """Custom charm events. + + Attrs: + nginx_route_available: Event to indicate that Nginx route relation is available. + nginx_route_broken: Event to indicate that Nginx route relation is broken. + """ + + nginx_route_available = ops.framework.EventSource(_NginxRouteAvailableEvent) + nginx_route_broken = ops.framework.EventSource(_NginxRouteBrokenEvent) + + +class NginxRouteRequirer(ops.framework.Object): + """This class defines the functionality for the 'requires' side of the 'nginx-route' relation. + + Hook events observed: + - relation-changed + """ + + def __init__( + self, + charm: ops.charm.CharmBase, + config: typing.Dict[str, typing.Union[str, int, bool]], + nginx_route_relation_name: str = "nginx-route", + ): + """Init function for the NginxRouteRequires class. + + Args: + charm: The charm that requires the nginx-route relation. + config: Contains all the configuration options for nginx-route. + nginx_route_relation_name: Specifies the relation name of the relation handled by this + requirer class. The relation must have the nginx-route interface. + """ + super().__init__(charm, nginx_route_relation_name) + self._charm = charm + self._nginx_route_relation_name = nginx_route_relation_name + self._charm.framework.observe( + self._charm.on[self._nginx_route_relation_name].relation_changed, + self._config_reconciliation, + ) + # Set default values. + self.config: typing.Dict[str, typing.Union[str, int, bool]] = { + "service-namespace": self._charm.model.name, + **config, + } + self._config_reconciliation(None) + + def _config_reconciliation(self, _event: typing.Any = None) -> None: + """Update the nginx-route relation data to be exactly as defined by config.""" + if not self._charm.model.unit.is_leader(): + return + for relation in self._charm.model.relations[self._nginx_route_relation_name]: + relation_app_data = relation.data[self._charm.app] + delete_keys = { + relation_field + for relation_field in relation_app_data + if relation_field not in self.config + } + for delete_key in delete_keys: + del relation_app_data[delete_key] + relation_app_data.update({k: str(v) for k, v in self.config.items()}) + + +# C901 is ignored since the method has too many ifs but wouldn't be +# necessarily good to reduce to smaller methods. +# E501: line too long +def require_nginx_route( # pylint: disable=too-many-locals,too-many-branches,too-many-arguments # noqa: C901,E501 + *, + charm: ops.charm.CharmBase, + service_hostname: str, + service_name: str, + service_port: int, + additional_hostnames: typing.Optional[str] = None, + backend_protocol: typing.Optional[str] = None, + enable_access_log: typing.Optional[bool] = None, + limit_rps: typing.Optional[int] = None, + limit_whitelist: typing.Optional[str] = None, + max_body_size: typing.Optional[int] = None, + owasp_modsecurity_crs: typing.Optional[str] = None, + owasp_modsecurity_custom_rules: typing.Optional[str] = None, + path_routes: typing.Optional[str] = None, + retry_errors: typing.Optional[str] = None, + rewrite_target: typing.Optional[str] = None, + rewrite_enabled: typing.Optional[bool] = None, + service_namespace: typing.Optional[str] = None, + session_cookie_max_age: typing.Optional[int] = None, + tls_secret_name: typing.Optional[str] = None, + nginx_route_relation_name: str = "nginx-route", +) -> NginxRouteRequirer: + """Set up nginx-route relation handlers on the requirer side. + + This function must be invoked in the charm class constructor. + + Args: + charm: The charm that requires the nginx-route relation. + service_hostname: configure Nginx ingress integrator + service-hostname option via relation. + service_name: configure Nginx ingress integrator service-name + option via relation. + service_port: configure Nginx ingress integrator service-port + option via relation. + additional_hostnames: configure Nginx ingress integrator + additional-hostnames option via relation, optional. + backend_protocol: configure Nginx ingress integrator + backend-protocol option via relation, optional. + enable_access_log: configure Nginx ingress + nginx.ingress.kubernetes.io/enable-access-log option. + limit_rps: configure Nginx ingress integrator limit-rps + option via relation, optional. + limit_whitelist: configure Nginx ingress integrator + limit-whitelist option via relation, optional. + max_body_size: configure Nginx ingress integrator + max-body-size option via relation, optional. + owasp_modsecurity_crs: configure Nginx ingress integrator + owasp-modsecurity-crs option via relation, optional. + owasp_modsecurity_custom_rules: configure Nginx ingress + integrator owasp-modsecurity-custom-rules option via + relation, optional. + path_routes: configure Nginx ingress integrator path-routes + option via relation, optional. + retry_errors: configure Nginx ingress integrator retry-errors + option via relation, optional. + rewrite_target: configure Nginx ingress integrator + rewrite-target option via relation, optional. + rewrite_enabled: configure Nginx ingress integrator + rewrite-enabled option via relation, optional. + service_namespace: configure Nginx ingress integrator + service-namespace option via relation, optional. + session_cookie_max_age: configure Nginx ingress integrator + session-cookie-max-age option via relation, optional. + tls_secret_name: configure Nginx ingress integrator + tls-secret-name option via relation, optional. + nginx_route_relation_name: Specifies the relation name of + the relation handled by this requirer class. The relation + must have the nginx-route interface. + + Returns: + the NginxRouteRequirer. + """ + config: typing.Dict[str, typing.Union[str, int, bool]] = {} + if service_hostname is not None: + config["service-hostname"] = service_hostname + if service_name is not None: + config["service-name"] = service_name + if service_port is not None: + config["service-port"] = service_port + if additional_hostnames is not None: + config["additional-hostnames"] = additional_hostnames + if backend_protocol is not None: + config["backend-protocol"] = backend_protocol + if enable_access_log is not None: + config["enable-access-log"] = "true" if enable_access_log else "false" + if limit_rps is not None: + config["limit-rps"] = limit_rps + if limit_whitelist is not None: + config["limit-whitelist"] = limit_whitelist + if max_body_size is not None: + config["max-body-size"] = max_body_size + if owasp_modsecurity_crs is not None: + config["owasp-modsecurity-crs"] = owasp_modsecurity_crs + if owasp_modsecurity_custom_rules is not None: + config["owasp-modsecurity-custom-rules"] = owasp_modsecurity_custom_rules + if path_routes is not None: + config["path-routes"] = path_routes + if retry_errors is not None: + config["retry-errors"] = retry_errors + if rewrite_target is not None: + config["rewrite-target"] = rewrite_target + if rewrite_enabled is not None: + config["rewrite-enabled"] = rewrite_enabled + if service_namespace is not None: + config["service-namespace"] = service_namespace + if session_cookie_max_age is not None: + config["session-cookie-max-age"] = session_cookie_max_age + if tls_secret_name is not None: + config["tls-secret-name"] = tls_secret_name + + return NginxRouteRequirer( + charm=charm, config=config, nginx_route_relation_name=nginx_route_relation_name + ) + + +class _NginxRouteProvider(ops.framework.Object): + """Class containing the functionality for the 'provides' side of the 'nginx-route' relation. + + Attrs: + on: nginx-route relation event describer. + + Hook events observed: + - relation-changed + """ + + on = _NginxRouteCharmEvents() + + def __init__( + self, + charm: ops.charm.CharmBase, + nginx_route_relation_name: str = "nginx-route", + ): + """Init function for the NginxRouterProvides class. + + Args: + charm: The charm that provides the nginx-route relation. + nginx_route_relation_name: Specifies the relation name of the relation handled by this + provider class. The relation must have the nginx-route interface. + """ + # Observe the relation-changed hook event and bind + # self.on_relation_changed() to handle the event. + super().__init__(charm, nginx_route_relation_name) + self._charm = charm + self._charm.framework.observe( + self._charm.on[nginx_route_relation_name].relation_changed, self._on_relation_changed + ) + self._charm.framework.observe( + self._charm.on[nginx_route_relation_name].relation_broken, self._on_relation_broken + ) + + def _on_relation_changed(self, event: ops.charm.RelationChangedEvent) -> None: + """Handle a change to the nginx-route relation. + + Confirm we have the fields we expect to receive. + + Args: + event: Event triggering the relation-changed hook for the relation. + + Raises: + RuntimeError: if _on_relation changed is triggered by a broken relation. + """ + # `self.unit` isn't available here, so use `self.model.unit`. + if not self._charm.model.unit.is_leader(): + return + + relation_name = event.relation.name + remote_app = event.app + if remote_app is None: + raise RuntimeError("_on_relation_changed was triggered by a broken relation.") + + if not event.relation.data[remote_app]: + logger.info( + "%s hasn't finished configuring, waiting until the relation data is populated.", + relation_name, + ) + return + + required_fields = {"service-hostname", "service-port", "service-name"} + missing_fields = sorted( + field + for field in required_fields + if event.relation.data[remote_app].get(field) is None + ) + if missing_fields: + logger.warning( + "Missing required data fields for %s relation: %s", + relation_name, + ", ".join(missing_fields), + ) + self._charm.model.unit.status = ops.model.BlockedStatus( + f"Missing fields for {relation_name}: {', '.join(missing_fields)}" + ) + return + + # Create an event that our charm can use to decide it's okay to + # configure the Kubernetes Nginx ingress resources. + self.on.nginx_route_available.emit() + + def _on_relation_broken(self, event: ops.charm.RelationBrokenEvent) -> None: + """Handle a relation-broken event in the nginx-route relation. + + Args: + event: Event triggering the relation-broken hook for the relation. + """ + if not self._charm.model.unit.is_leader(): + return + + # Create an event that our charm can use to remove the Kubernetes Nginx ingress resources. + self.on.nginx_route_broken.emit(event.relation) + + +# This is here only to maintain a reference to the instance of NginxRouteProvider created by +# the provide_nginx_route function. This is required for ops framework event handling to work. +# The provider instance will have the same lifetime as the charm that creates it. +__provider_references: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() + + +def provide_nginx_route( + charm: ops.charm.CharmBase, + on_nginx_route_available: typing.Callable, + on_nginx_route_broken: typing.Callable, + nginx_route_relation_name: str = "nginx-route", +) -> None: + """Set up nginx-route relation handlers on the provider side. + + This function must be invoked in the charm class constructor. + + Args: + charm: The charm that requires the nginx-route relation. + on_nginx_route_available: Callback function for the nginx-route-available event. + on_nginx_route_broken: Callback function for the nginx-route-broken event. + nginx_route_relation_name: Specifies the relation name of the relation handled by this + provider class. The relation must have the nginx-route interface. + + Raises: + RuntimeError: If provide_nginx_route was invoked twice with + the same nginx-route relation name + """ + ref_dict: typing.Dict[str, typing.Any] = __provider_references.get(charm, {}) + if ref_dict.get(nginx_route_relation_name) is not None: + raise RuntimeError( + "provide_nginx_route was invoked twice with the same nginx-route relation name" + ) + provider = _NginxRouteProvider( + charm=charm, nginx_route_relation_name=nginx_route_relation_name + ) + if charm in __provider_references: + __provider_references[charm][nginx_route_relation_name] = provider + else: + __provider_references[charm] = {nginx_route_relation_name: provider} + charm.framework.observe(provider.on.nginx_route_available, on_nginx_route_available) + charm.framework.observe(provider.on.nginx_route_broken, on_nginx_route_broken) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..724f93d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +[tool.bandit] +exclude_dirs = ["/venv/"] +[tool.bandit.assert_used] +skips = ["*/*test.py", "*/test_*.py", "*tests/*.py"] + +# Testing tools configuration +[tool.coverage.run] +branch = true + +[tool.coverage.report] +show_missing = true + +[tool.pytest.ini_options] +minversion = "6.0" +log_cli_level = "INFO" + +# Formatting tools configuration +[tool.black] +line-length = 120 +target-version = ["py38"] + +[tool.isort] +profile = "black" + +# Linting tools configuration +[tool.flake8] +max-line-length = 120 +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"] +docstring-convention = "google" +# Check for properly formatted copyright header in each file +copyright-check = "True" +copyright-author = "Canonical Ltd." +copyright-regexp = "Copyright\\s\\d{4}([-,]\\d{4})*\\s+%(author)s" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..618ba75 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +ops ~= 2.5 diff --git a/src/charm.py b/src/charm.py new file mode 100755 index 0000000..984801d --- /dev/null +++ b/src/charm.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Charm definition and helpers.""" + +import logging + +from charms.nginx_ingress_integrator.v0.nginx_route import require_nginx_route +from ops import main, pebble +from ops.charm import CharmBase +from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus +from ops.pebble import CheckStatus + +from log import log_event_handler +from relations.airbyte_server import AirbyteServer +from state import State + +logger = logging.getLogger(__name__) + +WEB_UI_PORT = 8080 +INTERNAL_API_PORT = 8001 +CONNECTOR_BUILDER_API_PORT = 80 +AIRBYTE_VERSION = "0.60.0" + + +class AirbyteUIK8sOperatorCharm(CharmBase): + """Airbyte UI charm. + + Attrs: + _state: used to store data that is persisted across invocations. + external_hostname: DNS listing used for external connections. + """ + + @property + def external_hostname(self): + """Return the DNS listing used for external connections.""" + return self.config["external-hostname"] or self.app.name + + def __init__(self, *args): + """Construct. + + Args: + args: Ignore. + """ + super().__init__(*args) + self._state = State(self.app, lambda: self.model.get_relation("peer")) + + self.name = "airbyte-webapp" + self.framework.observe(self.on[self.name].pebble_ready, self._on_pebble_ready) + self.framework.observe(self.on.update_status, self._on_update_status) + self.framework.observe(self.on.restart_action, self._on_restart) + self.framework.observe(self.on.peer_relation_changed, self._on_peer_relation_changed) + + # Handle Airbyte server relation + self.airbyte_server = AirbyteServer(self) + + # Handle Ingress. + self._require_nginx_route() + + def _require_nginx_route(self): + """Require nginx-route relation based on current configuration.""" + require_nginx_route( + charm=self, + service_hostname=self.external_hostname, + service_name=self.app.name, + service_port=WEB_UI_PORT, + tls_secret_name=self.config["tls-secret-name"], + backend_protocol="HTTP", + ) + + @log_event_handler(logger) + def _on_pebble_ready(self, event): + """Handle pebble ready event. + + Args: + event: The event triggered when the relation changed. + """ + self._update(event) + + @log_event_handler(logger) + def _on_peer_relation_changed(self, event): + """Handle peer relation changed event. + + Args: + event: The event triggered when the relation changed. + """ + self._update(event) + + @log_event_handler(logger) + def _on_update_status(self, event): + """Handle `update-status` events. + + Args: + event: The `update-status` event triggered at intervals. + """ + try: + self._validate() + except ValueError: + return + + container = self.unit.get_container(self.name) + valid_pebble_plan = self._validate_pebble_plan(container) + if not valid_pebble_plan: + self._update(event) + return + + check = container.get_check("up") + if check.status != CheckStatus.UP: + self.unit.status = MaintenanceStatus("Status check: DOWN") + return + + self.unit.set_workload_version(f"v{AIRBYTE_VERSION}") + self.unit.status = ActiveStatus() + + def _validate_pebble_plan(self, container): + """Validate pebble plan. + + Args: + container: application container + + Returns: + bool of pebble plan validity + """ + try: + plan = container.get_plan().to_dict() + return bool(plan["services"][self.name]["on-check-failure"]) + except (KeyError, pebble.ConnectionError): + return False + + def _on_restart(self, event): + """Restart Airbyte ui action handler. + + Args: + event:The event triggered by the restart action + """ + container = self.unit.get_container(self.name) + if not container.can_connect(): + event.defer() + return + + self.unit.status = MaintenanceStatus("restarting application") + container.restart(self.name) + + event.set_results({"result": "UI successfully restarted"}) + + def _validate(self): + """Validate that configuration and relations are valid and ready. + + Raises: + ValueError: in case of invalid configuration. + """ + if not self._state.is_ready(): + raise ValueError("peer relation not ready") + + if not self._state.airbyte_server: + raise ValueError("airbyte-server relation: not available") + + if not self._state.airbyte_server["status"] == "ready": + raise ValueError("airbyte-server relation: server is not ready") + + @log_event_handler(logger) + def _update(self, event): + """Update the Airbyte UI configuration and replan its execution. + + Args: + event: The event triggered when the relation changed. + """ + try: + self._validate() + except ValueError as err: + self.unit.status = BlockedStatus(str(err)) + return + + server_svc = self._state.airbyte_server["name"] + context = { + "API_URL": "/api/v1/", + "AIRBYTE_EDITION": "community", + "AIRBYTE_SERVER_HOST": f"{server_svc}:{INTERNAL_API_PORT}", + "INTERNAL_API_HOST": f"{server_svc}:{INTERNAL_API_PORT}", + "CONNECTOR_BUILDER_API_HOST": f"{server_svc}:{CONNECTOR_BUILDER_API_PORT}", + "CONNECTOR_BUILDER_API_URL": "/connector-builder-api", + "KEYCLOAK_INTERNAL_HOST": "localhost", + } + + self.model.unit.set_ports(WEB_UI_PORT) + container = self.unit.get_container(self.name) + if not container.can_connect(): + event.defer() + return + + pebble_layer = { + "summary": "airbyte layer", + "services": { + self.name: { + "summary": self.name, + "command": "./docker-entrypoint.sh nginx", + "startup": "enabled", + "override": "replace", + # Including config values here so that a change in the + # config forces replanning to restart the service. + "environment": context, + "on-check-failure": {"up": "ignore"}, + }, + }, + "checks": { + "up": { + "override": "replace", + "period": "10s", + "http": {"url": f"http://localhost:{WEB_UI_PORT}"}, + } + }, + } + container.add_layer(self.name, pebble_layer, combine=True) + container.replan() + + self.unit.status = MaintenanceStatus("replanning application") + + +if __name__ == "__main__": # pragma: nocover + main.main(AirbyteUIK8sOperatorCharm) # type: ignore diff --git a/src/log.py b/src/log.py new file mode 100644 index 0000000..27bfcff --- /dev/null +++ b/src/log.py @@ -0,0 +1,47 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Define logging helpers.""" + +import functools + + +def log_event_handler(logger): + """Log with the provided logger when a event handler method is executed. + + Args: + logger: logger used to log events. + + Returns: + Decorator wrapper. + """ + + def decorator(method): + """Log decorator wrapper. + + Args: + method: method wrapped by the decorator. + + Returns: + Decorated method. + """ + + @functools.wraps(method) + def decorated(self, event): + """Log decorator method. + + Args: + event: The event triggered when the relation changes. + + Returns: + Decorated method. + """ + logger.info(f"* running {self.__class__.__name__}.{method.__name__}") + try: + return method(self, event) + finally: + logger.info(f"* completed {self.__class__.__name__}.{method.__name__}") + + return decorated + + return decorator diff --git a/src/relations/airbyte_server.py b/src/relations/airbyte_server.py new file mode 100644 index 0000000..f63330c --- /dev/null +++ b/src/relations/airbyte_server.py @@ -0,0 +1,65 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Define the Airbyte server:ui relation.""" + +import logging + +from ops import framework + +from log import log_event_handler + +logger = logging.getLogger(__name__) + + +class AirbyteServer(framework.Object): + """Client for server:ui relation.""" + + def __init__(self, charm): + """Construct. + + Args: + charm: The charm to attach the hooks to. + """ + super().__init__(charm, "airbyte-server") + self.charm = charm + charm.framework.observe(charm.on.airbyte_server_relation_joined, self._on_airbyte_server_relation_changed) + charm.framework.observe(charm.on.airbyte_server_relation_changed, self._on_airbyte_server_relation_changed) + charm.framework.observe(charm.on.airbyte_server_relation_broken, self._on_airbyte_server_relation_broken) + + @log_event_handler(logger) + def _on_airbyte_server_relation_changed(self, event): + """Handle server:ui relation change event. + + Args: + event: The event triggered when the relation changed. + """ + if not self.charm.unit.is_leader(): + return + + if not self.charm._state.is_ready(): + event.defer() + return + + self.charm._state.airbyte_server = { + "name": event.relation.data[event.app].get("server_name"), + "status": event.relation.data[event.app].get("server_status"), + } + self.charm._update(event) + + @log_event_handler(logger) + def _on_airbyte_server_relation_broken(self, event): + """Handle server:ui relation broken event. + + Args: + event: The event triggered when the relation changed. + """ + if not self.charm.unit.is_leader(): + return + + if not self.charm._state.is_ready(): + event.defer() + return + + self.charm._state.airbyte_server = None + self.charm._update(event) diff --git a/src/state.py b/src/state.py new file mode 100644 index 0000000..9a4eb40 --- /dev/null +++ b/src/state.py @@ -0,0 +1,66 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Manager for handling charm state.""" + +import json + + +class State: + """A magic state that uses a relation as the data store. + + The get_relation callable is used to retrieve the relation. + As relation data values must be strings, all values are JSON encoded. + """ + + def __init__(self, app, get_relation): + """Construct. + + Args: + app: workload application + get_relation: get peer relation method + """ + # Use __dict__ to avoid calling __setattr__ and subsequent infinite recursion. + self.__dict__["_app"] = app + self.__dict__["_get_relation"] = get_relation + + def __setattr__(self, name, value): + """Set a value in the store with the given name. + + Args: + name: name of value to set in store. + value: value to set in store. + """ + v = json.dumps(value) + self._get_relation().data[self._app].update({name: v}) + + def __getattr__(self, name): + """Get from the store the value with the given name, or None. + + Args: + name: name of value to get from store. + + Returns: + value from store with given name. + """ + v = self._get_relation().data[self._app].get(name, "null") + return json.loads(v) + + def __delattr__(self, name): + """Delete the value with the given name from the store, if it exists. + + Args: + name: name of value to delete from store. + + Returns: + deleted value from store. + """ + return self._get_relation().data[self._app].pop(name, None) + + def is_ready(self): + """Report whether the relation is ready to be used. + + Returns: + A boolean representing whether the relation is ready to be used or not. + """ + return bool(self._get_relation()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..289a524 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Tests module.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0998abd --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,16 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Fixtures for jenkins-k8s charm tests.""" + +import pytest + + +def pytest_addoption(parser: pytest.Parser): + """Parse additional pytest options. + + Args: + parser: pytest command line parser. + """ + # The prebuilt charm file. + parser.addoption("--charm-file", action="append", default=[]) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..2b116cb --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,65 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Charm integration test config.""" + +import asyncio +import logging + +import pytest_asyncio +from helpers import ( + APP_NAME_AIRBYTE_SERVER, + APP_NAME_AIRBYTE_UI, + APP_NAME_TEMPORAL_ADMIN, + APP_NAME_TEMPORAL_SERVER, + METADATA, + create_default_namespace, + perform_airbyte_integrations, + perform_temporal_integrations, +) +from pytest_operator.plugin import OpsTest + +logger = logging.getLogger(__name__) + + +@pytest_asyncio.fixture(name="deploy", scope="module") +async def deploy(ops_test: OpsTest): + """Test the app is up and running.""" + charm = await ops_test.build_charm(".") + resources = {"airbyte-webapp": METADATA["resources"]["airbyte-webapp"]["upstream-source"]} + + asyncio.gather( + ops_test.model.deploy(charm, resources=resources, application_name=APP_NAME_AIRBYTE_UI), + ops_test.model.deploy(APP_NAME_AIRBYTE_SERVER, trust=True, channel="edge"), + ops_test.model.deploy( + APP_NAME_TEMPORAL_SERVER, + channel="edge", + config={"num-history-shards": 1}, + ), + ops_test.model.deploy(APP_NAME_TEMPORAL_ADMIN, channel="edge"), + ops_test.model.deploy("postgresql-k8s", channel="14/stable", trust=True), + ops_test.model.deploy("minio", channel="edge"), + ops_test.model.deploy("nginx-ingress-integrator", channel="edge", revision=103, trust=True), + ) + + async with ops_test.fast_forward(): + await ops_test.model.wait_for_idle( + apps=["postgresql-k8s", "minio"], status="active", raise_on_blocked=False, timeout=1200 + ) + await ops_test.model.wait_for_idle( + apps=[APP_NAME_TEMPORAL_SERVER, APP_NAME_TEMPORAL_ADMIN], + status="blocked", + raise_on_blocked=False, + timeout=600, + ) + await ops_test.model.wait_for_idle( + apps=["nginx-ingress-integrator"], + status="waiting", + raise_on_blocked=False, + timeout=600, + ) + + await perform_temporal_integrations(ops_test) + await create_default_namespace(ops_test) + + await perform_airbyte_integrations(ops_test) diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py new file mode 100644 index 0000000..ef641d4 --- /dev/null +++ b/tests/integration/helpers.py @@ -0,0 +1,168 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +# flake8: noqa + +"""Charm integration test helpers.""" + +import logging +import socket +from datetime import timedelta +from pathlib import Path + +import yaml +from pytest_operator.plugin import OpsTest +from temporal_client.activities import say_hello +from temporal_client.workflows import SayHello +from temporalio.client import Client +from temporalio.worker import Worker + +logger = logging.getLogger(__name__) + +METADATA = yaml.safe_load(Path("./charmcraft.yaml").read_text()) +APP_NAME_AIRBYTE_UI = METADATA["name"] +APP_NAME_AIRBYTE_SERVER = "airbyte-k8s" +APP_NAME_TEMPORAL_SERVER = "temporal-k8s" +APP_NAME_TEMPORAL_ADMIN = "temporal-admin-k8s" +APP_NAME_TEMPORAL_UI = "temporal-ui-k8s" + + +def gen_patch_getaddrinfo(host: str, resolve_to: str): # noqa + """Generate patched getaddrinfo function. + + This function is used to generate a patched getaddrinfo function that will resolve to the + resolve_to address without having to actually register a host. + + Args: + host: intended hostname of a given application. + resolve_to: destination address for host to resolve to. + Returns: + A patching function for getaddrinfo. + """ + original_getaddrinfo = socket.getaddrinfo + + def patched_getaddrinfo(*args): + """Patch getaddrinfo to point to desired ip address. + + Args: + args: original arguments to getaddrinfo when creating network connection. + Returns: + Patched getaddrinfo function. + """ + if args[0] == host: + return original_getaddrinfo(resolve_to, *args[1:]) + return original_getaddrinfo(*args) + + return patched_getaddrinfo + + +async def run_sample_workflow(ops_test: OpsTest): + """Connect a client and runs a basic Temporal workflow. + + Args: + ops_test: PyTest object. + """ + url = await get_application_url(ops_test, application=APP_NAME_TEMPORAL_SERVER, port=7233) + logger.info("running workflow on app address: %s", url) + + client = await Client.connect(url) + + # Run a worker for the workflow + async with Worker(client, task_queue="my-task-queue", workflows=[SayHello], activities=[say_hello]): + name = "Jean-luc" + result = await client.execute_workflow( + SayHello.run, name, id="my-workflow-id", task_queue="my-task-queue", run_timeout=timedelta(seconds=20) + ) + logger.info(f"result: {result}") + assert result == f"Hello, {name}!" + + +async def create_default_namespace(ops_test: OpsTest): + """Creates default namespace on Temporal server using tctl. + + Args: + ops_test: PyTest object. + """ + # Register default namespace from admin charm. + action = ( + await ops_test.model.applications[APP_NAME_TEMPORAL_ADMIN] + .units[0] + .run_action("tctl", args="--ns default namespace register -rd 3") + ) + result = (await action.wait()).results + logger.info(f"tctl result: {result}") + assert "result" in result and result["result"] == "command succeeded" + + +async def get_application_url(ops_test: OpsTest, application, port): + """Return application URL from the model. + + Args: + ops_test: PyTest object. + application: Name of the application. + port: Port number of the URL. + + Returns: + Application URL of the form {address}:{port} + """ + status = await ops_test.model.get_status() # noqa: F821 + address = status["applications"][application].public_address + return f"{address}:{port}" + + +async def get_unit_url(ops_test: OpsTest, application, unit, port, protocol="http"): + """Return unit URL from the model. + + Args: + ops_test: PyTest object. + application: Name of the application. + unit: Number of the unit. + port: Port number of the URL. + protocol: Transfer protocol (default: http). + + Returns: + Unit URL of the form {protocol}://{address}:{port} + """ + status = await ops_test.model.get_status() # noqa: F821 + address = status["applications"][application]["units"][f"{application}/{unit}"]["address"] + return f"{protocol}://{address}:{port}" + + +async def perform_temporal_integrations(ops_test: OpsTest): + """Integrate Temporal charm with postgresql, admin and ui charms. + + Args: + ops_test: PyTest object. + """ + await ops_test.model.integrate(f"{APP_NAME_TEMPORAL_SERVER}:db", "postgresql-k8s:database") + await ops_test.model.integrate(f"{APP_NAME_TEMPORAL_SERVER}:visibility", "postgresql-k8s:database") + await ops_test.model.integrate(f"{APP_NAME_TEMPORAL_SERVER}:admin", f"{APP_NAME_TEMPORAL_ADMIN}:admin") + await ops_test.model.wait_for_idle( + apps=[APP_NAME_TEMPORAL_SERVER], status="active", raise_on_blocked=False, timeout=180 + ) + + assert ops_test.model.applications[APP_NAME_TEMPORAL_SERVER].units[0].workload_status == "active" + + +async def perform_airbyte_integrations(ops_test: OpsTest): + """Perform Airbyte charm integrations. + + Args: + ops_test: PyTest object. + """ + await ops_test.model.integrate(APP_NAME_AIRBYTE_SERVER, "postgresql-k8s") + await ops_test.model.integrate(APP_NAME_AIRBYTE_SERVER, "minio") + await ops_test.model.integrate(APP_NAME_AIRBYTE_SERVER, APP_NAME_AIRBYTE_UI) + await ops_test.model.integrate(APP_NAME_AIRBYTE_UI, "nginx-ingress-integrator") + + await ops_test.model.wait_for_idle( + apps=[APP_NAME_AIRBYTE_SERVER, APP_NAME_AIRBYTE_UI, "nginx-ingress-integrator"], + status="active", + raise_on_blocked=False, + timeout=600, + ) + + assert ops_test.model.applications[APP_NAME_AIRBYTE_SERVER].units[0].workload_status == "active" + assert ops_test.model.applications[APP_NAME_AIRBYTE_UI].units[0].workload_status == "active" + + await run_sample_workflow(ops_test) diff --git a/tests/integration/temporal_client/activities.py b/tests/integration/temporal_client/activities.py new file mode 100644 index 0000000..2e8a8d6 --- /dev/null +++ b/tests/integration/temporal_client/activities.py @@ -0,0 +1,20 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + + +"""Temporal client activity.""" + +from temporalio import activity + + +@activity.defn +async def say_hello(name: str) -> str: + """Temporal activity. + + Args: + name: used to run the dynamic activity. + + Returns: + String in the form "Hello, {name}! + """ + return f"Hello, {name}!" diff --git a/tests/integration/temporal_client/workflows.py b/tests/integration/temporal_client/workflows.py new file mode 100644 index 0000000..96f13ae --- /dev/null +++ b/tests/integration/temporal_client/workflows.py @@ -0,0 +1,30 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + + +"""Temporal client sample workflow.""" + +from datetime import timedelta + +from temporalio import workflow + +# Import our activity, passing it through the sandbox +with workflow.unsafe.imports_passed_through(): + from .activities import say_hello + + +@workflow.defn +class SayHello: + """Temporal workflow class.""" + + @workflow.run + async def run(self, name: str) -> str: + """Workflow execution method. + + Args: + name: used to run the dynamic activity. + + Returns: + Workflow execution + """ + return await workflow.execute_activity(say_hello, name, schedule_to_close_timeout=timedelta(seconds=5)) diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py new file mode 100644 index 0000000..1ec0b3b --- /dev/null +++ b/tests/integration/test_charm.py @@ -0,0 +1,67 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Charm integration tests.""" + +import logging +import socket +import unittest.mock + +import pytest +import requests +from helpers import APP_NAME_AIRBYTE_UI, gen_patch_getaddrinfo, get_unit_url +from pytest_operator.plugin import OpsTest + +logger = logging.getLogger(__name__) + + +@pytest.mark.abort_on_fail +@pytest.mark.usefixtures("deploy") +class TestDeployment: + """Integration tests for Airbyte UI charm.""" + + async def test_basic_client(self, ops_test: OpsTest): + """Perform GET request on the Airbyte UI host.""" + url = await get_unit_url(ops_test, APP_NAME_AIRBYTE_UI, 0, 8080) + logger.info("curling app address: %s", url) + + response = requests.get(url, timeout=300) + assert response.status_code == 200 + + async def test_ingress(self, ops_test: OpsTest): + """Set external-hostname and test connectivity through ingress.""" + new_hostname = "airbyte-web" + application = ops_test.model.applications[APP_NAME_AIRBYTE_UI] + await application.set_config({"external-hostname": new_hostname}) + + async with ops_test.fast_forward(): + await ops_test.model.wait_for_idle( + apps=[APP_NAME_AIRBYTE_UI, "nginx-ingress-integrator"], + status="active", + raise_on_blocked=False, + idle_period=30, + timeout=1200, + ) + + with unittest.mock.patch.multiple(socket, getaddrinfo=gen_patch_getaddrinfo(new_hostname, "127.0.0.1")): + response = requests.get(f"https://{new_hostname}", timeout=5, verify=False) # nosec + assert ( + response.status_code == 200 + and 'content="Airbyte is the turnkey open-source data integration platform that syncs data from applications, APIs and databases to warehouses."' + in response.text + ) + + async def test_restart_action(self, ops_test: OpsTest): + """Test charm restart action.""" + action = await ops_test.model.applications[APP_NAME_AIRBYTE_UI].units[0].run_action("restart") + await action.wait() + + async with ops_test.fast_forward(): + await ops_test.model.wait_for_idle( + apps=[APP_NAME_AIRBYTE_UI], + status="active", + raise_on_blocked=False, + timeout=600, + ) + + assert ops_test.model.applications[APP_NAME_AIRBYTE_UI].units[0].workload_status == "active" diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..9902dfe --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1,9 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + + +"""Unit tests config.""" + +import ops.testing + +ops.testing.SIMULATE_CAN_CONNECT = True diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py new file mode 100644 index 0000000..aea0e2f --- /dev/null +++ b/tests/unit/test_charm.py @@ -0,0 +1,272 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. +# +# Learn more about testing at: https://juju.is/docs/sdk/testing + + +"""Charm unit tests.""" + +# pylint:disable=protected-access + +from unittest import TestCase, mock + +from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus +from ops.pebble import CheckStatus +from ops.testing import Harness + +from charm import AirbyteUIK8sOperatorCharm +from src.charm import CONNECTOR_BUILDER_API_PORT, INTERNAL_API_PORT, WEB_UI_PORT + +APP_NAME = "airbyte-webapp" +mock_incomplete_pebble_plan = {"services": {"airbyte-webapp": {"override": "replace"}}} + + +class TestCharm(TestCase): + """Unit tests for charm. + + Attrs: + maxDiff: Specifies max difference shown by failed tests. + """ + + maxDiff = None + + def setUp(self): + """Create setup for the unit tests.""" + self.harness = Harness(AirbyteUIK8sOperatorCharm) + self.addCleanup(self.harness.cleanup) + self.harness.set_can_connect(APP_NAME, True) + self.harness.set_leader(True) + self.harness.set_model_name("airbyte-model") + self.harness.begin() + + def test_initial_plan(self): + """The initial pebble plan is empty.""" + initial_plan = self.harness.get_container_pebble_plan(APP_NAME).to_dict() + self.assertEqual(initial_plan, {}) + + def test_blocked_by_peer_relation_not_ready(self): + """The charm is blocked without a peer relation.""" + harness = self.harness + + # Simulate pebble readiness. + container = harness.model.unit.get_container(APP_NAME) + harness.charm.on.airbyte_webapp_pebble_ready.emit(container) + + # No plans are set yet. + got_plan = harness.get_container_pebble_plan(APP_NAME).to_dict() + self.assertEqual(got_plan, {}) + + # The BlockStatus is set with a message. + self.assertEqual(harness.model.unit.status, BlockedStatus("peer relation not ready")) + + def test_ingress(self): + """The charm relates correctly to the nginx ingress charm and can be configured.""" + harness = self.harness + + simulate_lifecycle(harness) + + nginx_route_relation_id = harness.add_relation("nginx-route", "ingress") + harness.charm._require_nginx_route() + + assert harness.get_relation_data(nginx_route_relation_id, harness.charm.app) == { + "service-namespace": harness.charm.model.name, + "service-hostname": harness.charm.app.name, + "service-name": harness.charm.app.name, + "service-port": str(WEB_UI_PORT), + "tls-secret-name": "airbyte-tls", + "backend-protocol": "HTTP", + } + + def test_ingress_update_hostname(self): + """The charm relates correctly to the nginx ingress charm and can be configured.""" + harness = self.harness + + simulate_lifecycle(harness) + + nginx_route_relation_id = harness.add_relation("nginx-route", "ingress") + + new_hostname = "new-airbyte-ui-k8s" + harness.update_config({"external-hostname": new_hostname}) + harness.charm._require_nginx_route() + + assert harness.get_relation_data(nginx_route_relation_id, harness.charm.app) == { + "service-namespace": harness.charm.model.name, + "service-hostname": new_hostname, + "service-name": harness.charm.app.name, + "service-port": str(WEB_UI_PORT), + "tls-secret-name": "airbyte-tls", + "backend-protocol": "HTTP", + } + + def test_ingress_update_tls(self): + """The charm relates correctly to the nginx ingress charm and can be configured.""" + harness = self.harness + + simulate_lifecycle(harness) + + nginx_route_relation_id = harness.add_relation("nginx-route", "ingress") + + new_tls = "new-tls" + harness.update_config({"tls-secret-name": new_tls}) + harness.charm._require_nginx_route() + + assert harness.get_relation_data(nginx_route_relation_id, harness.charm.app) == { + "service-namespace": harness.charm.model.name, + "service-hostname": harness.charm.app.name, + "service-name": harness.charm.app.name, + "service-port": str(WEB_UI_PORT), + "tls-secret-name": new_tls, + "backend-protocol": "HTTP", + } + + def test_ready(self): + """The pebble plan is correctly generated when the charm is ready.""" + harness = self.harness + + simulate_lifecycle(harness) + + # The plan is generated after pebble is ready. + want_plan = { + "services": { + APP_NAME: { + "summary": APP_NAME, + "command": "./docker-entrypoint.sh nginx", + "startup": "enabled", + "override": "replace", + "environment": { + "API_URL": "/api/v1/", + "AIRBYTE_EDITION": "community", + "AIRBYTE_SERVER_HOST": "airbyte-k8s:8001", + "CONNECTOR_BUILDER_API_URL": "/connector-builder-api", + "INTERNAL_API_HOST": f"airbyte-k8s:{INTERNAL_API_PORT}", + "CONNECTOR_BUILDER_API_HOST": f"airbyte-k8s:{CONNECTOR_BUILDER_API_PORT}", + "KEYCLOAK_INTERNAL_HOST": "localhost", + }, + "on-check-failure": {"up": "ignore"}, + } + }, + "checks": { + "up": { + "override": "replace", + "period": "10s", + "http": {"url": f"http://localhost:{WEB_UI_PORT}"}, + } + }, + } + + got_plan = harness.get_container_pebble_plan(APP_NAME).to_dict() + self.assertEqual(got_plan, want_plan) + + # The service was started. + service = harness.model.unit.get_container(APP_NAME).get_service(APP_NAME) + self.assertTrue(service.is_running()) + + def test_update_status_up(self): + """The charm updates the unit status to active based on UP status.""" + harness = self.harness + + simulate_lifecycle(harness) + + container = harness.model.unit.get_container(APP_NAME) + container.get_check = mock.Mock(status="up") + container.get_check.return_value.status = CheckStatus.UP + harness.charm.on.update_status.emit() + + self.assertEqual(harness.model.unit.status, ActiveStatus()) + + def test_update_status_down(self): + """The charm updates the unit status to maintenance based on DOWN status.""" + harness = self.harness + + simulate_lifecycle(harness) + + container = harness.model.unit.get_container(APP_NAME) + container.get_check = mock.Mock(status="up") + container.get_check.return_value.status = CheckStatus.DOWN + harness.charm.on.update_status.emit() + + self.assertEqual(harness.model.unit.status, MaintenanceStatus("Status check: DOWN")) + + def test_incomplete_pebble_plan(self): + """The charm re-applies the pebble plan if incomplete.""" + harness = self.harness + simulate_lifecycle(harness) + + container = harness.model.unit.get_container(APP_NAME) + container.add_layer(APP_NAME, mock_incomplete_pebble_plan, combine=True) + harness.charm.on.update_status.emit() + + self.assertEqual( + harness.model.unit.status, + MaintenanceStatus("replanning application"), + ) + plan = harness.get_container_pebble_plan(APP_NAME).to_dict() + assert plan != mock_incomplete_pebble_plan + + @mock.patch("charm.AirbyteUIK8sOperatorCharm._validate_pebble_plan", return_value=True) + def test_missing_pebble_plan(self, mock_validate_pebble_plan): + """The charm re-applies the pebble plan if missing.""" + harness = self.harness + simulate_lifecycle(harness) + + mock_validate_pebble_plan.return_value = False + harness.charm.on.update_status.emit() + self.assertEqual( + harness.model.unit.status, + MaintenanceStatus("replanning application"), + ) + plan = harness.get_container_pebble_plan(APP_NAME).to_dict() + assert plan is not None + + +def simulate_lifecycle(harness): + """Simulate a healthy charm life-cycle. + + Args: + harness: ops.testing.Harness object used to simulate charm lifecycle. + """ + # Simulate pebble readiness. + container = harness.model.unit.get_container(APP_NAME) + harness.charm.on.airbyte_webapp_pebble_ready.emit(container) + + # Simulate peer relation readiness. + harness.add_relation("peer", "airbyte") + + # Add the airbyte relation. + harness.add_relation("airbyte-server", "airbyte-k8s") + + # Simulate server readiness. + app = type("App", (), {"name": "airbyte-ui-k8s"})() + relation = type( + "Relation", + (), + { + "data": {app: {"server_status": "ready", "server_name": "airbyte-k8s"}}, + "name": "ui", + "id": 42, + }, + )() + unit = type("Unit", (), {"app": app, "name": "airbyte-ui-k8s/0"})() + event = type("Event", (), {"app": app, "relation": relation, "unit": unit})() + harness.charm.airbyte_server._on_airbyte_server_relation_changed(event) + + +def make_ui_changed_event(rel_name): + """Create and return a mock relation changed event. + + The event is generated by the relation with the given name. + + Args: + rel_name: Relationship name. + + Returns: + Event dict. + """ + return type( + "Event", + (), + { + "data": {"status": "ready", "name": "airbyte-k8s"}, + "relation": type("Relation", (), {"name": rel_name}), + }, + ) diff --git a/tests/unit/test_state.py b/tests/unit/test_state.py new file mode 100644 index 0000000..54ba55e --- /dev/null +++ b/tests/unit/test_state.py @@ -0,0 +1,70 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. +# +# Learn more about testing at: https://juju.is/docs/sdk/testing + + +"""State unit tests.""" + +import json +from unittest import TestCase + +from state import State + + +class TestState(TestCase): + """Unit tests for state. + + Attrs: + maxDiff: Specifies max difference shown by failed tests. + """ + + maxDiff = None + + def test_get(self): + """It is possible to retrieve attributes from the state.""" + state = make_state({"foo": json.dumps("bar")}) + self.assertEqual(state.foo, "bar") + self.assertIsNone(state.bad) + + def test_set(self): + """It is possible to set attributes in the state.""" + data = {"foo": json.dumps("bar")} + state = make_state(data) + state.foo = 42 + state.list = [1, 2, 3] + self.assertEqual(state.foo, 42) + self.assertEqual(state.list, [1, 2, 3]) + self.assertEqual(data, {"foo": "42", "list": "[1, 2, 3]"}) + + def test_del(self): + """It is possible to unset attributes in the state.""" + data = {"foo": json.dumps("bar"), "answer": json.dumps(42)} + state = make_state(data) + del state.foo + self.assertIsNone(state.foo) + self.assertEqual(data, {"answer": "42"}) + # Deleting a name that is not set does not error. + del state.foo + + def test_is_ready(self): + """The state is not ready when it is not possible to get relations.""" + state = make_state({}) + self.assertTrue(state.is_ready()) + + state = State("myapp", lambda: None) + self.assertFalse(state.is_ready()) + + +def make_state(data): + """Create state object. + + Args: + data: Data to be included in state. + + Returns: + State object with data. + """ + app = "myapp" + rel = type("Rel", (), {"data": {app: data}})() + return State(app, lambda: rel) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..8077a1c --- /dev/null +++ b/tox.ini @@ -0,0 +1,115 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +[tox] +no_package = True +skip_missing_interpreters = True +env_list = fmt, lint, static, unit, coverage-report +min_version = 4.0.0 +max-line-length=120 + +[vars] +src_path = {tox_root}/src +tests_path = {tox_root}/tests +;lib_path = {tox_root}/lib/charms/operator_name_with_underscores +all_path = {[vars]src_path} {[vars]tests_path} + +[testenv] +set_env = + PYTHONPATH = {tox_root}/lib:{[vars]src_path} + PYTHONBREAKPOINT=pdb.set_trace + PY_COLORS=1 +pass_env = + PYTHONPATH + CHARM_BUILD_DIR + MODEL_SETTINGS + +[testenv:fmt] +description = Format the code +deps = + black==22.8.0 + isort==5.10.1 +commands = + isort {[vars]src_path} {[vars]tests_path} + black {[vars]src_path} {[vars]tests_path} + +[testenv:lint] +description = Lint the code +deps = + mypy + pylint + pydocstyle + pytest + black==22.8.0 + codespell==2.2.1 + flake8==5.0.4 + flake8-builtins==1.5.3 + flake8-copyright==0.2.3 + flake8-docstrings==1.6.0 + isort==5.10.1 + pep8-naming==0.13.2 + pyproject-flake8==5.0.4.post1 + flake8-docstrings-complete>=1.0.3 + flake8-test-docs>=1.0 +commands = + pydocstyle {[vars]src_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 {[vars]src_path} {[vars]tests_path} + isort --check-only --diff {[vars]src_path} {[vars]tests_path} + black --check --diff {[vars]src_path} {[vars]tests_path} + mypy {[vars]all_path} --ignore-missing-imports --follow-imports=skip --install-types --non-interactive + pylint {[vars]all_path} --disable=E0401,W1203,W0613,W0718,R0903,W1514,C0103,R0913,C0301,W0212,R0902,C0104,W0640,R0801,W0511,R0914,R0912 + + +[testenv:unit] +description = Run unit tests +deps = + pytest + coverage[toml] + -r {tox_root}/requirements.txt +commands = + coverage run --source={[vars]src_path} \ + -m pytest \ + --tb native \ + -v \ + -s \ + {posargs} \ + {[vars]tests_path}/unit + coverage report + +[testenv: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]tests_path} + +[testenv:integration] +description = Run integration tests +deps = + ipdb==0.13.9 + juju==3.2.0.1 + pytest==7.1.3 + pytest-operator==0.35.0 + temporalio==1.1.0 + pytest-asyncio==0.21 + -r{toxinidir}/requirements.txt +commands = + pytest -v \ + -s \ + --tb native \ + --log-cli-level=INFO \ + {posargs} \ + {[vars]tests_path}/integration