From d5b463cc04638b134d982c386596c82258c509fb Mon Sep 17 00:00:00 2001 From: Patricia Reinoso Date: Wed, 31 May 2023 08:37:18 +0000 Subject: [PATCH] Add Keystone charm osm-keystone charm was in https://github.com/charmed-osm/keystone-operator Change-Id: Iba2321b80dfe8aed79cf27d49883bcec671ff223 Signed-off-by: Patricia Reinoso --- devops-stages/stage-test.sh | 2 +- installers/charm/osm-keystone/.gitignore | 29 ++ installers/charm/osm-keystone/.jujuignore | 23 + installers/charm/osm-keystone/CONTRIBUTING.md | 71 +++ installers/charm/osm-keystone/LICENSE | 202 ++++++++ installers/charm/osm-keystone/README.md | 45 ++ installers/charm/osm-keystone/actions.yaml | 20 + installers/charm/osm-keystone/charmcraft.yaml | 30 ++ installers/charm/osm-keystone/config.yaml | 221 +++++++++ .../v0/kubernetes_service_patch.py | 253 ++++++++++ installers/charm/osm-keystone/metadata.yaml | 45 ++ installers/charm/osm-keystone/pyproject.toml | 54 +++ .../charm/osm-keystone/requirements.txt | 23 + installers/charm/osm-keystone/src/charm.py | 443 ++++++++++++++++++ installers/charm/osm-keystone/src/cluster.py | 135 ++++++ installers/charm/osm-keystone/src/config.py | 184 ++++++++ .../charm/osm-keystone/src/interfaces.py | 190 ++++++++ .../tests/integration/test_charm.py | 51 ++ .../osm-keystone/tests/unit/test_charm.py | 136 ++++++ installers/charm/osm-keystone/tox.ini | 111 +++++ 20 files changed, 2267 insertions(+), 1 deletion(-) create mode 100644 installers/charm/osm-keystone/.gitignore create mode 100644 installers/charm/osm-keystone/.jujuignore create mode 100644 installers/charm/osm-keystone/CONTRIBUTING.md create mode 100644 installers/charm/osm-keystone/LICENSE create mode 100644 installers/charm/osm-keystone/README.md create mode 100644 installers/charm/osm-keystone/actions.yaml create mode 100644 installers/charm/osm-keystone/charmcraft.yaml create mode 100644 installers/charm/osm-keystone/config.yaml create mode 100644 installers/charm/osm-keystone/lib/charms/observability_libs/v0/kubernetes_service_patch.py create mode 100644 installers/charm/osm-keystone/metadata.yaml create mode 100644 installers/charm/osm-keystone/pyproject.toml create mode 100644 installers/charm/osm-keystone/requirements.txt create mode 100755 installers/charm/osm-keystone/src/charm.py create mode 100644 installers/charm/osm-keystone/src/cluster.py create mode 100644 installers/charm/osm-keystone/src/config.py create mode 100644 installers/charm/osm-keystone/src/interfaces.py create mode 100644 installers/charm/osm-keystone/tests/integration/test_charm.py create mode 100644 installers/charm/osm-keystone/tests/unit/test_charm.py create mode 100644 installers/charm/osm-keystone/tox.ini diff --git a/devops-stages/stage-test.sh b/devops-stages/stage-test.sh index 4635421d..1e2f913c 100755 --- a/devops-stages/stage-test.sh +++ b/devops-stages/stage-test.sh @@ -20,7 +20,7 @@ CURRENT_DIR=`pwd` # Execute tests for charms CHARM_PATH="./installers/charm" -NEW_CHARMS_NAMES="osm-lcm osm-mon osm-nbi osm-ng-ui osm-pol osm-ro vca-integrator-operator" +NEW_CHARMS_NAMES="osm-keystone osm-lcm osm-mon osm-nbi osm-ng-ui osm-pol osm-ro vca-integrator-operator" OLD_CHARMS_NAMES="prometheus grafana" for charm in $NEW_CHARMS_NAMES; do if [ $(git diff --name-only "origin/${GERRIT_BRANCH}" -- "installers/charm/${charm}" | wc -l) -ne 0 ]; then diff --git a/installers/charm/osm-keystone/.gitignore b/installers/charm/osm-keystone/.gitignore new file mode 100644 index 00000000..87d0a587 --- /dev/null +++ b/installers/charm/osm-keystone/.gitignore @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +# Copyright 2022 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# +# To get in touch with the maintainers, please contact: +# osm-charmers@lists.launchpad.net +venv/ +build/ +*.charm +.tox/ +.coverage +coverage.xml +__pycache__/ +*.py[cod] +.vscode \ No newline at end of file diff --git a/installers/charm/osm-keystone/.jujuignore b/installers/charm/osm-keystone/.jujuignore new file mode 100644 index 00000000..17c7a8bb --- /dev/null +++ b/installers/charm/osm-keystone/.jujuignore @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +# Copyright 2022 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# +# To get in touch with the maintainers, please contact: +# osm-charmers@lists.launchpad.net +/venv +*.py[cod] +*.charm diff --git a/installers/charm/osm-keystone/CONTRIBUTING.md b/installers/charm/osm-keystone/CONTRIBUTING.md new file mode 100644 index 00000000..3d86cf8e --- /dev/null +++ b/installers/charm/osm-keystone/CONTRIBUTING.md @@ -0,0 +1,71 @@ +# Copyright 2021 Canonical Ltd. +# +# 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. +# + +# Contributing + +## Overview + +This documents explains the processes and practices recommended for contributing enhancements to +the Keystone charm. + +- If you would like to chat with us about your use-cases or proposed implementation, you can reach + us at [OSM public channel](https://opensourcemano.slack.com/archives/CA2TLA48Y) +- Familiarising yourself with the [Charmed Operator Framework](https://juju.is/docs/sdk) library + will help you a lot when working on new features or bug fixes. +- All enhancements require review before being merged. Code review typically examines + - code quality + - test coverage + - user experience for Juju administrators this charm. +- Please help us out in ensuring easy to review branches by rebasing your gerrit patch onto + the `master` branch. + +## Developing + +You can use the environments created by `tox` for development: + +```shell +tox --notest -e unit +source .tox/unit/bin/activate +``` + +### Testing + +```shell +tox -e fmt # update your code according to linting rules +tox -e lint # code style +tox -e unit # unit tests +# tox -e integration # integration tests +tox # runs 'lint' and 'unit' environments +``` + +## Build charm + +Build the charm in this git repository using: + +```shell +charmcraft pack +``` + +### Deploy + +```bash +# Create a model +juju add-model test-keystone +# Enable DEBUG logging +juju model-config logging-config="=INFO;unit=DEBUG" +# Deploy the charm +juju deploy ./keystone_ubuntu-22.04-amd64.charm \ + --resource keystone-image=opensourcemano/keystone:testing-daily --series jammy +``` diff --git a/installers/charm/osm-keystone/LICENSE b/installers/charm/osm-keystone/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/installers/charm/osm-keystone/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 [yyyy] [name of copyright owner] + + 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/installers/charm/osm-keystone/README.md b/installers/charm/osm-keystone/README.md new file mode 100644 index 00000000..08761b9f --- /dev/null +++ b/installers/charm/osm-keystone/README.md @@ -0,0 +1,45 @@ +# Copyright 2021 Canonical Ltd. +# +# 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. +# + +# Keystone Operator + +[![code style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black/tree/main) + +[![Keystone](https://charmhub.io/osm-keystone/badge.svg)](https://charmhub.io/osm-keystone) + +## Description + +This charm deploys Keystone in K8s. It is mainly developed to be used as part of the OSM deployment. + +## Usage + +The Keystone Operator may be deployed using the Juju command line as in + +```shell +$ juju add-model keystone +$ juju deploy charmed-osm-mariadb-k8s db +$ juju deploy osm-keystone --trust +$ juju relate osm-keystone db +``` + +## OCI Images + +- [keystone](https://hub.docker.com/r/opensourcemano/keystone) + +## Contributing + +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. diff --git a/installers/charm/osm-keystone/actions.yaml b/installers/charm/osm-keystone/actions.yaml new file mode 100644 index 00000000..85ed7e6e --- /dev/null +++ b/installers/charm/osm-keystone/actions.yaml @@ -0,0 +1,20 @@ +# Copyright 2021 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# + +db-sync: + description: Execute `keystone-manage db_sync` in the workload container. diff --git a/installers/charm/osm-keystone/charmcraft.yaml b/installers/charm/osm-keystone/charmcraft.yaml new file mode 100644 index 00000000..c8374f30 --- /dev/null +++ b/installers/charm/osm-keystone/charmcraft.yaml @@ -0,0 +1,30 @@ +# Copyright 2021 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# + +type: "charm" +bases: + - build-on: + - name: "ubuntu" + channel: "22.04" + run-on: + - name: "ubuntu" + channel: "22.04" +parts: + charm: + build-packages: + - git diff --git a/installers/charm/osm-keystone/config.yaml b/installers/charm/osm-keystone/config.yaml new file mode 100644 index 00000000..7312bb4d --- /dev/null +++ b/installers/charm/osm-keystone/config.yaml @@ -0,0 +1,221 @@ +# Copyright 2021 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com + +options: + region-id: + type: string + description: Region ID to be created when starting the service + default: RegionOne + keystone-db-password: + type: string + description: Keystone DB Password + default: admin + admin-username: + type: string + description: Admin username to be created when starting the service + default: admin + admin-password: + type: string + description: Admin password to be created when starting the service + default: admin + admin-project: + type: string + description: Admin project to be created when starting the service + default: admin + service-username: + type: string + description: Service Username to be created when starting the service + default: nbi + service-password: + type: string + description: Service Password to be created when starting the service + default: nbi + service-project: + type: string + description: Service Project to be created when starting the service + default: service + user-domain-name: + type: string + description: User domain name (Hardcoded in the container start.sh script) + default: default + project-domain-name: + type: string + description: | + Project domain name (Hardcoded in the container start.sh script) + default: default + token-expiration: + type: int + description: Token keys expiration in seconds + default: 3600 + ldap-enabled: + type: boolean + description: Boolean to enable/disable LDAP authentication + default: false + ldap-authentication-domain-name: + type: string + description: Name of the domain which use LDAP authentication + default: "" + ldap-url: + type: string + description: URL of the LDAP server + default: "ldap://localhost" + ldap-bind-user: + type: string + description: User to bind and search for users + default: "" + ldap-bind-password: + type: string + description: Password to bind and search for users + default: "" + ldap-chase-referrals: + type: string + description: | + Sets keystone’s referral chasing behavior across directory partitions. + If left unset, the system’s default behavior will be used. + default: "" + ldap-page-size: + type: int + description: | + Defines the maximum number of results per page that keystone should + request from the LDAP server when listing objects. A value of zero (0) + disables paging. + default: 0 + ldap-user-tree-dn: + type: string + description: | + Root of the tree in LDAP server in which Keystone will search for users + default: "" + ldap-user-objectclass: + type: string + description: | + LDAP object class that Keystone will filter on within user_tree_dn to + find user objects. Any objects of other classes will be ignored. + default: inetOrgPerson + ldap-user-id-attribute: + type: string + description: | + This set of options define the mapping to LDAP attributes for the three + key user attributes supported by Keystone. The LDAP attribute chosen for + user_id must be something that is immutable for a user and no more than + 64 characters in length. Notice that Distinguished Name (DN) may be + longer than 64 characters and thus is not suitable. An uid, or mail may + be appropriate. + default: cn + ldap-user-name-attribute: + type: string + description: | + This set of options define the mapping to LDAP attributes for the three + key user attributes supported by Keystone. The LDAP attribute chosen for + user_id must be something that is immutable for a user and no more than + 64 characters in length. Notice that Distinguished Name (DN) may be + longer than 64 characters and thus is not suitable. An uid, or mail may + be appropriate. + default: sn + ldap-user-pass-attribute: + type: string + description: | + This set of options define the mapping to LDAP attributes for the three + key user attributes supported by Keystone. The LDAP attribute chosen for + user_id must be something that is immutable for a user and no more than + 64 characters in length. Notice that Distinguished Name (DN) may be + longer than 64 characters and thus is not suitable. An uid, or mail may + be appropriate. + default: userPassword + ldap-user-filter: + type: string + description: | + This filter option allow additional filter (over and above + user_objectclass) to be included into the search of user. One common use + of this is to provide more efficient searching, where the recommended + search for user objects is (&(objectCategory=person)(objectClass=user)). + By specifying user_objectclass as user and user_filter as + objectCategory=person in the Keystone configuration file, this can be + achieved. + default: "" + ldap-user-enabled-attribute: + type: string + description: | + In Keystone, a user entity can be either enabled or disabled. Setting + the above option will give a mapping to an equivalent attribute in LDAP, + allowing your LDAP management tools to disable a user. + default: enabled + ldap-user-enabled-mask: + type: int + description: | + Some LDAP schemas, rather than having a dedicated attribute for user + enablement, use a bit within a general control attribute (such as + userAccountControl) to indicate this. Setting user_enabled_mask will + cause Keystone to look at only the status of this bit in the attribute + specified by user_enabled_attribute, with the bit set indicating the + user is enabled. + default: 0 + ldap-user-enabled-default: + type: string + description: | + Most LDAP servers use a boolean or bit in a control field to indicate + enablement. However, some schemas might use an integer value in an + attribute. In this situation, set user_enabled_default to the integer + value that represents a user being enabled. + default: "true" + ldap-user-enabled-invert: + type: boolean + description: | + Some LDAP schemas have an “account locked” attribute, which is the + equivalent to account being “disabled.” In order to map this to the + Keystone enabled attribute, you can utilize the user_enabled_invert + setting in conjunction with user_enabled_attribute to map the lock + status to disabled in Keystone. + default: false + ldap-group-objectclass: + type: string + description: The LDAP object class to use for groups. + default: groupOfNames + ldap-group-tree-dn: + type: string + description: The search base to use for groups. + default: "" + ldap-use-starttls: + type: boolean + description: | + Enable Transport Layer Security (TLS) for providing a secure connection + from Keystone to LDAP (StartTLS, not LDAPS). + default: false + ldap-tls-cacert-base64: + type: string + description: | + CA certificate in Base64 format (if you have the PEM file, text inside + "-----BEGIN CERTIFICATE-----"/"-----END CERTIFICATE-----" tags). + default: "" + ldap-tls-req-cert: + type: string + description: | + Defines how the certificates are checked for validity in the client + (i.e., Keystone end) of the secure connection (this doesn’t affect what + level of checking the server is doing on the certificates it receives + from Keystone). Possible values are "demand", "never", and "allow". The + default of demand means the client always checks the certificate and + will drop the connection if it is not provided or invalid. never is the + opposite—it never checks it, nor requires it to be provided. allow means + that if it is not provided then the connection is allowed to continue, + but if it is provided it will be checked—and if invalid, the connection + will be dropped. + default: demand + mysql-uri: + type: string + description: | + Mysql URI with the following format: + mysql://:@:/ diff --git a/installers/charm/osm-keystone/lib/charms/observability_libs/v0/kubernetes_service_patch.py b/installers/charm/osm-keystone/lib/charms/observability_libs/v0/kubernetes_service_patch.py new file mode 100644 index 00000000..39b364b1 --- /dev/null +++ b/installers/charm/osm-keystone/lib/charms/observability_libs/v0/kubernetes_service_patch.py @@ -0,0 +1,253 @@ +# Copyright 2021 Canonical Ltd. +# +# 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. +# + +"""# KubernetesServicePatch Library. + +This library is designed to enable developers to more simply patch the Kubernetes Service created +by Juju during the deployment of a sidecar charm. When sidecar charms are deployed, Juju creates a +service named after the application in the namespace (named after the Juju model). This service by +default contains a "placeholder" port, which is 65536/TCP. + +When modifying the default set of resources managed by Juju, one must consider the lifecycle of the +charm. In this case, any modifications to the default service (created during deployment), will +be overwritten during a charm upgrade. + +When intialised, this library binds a handler to the parent charm's `install` and `upgrade_charm` +events which applies the patch to the cluster. This should ensure that the service ports are +correct throughout the charm's life. + +The constructor simply takes a reference to the parent charm, and a list of tuples that each define +a port for the service, where each tuple contains: + +- a name for the port +- port for the service to listen on +- optionally: a targetPort for the service (the port in the container!) +- optionally: a nodePort for the service (for NodePort or LoadBalancer services only!) +- optionally: a name of the service (in case service name needs to be patched as well) + +## Getting Started + +To get started using the library, you just need to fetch the library using `charmcraft`. **Note +that you also need to add `lightkube` and `lightkube-models` to your charm's `requirements.txt`.** + +```shell +cd some-charm +charmcraft fetch-lib charms.observability_libs.v0.kubernetes_service_patch +echo <<-EOF >> requirements.txt +lightkube +lightkube-models +EOF +``` + +Then, to initialise the library: + +For ClusterIP services: +```python +# ... +from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch + +class SomeCharm(CharmBase): + def __init__(self, *args): + # ... + self.service_patcher = KubernetesServicePatch(self, [(f"{self.app.name}", 8080)]) + # ... +``` + +For LoadBalancer/NodePort services: +```python +# ... +from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch + +class SomeCharm(CharmBase): + def __init__(self, *args): + # ... + self.service_patcher = KubernetesServicePatch( + self, [(f"{self.app.name}", 443, 443, 30666)], "LoadBalancer" + ) + # ... +``` + +Additionally, you may wish to use mocks in your charm's unit testing to ensure that the library +does not try to make any API calls, or open any files during testing that are unlikely to be +present, and could break your tests. The easiest way to do this is during your test `setUp`: + +```python +# ... + +@patch("charm.KubernetesServicePatch", lambda x, y: None) +def setUp(self, *unused): + self.harness = Harness(SomeCharm) + # ... +``` +""" + +import logging +from types import MethodType +from typing import Literal, Sequence, Tuple, Union + +from lightkube import ApiError, Client +from lightkube.models.core_v1 import ServicePort, ServiceSpec +from lightkube.models.meta_v1 import ObjectMeta +from lightkube.resources.core_v1 import Service +from lightkube.types import PatchType +from ops.charm import CharmBase +from ops.framework import Object + +logger = logging.getLogger(__name__) + +# The unique Charmhub library identifier, never change it +LIBID = "0042f86d0a874435adef581806cddbbb" + +# 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 = 5 + +PortDefinition = Union[Tuple[str, int], Tuple[str, int, int], Tuple[str, int, int, int]] +ServiceType = Literal["ClusterIP", "LoadBalancer"] + + +class KubernetesServicePatch(Object): + """A utility for patching the Kubernetes service set up by Juju.""" + + def __init__( + self, + charm: CharmBase, + ports: Sequence[PortDefinition], + service_name: str = None, + service_type: ServiceType = "ClusterIP", + ): + """Constructor for KubernetesServicePatch. + + Args: + charm: the charm that is instantiating the library. + ports: a list of tuples (name, port, targetPort, nodePort) for every service port. + service_name: allows setting custom name to the patched service. If none given, + application name will be used. + service_type: desired type of K8s service. Default value is in line with ServiceSpec's + default value. + """ + super().__init__(charm, "kubernetes-service-patch") + self.charm = charm + self.service_name = service_name if service_name else self._app + self.service = self._service_object(ports, service_name, service_type) + + # Make mypy type checking happy that self._patch is a method + assert isinstance(self._patch, MethodType) + # Ensure this patch is applied during the 'install' and 'upgrade-charm' events + self.framework.observe(charm.on.install, self._patch) + self.framework.observe(charm.on.upgrade_charm, self._patch) + + def _service_object( + self, + ports: Sequence[PortDefinition], + service_name: str = None, + service_type: ServiceType = "ClusterIP", + ) -> Service: + """Creates a valid Service representation for Alertmanager. + + Args: + ports: a list of tuples of the form (name, port) or (name, port, targetPort) + or (name, port, targetPort, nodePort) for every service port. If the 'targetPort' + is omitted, it is assumed to be equal to 'port', with the exception of NodePort + and LoadBalancer services, where all port numbers have to be specified. + service_name: allows setting custom name to the patched service. If none given, + application name will be used. + service_type: desired type of K8s service. Default value is in line with ServiceSpec's + default value. + + Returns: + Service: A valid representation of a Kubernetes Service with the correct ports. + """ + if not service_name: + service_name = self._app + return Service( + apiVersion="v1", + kind="Service", + metadata=ObjectMeta( + namespace=self._namespace, + name=service_name, + labels={"app.kubernetes.io/name": service_name}, + ), + spec=ServiceSpec( + selector={"app.kubernetes.io/name": service_name}, + ports=[ + ServicePort( + name=p[0], + port=p[1], + targetPort=p[2] if len(p) > 2 else p[1], # type: ignore[misc] + nodePort=p[3] if len(p) > 3 else None, # type: ignore[arg-type, misc] + ) + for p in ports + ], + type=service_type, + ), + ) + + def _patch(self, _) -> None: + """Patch the Kubernetes service created by Juju to map the correct port. + + Raises: + PatchFailed: if patching fails due to lack of permissions, or otherwise. + """ + if not self.charm.unit.is_leader(): + return + + client = Client() + try: + client.patch(Service, self._app, self.service, patch_type=PatchType.MERGE) + except ApiError as e: + if e.status.code == 403: + logger.error("Kubernetes service patch failed: `juju trust` this application.") + else: + logger.error("Kubernetes service patch failed: %s", str(e)) + else: + logger.info("Kubernetes service '%s' patched successfully", self._app) + + def is_patched(self) -> bool: + """Reports if the service patch has been applied. + + Returns: + bool: A boolean indicating if the service patch has been applied. + """ + client = Client() + # Get the relevant service from the cluster + service = client.get(Service, name=self.service_name, namespace=self._namespace) + # Construct a list of expected ports, should the patch be applied + expected_ports = [(p.port, p.targetPort) for p in self.service.spec.ports] + # Construct a list in the same manner, using the fetched service + fetched_ports = [(p.port, p.targetPort) for p in service.spec.ports] # type: ignore[attr-defined] # noqa: E501 + return expected_ports == fetched_ports + + @property + def _app(self) -> str: + """Name of the current Juju application. + + Returns: + str: A string containing the name of the current Juju application. + """ + return self.charm.app.name + + @property + def _namespace(self) -> str: + """The Kubernetes namespace we're running in. + + Returns: + str: A string containing the name of the current Kubernetes namespace. + """ + with open("/var/run/secrets/kubernetes.io/serviceaccount/namespace", "r") as f: + return f.read().strip() diff --git a/installers/charm/osm-keystone/metadata.yaml b/installers/charm/osm-keystone/metadata.yaml new file mode 100644 index 00000000..61a412ba --- /dev/null +++ b/installers/charm/osm-keystone/metadata.yaml @@ -0,0 +1,45 @@ +# Copyright 2021 Canonical Ltd. +# +# 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. +# + +name: osm-keystone +display-name: Keystone +description: | + Keystone operator used for Charmed OSM + +summary: | + Keystone operator used for Charmed OSM + +containers: + keystone: + resource: keystone-image + +resources: + keystone-image: + type: oci-image + description: OCI image for Keystone + upstream-source: opensourcemano/keystone:testing-daily + +requires: + db: + interface: mysql + limit: 1 + +peers: + cluster: + interface: cluster + +provides: + keystone: + interface: keystone diff --git a/installers/charm/osm-keystone/pyproject.toml b/installers/charm/osm-keystone/pyproject.toml new file mode 100644 index 00000000..af62f24a --- /dev/null +++ b/installers/charm/osm-keystone/pyproject.toml @@ -0,0 +1,54 @@ +# Copyright 2021 Canonical Ltd. +# +# 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. +# + +# 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 = 99 +target-version = ["py38"] + +[tool.isort] +profile = "black" + +# 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"] +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" + +[tool.bandit] +tests = ["B201", "B301"] diff --git a/installers/charm/osm-keystone/requirements.txt b/installers/charm/osm-keystone/requirements.txt new file mode 100644 index 00000000..4284431b --- /dev/null +++ b/installers/charm/osm-keystone/requirements.txt @@ -0,0 +1,23 @@ +# Copyright 2021 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# +# To get in touch with the maintainers, please contact: +# osm-charmers@lists.launchpad.net +ops < 2.2 +git+https://github.com/charmed-osm/config-validator/ +lightkube +lightkube-models \ No newline at end of file diff --git a/installers/charm/osm-keystone/src/charm.py b/installers/charm/osm-keystone/src/charm.py new file mode 100755 index 00000000..c368ade3 --- /dev/null +++ b/installers/charm/osm-keystone/src/charm.py @@ -0,0 +1,443 @@ +#!/usr/bin/env python3 +# Copyright 2021 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# +# To get in touch with the maintainers, please contact: +# osm-charmers@lists.launchpad.net +# +# +# This file populates the Actions tab on Charmhub. +# See https://juju.is/docs/some-url-to-be-determined/ for a checklist and guidance. + +"""Keystone charm module.""" + +import logging +from datetime import datetime + +from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch +from config_validator import ValidationError +from ops import pebble +from ops.charm import ActionEvent, CharmBase, ConfigChangedEvent, UpdateStatusEvent +from ops.main import main +from ops.model import ActiveStatus, BlockedStatus, Container, MaintenanceStatus + +import cluster +from config import ConfigModel, MysqlConnectionData, get_environment, validate_config +from interfaces import KeystoneServer, MysqlClient + +logger = logging.getLogger(__name__) + + +# We expect the keystone container to use the default port +PORT = 5000 + +KEY_SETUP_FILE = "/etc/keystone/key-setup" +CREDENTIAL_KEY_REPOSITORY = "/etc/keystone/credential-keys/" +FERNET_KEY_REPOSITORY = "/etc/keystone/fernet-keys/" +KEYSTONE_USER = "keystone" +KEYSTONE_GROUP = "keystone" +FERNET_MAX_ACTIVE_KEYS = 3 +KEYSTONE_FOLDER = "/etc/keystone/" + + +class CharmError(Exception): + """Charm error exception.""" + + +class KeystoneCharm(CharmBase): + """Keystone Charm operator.""" + + on = cluster.ClusterEvents() + + def __init__(self, *args) -> None: + super().__init__(*args) + event_observe_mapping = { + self.on.keystone_pebble_ready: self._on_config_changed, + self.on.config_changed: self._on_config_changed, + self.on.update_status: self._on_update_status, + self.on.cluster_keys_changed: self._on_cluster_keys_changed, + self.on["keystone"].relation_joined: self._publish_keystone_info, + self.on["db"].relation_changed: self._on_config_changed, + self.on["db"].relation_broken: self._on_config_changed, + self.on["db-sync"].action: self._on_db_sync_action, + } + for event, observer in event_observe_mapping.items(): + self.framework.observe(event, observer) + self.cluster = cluster.Cluster(self) + self.mysql_client = MysqlClient(self, relation_name="db") + self.keystone = KeystoneServer(self, relation_name="keystone") + self.service_patch = KubernetesServicePatch(self, [(f"{self.app.name}", PORT)]) + + @property + def container(self) -> Container: + """Property to get keystone container.""" + return self.unit.get_container("keystone") + + def _on_db_sync_action(self, event: ActionEvent): + process = self.container.exec(["keystone-manage", "db_sync"]) + try: + process.wait() + event.set_results({"output": "db-sync was successfully executed."}) + except pebble.ExecError as e: + error_message = f"db-sync action failed with code {e.exit_code} and stderr {e.stderr}." + logger.error(error_message) + event.fail(error_message) + + def _publish_keystone_info(self, _): + """Handler for keystone-relation-joined.""" + if self.unit.is_leader(): + config = ConfigModel(**dict(self.config)) + self.keystone.publish_info( + host=f"http://{self.app.name}:{PORT}/v3", + port=PORT, + user_domain_name=config.user_domain_name, + project_domain_name=config.project_domain_name, + username=config.service_username, + password=config.service_password, + service=config.service_project, + keystone_db_password=config.keystone_db_password, + region_id=config.region_id, + admin_username=config.admin_username, + admin_password=config.admin_password, + admin_project_name=config.admin_project, + ) + + def _on_config_changed(self, _: ConfigChangedEvent) -> None: + """Handler for config-changed event.""" + if self.container.can_connect(): + try: + self._handle_fernet_key_rotation() + self._safe_restart() + self.unit.status = ActiveStatus() + except CharmError as e: + self.unit.status = BlockedStatus(str(e)) + except ValidationError as e: + self.unit.status = BlockedStatus(str(e)) + else: + logger.info("pebble socket not available, deferring config-changed") + self.unit.status = MaintenanceStatus("waiting for pebble to start") + + def _on_update_status(self, event: UpdateStatusEvent) -> None: + """Handler for update-status event.""" + if self.container.can_connect(): + self._handle_fernet_key_rotation() + else: + logger.info("pebble socket not available, deferring config-changed") + event.defer() + self.unit.status = MaintenanceStatus("waiting for pebble to start") + + def _on_cluster_keys_changed(self, _) -> None: + """Handler for ClusterKeysChanged event.""" + self._handle_fernet_key_rotation() + + def _handle_fernet_key_rotation(self) -> None: + """Handles fernet key rotation. + + First, the function writes the existing keys in the relation to disk. + Then, if the unit is the leader, checks if the keys should be rotated + or not. + """ + self._key_write() + if self.unit.is_leader(): + if not self.cluster.get_keys(): + self._key_setup() + self._fernet_keys_rotate_and_sync() + + def _key_write(self) -> None: + """Write keys to container from the relation data.""" + if self.unit.is_leader(): + return + keys = self.cluster.get_keys() + if not keys: + logger.debug('"key_repository" not in relation data yet...') + return + + for key_repository in [FERNET_KEY_REPOSITORY, CREDENTIAL_KEY_REPOSITORY]: + self._create_keys_folders() + for key_number, key in keys[key_repository].items(): + logger.debug(f"writing key {key_number} in {key_repository}") + file_path = f"{key_repository}{key_number}" + if self._file_changed(file_path, key): + self.container.push( + file_path, + key, + user=KEYSTONE_USER, + group=KEYSTONE_GROUP, + permissions=0o600, + ) + self.container.push(KEY_SETUP_FILE, "") + + def _file_changed(self, file_path: str, content: str) -> bool: + """Check if file in container has changed its value. + + This function checks if the file exists in the container. If it does, + then it checks if the content of that file is equal to the content passed to + this function. If the content is equal, the function returns False, otherwise True. + + Args: + file_path (str): File path in the container. + content (str): Content of the file. + + Returns: + bool: True if the content of the file has changed, or the file doesn't exist in + the container. False if the content passed to this function is the same as + in the container. + """ + if self._file_exists(file_path): + old_content = self.container.pull(file_path).read() + if old_content == content: + return False + return True + + def _create_keys_folders(self) -> None: + """Create folders for Key repositories.""" + fernet_key_repository_found = False + credential_key_repository_found = False + for file in self.container.list_files(KEYSTONE_FOLDER): + if file.type == pebble.FileType.DIRECTORY: + if file.path == CREDENTIAL_KEY_REPOSITORY: + credential_key_repository_found = True + if file.path == FERNET_KEY_REPOSITORY: + fernet_key_repository_found = True + if not fernet_key_repository_found: + self.container.make_dir( + FERNET_KEY_REPOSITORY, + user="keystone", + group="keystone", + permissions=0o700, + make_parents=True, + ) + if not credential_key_repository_found: + self.container.make_dir( + CREDENTIAL_KEY_REPOSITORY, + user=KEYSTONE_USER, + group=KEYSTONE_GROUP, + permissions=0o700, + make_parents=True, + ) + + def _fernet_keys_rotate_and_sync(self) -> None: + """Rotate and sync the keys if the unit is the leader and the primary key has expired. + + The modification time of the staging key (key with index '0') is used, + along with the config setting "token-expiration" to determine whether to + rotate the keys. + + The rotation time = token-expiration / (max-active-keys - 2) + where max-active-keys has a minimum of 3. + """ + if not self.unit.is_leader(): + return + try: + fernet_key_file = self.container.list_files(f"{FERNET_KEY_REPOSITORY}0")[0] + last_rotation = fernet_key_file.last_modified.timestamp() + except pebble.APIError: + logger.warning( + "Fernet key rotation requested but key repository not " "initialized yet" + ) + return + + config = ConfigModel(**self.config) + rotation_time = config.token_expiration // (FERNET_MAX_ACTIVE_KEYS - 2) + + now = datetime.now().timestamp() + if last_rotation + rotation_time > now: + # No rotation to do as not reached rotation time + logger.debug("No rotation needed") + self._key_leader_set() + return + # now rotate the keys and sync them + self._fernet_rotate() + self._key_leader_set() + + logger.info("Rotated and started sync of fernet keys") + + def _key_leader_set(self) -> None: + """Read current key sets and update peer relation data. + + The keys are read from the `FERNET_KEY_REPOSITORY` and `CREDENTIAL_KEY_REPOSITORY` + directories. Note that this function will fail if it is called on the unit that is + not the leader. + """ + disk_keys = {} + for key_repository in [FERNET_KEY_REPOSITORY, CREDENTIAL_KEY_REPOSITORY]: + disk_keys[key_repository] = {} + for file in self.container.list_files(key_repository): + key_content = self.container.pull(f"{key_repository}{file.name}").read() + disk_keys[key_repository][file.name] = key_content + self.cluster.save_keys(disk_keys) + + def _fernet_rotate(self) -> None: + """Rotate Fernet keys. + + To rotate the Fernet tokens, and create a new staging key, it calls (as the + "keystone" user): + + keystone-manage fernet_rotate + + Note that we do not rotate the Credential encryption keys. + + Note that this does NOT synchronise the keys between the units. This is + performed in `self._key_leader_set`. + """ + logger.debug("Rotating Fernet tokens") + try: + exec_command = [ + "keystone-manage", + "fernet_rotate", + "--keystone-user", + KEYSTONE_USER, + "--keystone-group", + KEYSTONE_GROUP, + ] + logger.debug(f'Executing command: {" ".join(exec_command)}') + self.container.exec(exec_command).wait() + logger.info("Fernet keys successfully rotated.") + except pebble.ExecError as e: + logger.error(f"Fernet Key rotation failed: {e}") + logger.error("Exited with code %d. Stderr:", e.exit_code) + for line in e.stderr.splitlines(): + logger.error(" %s", line) + + def _key_setup(self) -> None: + """Initialize Fernet and Credential encryption key repositories. + + To setup the key repositories: + + keystone-manage fernet_setup + keystone-manage credential_setup + + In addition we migrate any credentials currently stored in database using + the null key to be encrypted by the new credential key: + + keystone-manage credential_migrate + + Note that we only want to do this once, so we touch an empty file + (KEY_SETUP_FILE) to indicate that it has been done. + """ + if self._file_exists(KEY_SETUP_FILE) or not self.unit.is_leader(): + return + + logger.debug("Setting up key repositories for Fernet tokens and Credential encryption.") + try: + for command in ["fernet_setup", "credential_setup"]: + exec_command = [ + "keystone-manage", + command, + "--keystone-user", + KEYSTONE_USER, + "--keystone-group", + KEYSTONE_GROUP, + ] + logger.debug(f'Executing command: {" ".join(exec_command)}') + self.container.exec(exec_command).wait() + self.container.push(KEY_SETUP_FILE, "") + logger.info("Key repositories initialized successfully.") + except pebble.ExecError as e: + logger.error("Failed initializing key repositories.") + logger.error("Exited with code %d. Stderr:", e.exit_code) + for line in e.stderr.splitlines(): + logger.error(" %s", line) + + def _file_exists(self, path: str) -> bool: + """Check if a file exists in the container. + + Args: + path (str): Path of the file to be checked. + + Returns: + bool: True if the file exists, else False. + """ + file_exists = None + try: + _ = self.container.pull(path) + file_exists = True + except pebble.PathError: + file_exists = False + exist_str = "exists" if file_exists else 'doesn"t exist' + logger.debug(f"File {path} {exist_str}.") + return file_exists + + def _safe_restart(self) -> None: + """Safely restart the keystone service. + + This function (re)starts the keystone service after doing some safety checks, + like validating the charm configuration, checking the mysql relation is ready. + """ + validate_config(self.config) + self._check_mysql_data() + # Workaround: OS_AUTH_URL is not ready when the entrypoint restarts apache2. + # The function `self._patch_entrypoint` fixes that. + self._patch_entrypoint() + self._replan() + + def _patch_entrypoint(self) -> None: + """Patches the entrypoint of the Keystone service. + + The entrypoint that restarts apache2, expects immediate communication to OS_AUTH_URL. + This does not happen instantly. This function patches the entrypoint to wait until a + curl to OS_AUTH_URL succeeds. + """ + installer_script = self.container.pull("/app/start.sh").read() + wait_until_ready_command = "until $(curl --output /dev/null --silent --head --fail $OS_AUTH_URL); do echo '...'; sleep 5; done" + self.container.push( + "/app/start-patched.sh", + installer_script.replace( + "source setup_env", f"source setup_env && {wait_until_ready_command}" + ), + permissions=0o755, + ) + + def _check_mysql_data(self) -> None: + """Check if the mysql relation is ready. + + Raises: + CharmError: Error raised if the mysql relation is not ready. + """ + if self.mysql_client.is_missing_data_in_unit() and not self.config.get("mysql-uri"): + raise CharmError("mysql relation is missing") + + def _replan(self) -> None: + """Replan keystone service. + + This function starts the keystone service if it is not running. + If the service started already, this function will restart the + service if there are any changes to the layer. + """ + mysql_data = MysqlConnectionData( + self.config.get("mysql-uri") + or f"mysql://root:{self.mysql_client.root_password}@{self.mysql_client.host}:{self.mysql_client.port}/" + ) + layer = { + "summary": "keystone layer", + "description": "pebble config layer for keystone", + "services": { + "keystone": { + "override": "replace", + "summary": "keystone service", + "command": "/app/start-patched.sh", + "startup": "enabled", + "environment": get_environment(self.app.name, self.config, mysql_data), + } + }, + } + self.container.add_layer("keystone", layer, combine=True) + self.container.replan() + + +if __name__ == "__main__": # pragma: no cover + main(KeystoneCharm) diff --git a/installers/charm/osm-keystone/src/cluster.py b/installers/charm/osm-keystone/src/cluster.py new file mode 100644 index 00000000..f38adec0 --- /dev/null +++ b/installers/charm/osm-keystone/src/cluster.py @@ -0,0 +1,135 @@ +# Copyright 2021 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# +# To get in touch with the maintainers, please contact: +# osm-charmers@lists.launchpad.net +# +# +# This file populates the Actions tab on Charmhub. +# See https://juju.is/docs/some-url-to-be-determined/ for a checklist and guidance. + +"""Keystone cluster library. + +This library allows the integration with Apache Guacd charm. Is is published as part of the +[davigar15-apache-guacd]((https://charmhub.io/davigar15-apache-guacd) charm. + +The charm that requires guacd should include the following content in its metadata.yaml: + +```yaml +# ... +peers: + cluster: + interface: cluster +# ... +``` + +A typical example of including this library might be: + +```python +# ... +from ops.framework import StoredState +from charms.keystone.v0 import cluster + +class SomeApplication(CharmBase): + on = cluster.ClusterEvents() + + def __init__(self, *args): + # ... + self.cluster = cluster.Cluster(self) + self.framework.observe(self.on.cluster_keys_changed, self._cluster_keys_changed) + # ... + + def _cluster_keys_changed(self, _): + fernet_keys = self.cluster.fernet_keys + credential_keys = self.cluster.credential_keys + # ... +``` +""" + + +import json +import logging +from typing import Any, Dict, List + +from ops.charm import CharmEvents +from ops.framework import EventBase, EventSource, Object +from ops.model import Relation + +# Number of keys need might need to be adjusted in the future +NUMBER_FERNET_KEYS = 2 +NUMBER_CREDENTIAL_KEYS = 2 + +logger = logging.getLogger(__name__) + + +class ClusterKeysChangedEvent(EventBase): + """Event to announce a change in the Guacd service.""" + + +class ClusterEvents(CharmEvents): + """Cluster Events.""" + + cluster_keys_changed = EventSource(ClusterKeysChangedEvent) + + +class Cluster(Object): + """Peer relation.""" + + def __init__(self, charm): + super().__init__(charm, "cluster") + self.charm = charm + + @property + def fernet_keys(self) -> List[str]: + """Fernet keys.""" + relation: Relation = self.model.get_relation("cluster") + application_data = relation.data[self.model.app] + return json.loads(application_data.get("keys-fernet", "[]")) + + @property + def credential_keys(self) -> List[str]: + """Credential keys.""" + relation: Relation = self.model.get_relation("cluster") + application_data = relation.data[self.model.app] + return json.loads(application_data.get("keys-credential", "[]")) + + def save_keys(self, keys: Dict[str, Any]) -> None: + """Generate fernet and credential keys. + + This method will generate new keys and fire the cluster_keys_changed event. + """ + logger.debug("Saving keys...") + relation: Relation = self.model.get_relation("cluster") + data = relation.data[self.model.app] + current_keys_str = data.get("key_repository", "{}") + current_keys = json.loads(current_keys_str) + if current_keys != keys: + data["key_repository"] = json.dumps(keys) + self.charm.on.cluster_keys_changed.emit() + logger.info("Keys saved!") + + def get_keys(self) -> Dict[str, Any]: + """Get keys from the relation. + + Returns: + Dict[str, Any]: Dictionary with the keys. + """ + relation: Relation = self.model.get_relation("cluster") + data = relation.data[self.model.app] + current_keys_str = data.get("key_repository", "{}") + current_keys = json.loads(current_keys_str) + return current_keys diff --git a/installers/charm/osm-keystone/src/config.py b/installers/charm/osm-keystone/src/config.py new file mode 100644 index 00000000..803d5646 --- /dev/null +++ b/installers/charm/osm-keystone/src/config.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +# Copyright 2021 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# +# To get in touch with the maintainers, please contact: +# osm-charmers@lists.launchpad.net +# +# +# This file populates the Actions tab on Charmhub. +# See https://juju.is/docs/some-url-to-be-determined/ for a checklist and guidance. + +"""Module that takes take of the charm configuration.""" + +import re +from typing import Any, Dict, Optional + +from config_validator import ConfigValidator, ValidationError +from ops.model import ConfigData + + +class MysqlConnectionData: + """Mysql Connection Data class.""" + + _compiled_regex = re.compile( + r"^mysql\:\/\/{}@{}\/{}?$".format( + r"(?P[_\w]+):(?P[\w\W]+)", + r"(?P[\-\.\w]+):(?P\d+)", + r"(?P[_\w]+)", + ) + ) + + def __init__(self, mysql_uri: str): + match = self._compiled_regex.search(mysql_uri) + if not match: + raise ValidationError("mysql_uri is not properly formed") + mysql_data = match.groupdict() + self.host = mysql_data.get("host") + self.port = int(mysql_data.get("port")) + self.username = mysql_data.get("username") + self.password = mysql_data.get("password") + self.database = mysql_data.get("database") + self.uri = mysql_uri + + +def validate_config(config: ConfigData): + """Validate charm configuration. + + Args: + config (ConfigData): Charm configuration. + + Raises: + config_validator.ValidationError if the validation failed. + """ + kwargs: Dict[str, Any] = config + ConfigModel(**kwargs) + ConfigLdapModel(**kwargs) + + +def get_environment( + service_name: str, config: ConfigData, mysql_data: MysqlConnectionData +) -> Dict[str, Any]: + """Get environment variables. + + Args: + service_name (str): Cluster IP service name. + config (ConfigData): Charm configuration. + + Returns: + Dict[str, Any]: Dictionary with the environment variables for Keystone service. + """ + kwargs: Dict[str, Any] = config + config = ConfigModel(**kwargs) + config_ldap = ConfigLdapModel(**kwargs) + environment = { + "DB_HOST": mysql_data.host, + "DB_PORT": mysql_data.port, + "ROOT_DB_USER": mysql_data.username, + "ROOT_DB_PASSWORD": mysql_data.password, + "REGION_ID": config.region_id, + "KEYSTONE_HOST": service_name, + "KEYSTONE_DB_PASSWORD": config.keystone_db_password, + "ADMIN_USERNAME": config.admin_username, + "ADMIN_PASSWORD": config.admin_password, + "ADMIN_PROJECT": config.admin_project, + "SERVICE_USERNAME": config.service_username, + "SERVICE_PASSWORD": config.service_password, + "SERVICE_PROJECT": config.service_project, + } + if config_ldap.ldap_enabled: + environment.update( + { + "LDAP_AUTHENTICATION_DOMAIN_NAME": config_ldap.ldap_authentication_domain_name, + "LDAP_URL": config_ldap.ldap_url, + "LDAP_PAGE_SIZE": str(config_ldap.ldap_page_size), + "LDAP_USER_OBJECTCLASS": config_ldap.ldap_user_objectclass, + "LDAP_USER_ID_ATTRIBUTE": config_ldap.ldap_user_id_attribute, + "LDAP_USER_NAME_ATTRIBUTE": config_ldap.ldap_user_name_attribute, + "LDAP_USER_PASS_ATTRIBUTE": config_ldap.ldap_user_pass_attribute, + "LDAP_USER_ENABLED_MASK": str(config_ldap.ldap_user_enabled_mask), + "LDAP_USER_ENABLED_DEFAULT": config_ldap.ldap_user_enabled_default, + "LDAP_USER_ENABLED_INVERT": str(config_ldap.ldap_user_enabled_invert), + "LDAP_GROUP_OBJECTCLASS": config_ldap.ldap_group_objectclass, + } + ) + if config_ldap.ldap_use_starttls: + environment.update( + { + "LDAP_USE_STARTTLS": str(config_ldap.ldap_use_starttls), + "LDAP_TLS_CACERT_BASE64": config_ldap.ldap_tls_cacert_base64, + "LDAP_TLS_REQ_CERT": config_ldap.ldap_tls_req_cert, + } + ) + optional_ldap_configs = { + "LDAP_BIND_USER": config_ldap.ldap_bind_user, + "LDAP_BIND_PASSWORD": config_ldap.ldap_bind_password, + "LDAP_USER_TREE_DN": config_ldap.ldap_user_tree_dn, + "LDAP_USER_FILTER": config_ldap.ldap_user_filter, + "LDAP_USER_ENABLED_ATTRIBUTE": config_ldap.ldap_user_enabled_attribute, + "LDAP_CHASE_REFERRALS": config_ldap.ldap_chase_referrals, + "LDAP_GROUP_TREE_DN": config_ldap.ldap_group_tree_dn, + "LDAP_TLS_CACERT_BASE64": config_ldap.ldap_tls_cacert_base64, + } + for env, value in optional_ldap_configs.items(): + if value: + environment[env] = value + return environment + + +class ConfigModel(ConfigValidator): + """Keystone Configuration.""" + + region_id: str + keystone_db_password: str + admin_username: str + admin_password: str + admin_project: str + service_username: str + service_password: str + service_project: str + user_domain_name: str + project_domain_name: str + token_expiration: int + mysql_uri: Optional[str] + + +class ConfigLdapModel(ConfigValidator): + """LDAP Configuration.""" + + ldap_enabled: bool + ldap_authentication_domain_name: Optional[str] + ldap_url: Optional[str] + ldap_bind_user: Optional[str] + ldap_bind_password: Optional[str] + ldap_chase_referrals: Optional[str] + ldap_page_size: Optional[int] + ldap_user_tree_dn: Optional[str] + ldap_user_objectclass: Optional[str] + ldap_user_id_attribute: Optional[str] + ldap_user_name_attribute: Optional[str] + ldap_user_pass_attribute: Optional[str] + ldap_user_filter: Optional[str] + ldap_user_enabled_attribute: Optional[str] + ldap_user_enabled_mask: Optional[int] + ldap_user_enabled_default: Optional[str] + ldap_user_enabled_invert: Optional[bool] + ldap_group_objectclass: Optional[str] + ldap_group_tree_dn: Optional[str] + ldap_use_starttls: Optional[bool] + ldap_tls_cacert_base64: Optional[str] + ldap_tls_req_cert: Optional[str] diff --git a/installers/charm/osm-keystone/src/interfaces.py b/installers/charm/osm-keystone/src/interfaces.py new file mode 100644 index 00000000..7b019dd7 --- /dev/null +++ b/installers/charm/osm-keystone/src/interfaces.py @@ -0,0 +1,190 @@ +# Copyright 2021 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# +# To get in touch with the maintainers, please contact: +# osm-charmers@lists.launchpad.net +# +# +# This file populates the Actions tab on Charmhub. +# See https://juju.is/docs/some-url-to-be-determined/ for a checklist and guidance. + +"""Interfaces used by this charm.""" + +import ops.charm +import ops.framework +import ops.model + + +class BaseRelationClient(ops.framework.Object): + """Requires side of a Kafka Endpoint.""" + + def __init__( + self, + charm: ops.charm.CharmBase, + relation_name: str, + mandatory_fields: list = [], + ): + super().__init__(charm, relation_name) + self.relation_name = relation_name + self.mandatory_fields = mandatory_fields + self._update_relation() + + def get_data_from_unit(self, key: str): + """Get data from unit relation data.""" + if not self.relation: + # This update relation doesn't seem to be needed, but I added it because apparently + # the data is empty in the unit tests. + # In reality, the constructor is called in every hook. + # In the unit tests when doing an update_relation_data, apparently it is not called. + self._update_relation() + if self.relation: + for unit in self.relation.units: + data = self.relation.data[unit].get(key) + if data: + return data + + def get_data_from_app(self, key: str): + """Get data from app relation data.""" + if not self.relation or self.relation.app not in self.relation.data: + # This update relation doesn't seem to be needed, but I added it because apparently + # the data is empty in the unit tests. + # In reality, the constructor is called in every hook. + # In the unit tests when doing an update_relation_data, apparently it is not called. + self._update_relation() + if self.relation and self.relation.app in self.relation.data: + data = self.relation.data[self.relation.app].get(key) + if data: + return data + + def is_missing_data_in_unit(self): + """Check if mandatory fields are present in any of the unit's relation data.""" + return not all([self.get_data_from_unit(field) for field in self.mandatory_fields]) + + def is_missing_data_in_app(self): + """Check if mandatory fields are set in relation data.""" + return not all([self.get_data_from_app(field) for field in self.mandatory_fields]) + + def _update_relation(self): + self.relation = self.framework.model.get_relation(self.relation_name) + + +class MysqlClient(BaseRelationClient): + """Requires side of a Mysql Endpoint.""" + + mandatory_fields = ["host", "port", "user", "password", "root_password"] + + def __init__(self, charm: ops.charm.CharmBase, relation_name: str): + super().__init__(charm, relation_name, self.mandatory_fields) + + @property + def host(self): + """Host.""" + return self.get_data_from_unit("host") + + @property + def port(self): + """Port.""" + return self.get_data_from_unit("port") + + @property + def user(self): + """User.""" + return self.get_data_from_unit("user") + + @property + def password(self): + """Password.""" + return self.get_data_from_unit("password") + + @property + def root_password(self): + """Root password.""" + return self.get_data_from_unit("root_password") + + @property + def database(self): + """Database.""" + return self.get_data_from_unit("database") + + def get_root_uri(self, database: str): + """Get the URI for the mysql connection with the root user credentials. + + Args: + database: Database name + + Return: + A string with the following format: + mysql://root:@:/ + """ + return "mysql://root:{}@{}:{}/{}".format( + self.root_password, self.host, self.port, database + ) + + def get_uri(self): + """Get the URI for the mysql connection with the standard user credentials. + + Args: + database: Database name + Return: + A string with the following format: + mysql://:@:/ + """ + return "mysql://{}:{}@{}:{}/{}".format( + self.user, self.password, self.host, self.port, self.database + ) + + +class KeystoneServer(ops.framework.Object): + """Provides side of a Keystone Endpoint.""" + + relation_name: str = None + + def __init__(self, charm: ops.charm.CharmBase, relation_name: str): + super().__init__(charm, relation_name) + self.relation_name = relation_name + + def publish_info( + self, + host: str, + port: int, + user_domain_name: str, + project_domain_name: str, + username: str, + password: str, + service: str, + keystone_db_password: str, + region_id: str, + admin_username: str, + admin_password: str, + admin_project_name: str, + ): + """Publish information in Keystone relation.""" + if self.framework.model.unit.is_leader(): + for relation in self.framework.model.relations[self.relation_name]: + relation_data = relation.data[self.framework.model.app] + relation_data["host"] = str(host) + relation_data["port"] = str(port) + relation_data["user_domain_name"] = str(user_domain_name) + relation_data["project_domain_name"] = str(project_domain_name) + relation_data["username"] = str(username) + relation_data["password"] = str(password) + relation_data["service"] = str(service) + relation_data["keystone_db_password"] = str(keystone_db_password) + relation_data["region_id"] = str(region_id) + relation_data["admin_username"] = str(admin_username) + relation_data["admin_password"] = str(admin_password) + relation_data["admin_project_name"] = str(admin_project_name) diff --git a/installers/charm/osm-keystone/tests/integration/test_charm.py b/installers/charm/osm-keystone/tests/integration/test_charm.py new file mode 100644 index 00000000..7e985427 --- /dev/null +++ b/installers/charm/osm-keystone/tests/integration/test_charm.py @@ -0,0 +1,51 @@ +# Copyright 2021 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# + +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()) + + +@pytest.mark.abort_on_fail +async def test_build_and_deploy(ops_test: OpsTest): + """Build the charm-under-test and deploy it together with related charms. + + Assert on the unit status before any relations/configurations take place. + """ + await ops_test.model.set_config({"update-status-hook-interval": "10s"}) + await ops_test.model.deploy("charmed-osm-mariadb-k8s", application_name="mariadb-k8s") + # build and deploy charm from local source folder + charm = await ops_test.build_charm(".") + resources = { + "keystone-image": METADATA["resources"]["keystone-image"]["upstream-source"], + } + await ops_test.model.deploy(charm, resources=resources, application_name="keystone") + await ops_test.model.add_relation("keystone:db", "mariadb-k8s:mysql") + await ops_test.model.wait_for_idle( + apps=["keystone", "mariadb-k8s"], status="active", timeout=1000 + ) + assert ops_test.model.applications["keystone"].units[0].workload_status == "active" + + await ops_test.model.set_config({"update-status-hook-interval": "60m"}) diff --git a/installers/charm/osm-keystone/tests/unit/test_charm.py b/installers/charm/osm-keystone/tests/unit/test_charm.py new file mode 100644 index 00000000..7207b63e --- /dev/null +++ b/installers/charm/osm-keystone/tests/unit/test_charm.py @@ -0,0 +1,136 @@ +# Copyright 2021 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# + +import pytest +from ops import pebble +from ops.model import ActiveStatus, BlockedStatus +from ops.testing import Harness +from pytest_mock import MockerFixture + +from charm import FERNET_KEY_REPOSITORY, KEYSTONE_FOLDER, KeystoneCharm + + +@pytest.fixture +def harness_no_relations(mocker: MockerFixture): + mocker.patch("charm.cluster") + mocker.patch("charm.KubernetesServicePatch") + keystone_harness = Harness(KeystoneCharm) + keystone_harness.begin() + container = keystone_harness.charm.unit.get_container("keystone") + keystone_harness.set_can_connect(container, True) + container.make_dir(KEYSTONE_FOLDER, make_parents=True) + container.make_dir(FERNET_KEY_REPOSITORY, make_parents=True) + container.push(f"{FERNET_KEY_REPOSITORY}0", "token") + container.make_dir("/app", make_parents=True) + container.push("/app/start.sh", "") + container.exec = mocker.Mock() + yield keystone_harness + keystone_harness.cleanup() + + +@pytest.fixture +def harness(harness_no_relations: Harness): + mysql_rel_id = harness_no_relations.add_relation("db", "mysql") + harness_no_relations.add_relation_unit(mysql_rel_id, "mysql/0") + harness_no_relations.update_relation_data( + mysql_rel_id, + "mysql/0", + { + "host": "host", + "port": "3306", + "user": "user", + "root_password": "root_pass", + "password": "password", + "database": "db", + }, + ) + return harness_no_relations + + +def test_mysql_missing_relation(mocker: MockerFixture, harness_no_relations: Harness): + spy_safe_restart = mocker.spy(harness_no_relations.charm, "_safe_restart") + harness_no_relations.charm.on.keystone_pebble_ready.emit("keystone") + assert harness_no_relations.charm.unit.status == BlockedStatus("mysql relation is missing") + assert spy_safe_restart.call_count == 1 + harness_no_relations.charm.on.config_changed.emit() + assert harness_no_relations.charm.unit.status == BlockedStatus("mysql relation is missing") + assert spy_safe_restart.call_count == 2 + + +def test_mysql_relation_ready(mocker: MockerFixture, harness: Harness): + spy = mocker.spy(harness.charm, "_safe_restart") + harness.charm.on.config_changed.emit() + assert harness.charm.unit.status == ActiveStatus() + assert spy.call_count == 1 + + +def test_db_sync_action(mocker: MockerFixture, harness: Harness): + event_mock = mocker.Mock() + harness.charm._on_db_sync_action(event_mock) + event_mock.set_results.assert_called_once_with( + {"output": "db-sync was successfully executed."} + ) + event_mock.fail.assert_not_called() + harness.charm.container.exec().wait.side_effect = pebble.ExecError( + ["keystone-manage", "db_sync"], 1, "", "Error" + ) + harness.charm._on_db_sync_action(event_mock) + event_mock.fail.assert_called_once_with("db-sync action failed with code 1 and stderr Error.") + + +def test_provide_keystone_relation(mocker: MockerFixture, harness: Harness): + # Non-leader + mon_rel_id = harness.add_relation("keystone", "mon") + harness.add_relation_unit(mon_rel_id, "mon/0") + data = harness.get_relation_data(mon_rel_id, harness.charm.app) + assert data == {} + # Leader + harness.set_leader(True) + nbi_rel_id = harness.add_relation("keystone", "nbi") + harness.add_relation_unit(nbi_rel_id, "nbi/0") + data = harness.get_relation_data(nbi_rel_id, harness.charm.app) + assert data == { + "host": "http://osm-keystone:5000/v3", + "port": "5000", + "user_domain_name": "default", + "project_domain_name": "default", + "username": "nbi", + "password": "nbi", + "service": "service", + "keystone_db_password": "admin", + "region_id": "RegionOne", + "admin_username": "admin", + "admin_password": "admin", + "admin_project_name": "admin", + } + + +def test_update_status_rotation(mocker: MockerFixture, harness: Harness): + spy_fernet_rotate = mocker.spy(harness.charm, "_fernet_rotate") + harness.set_leader(True) + harness._update_config({"token-expiration": -1}) + harness.charm.on.update_status.emit() + assert spy_fernet_rotate.call_count == 1 + + +def test_update_status_no_rotation(mocker: MockerFixture, harness: Harness): + spy_fernet_rotate = mocker.spy(harness.charm, "_fernet_rotate") + harness.set_leader(True) + harness._update_config({"token-expiration": 3600}) + harness.charm.on.update_status.emit() + assert spy_fernet_rotate.call_count == 0 diff --git a/installers/charm/osm-keystone/tox.ini b/installers/charm/osm-keystone/tox.ini new file mode 100644 index 00000000..d08fe86c --- /dev/null +++ b/installers/charm/osm-keystone/tox.ini @@ -0,0 +1,111 @@ +# Copyright 2021 Canonical Ltd. +# +# 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. +# + +[tox] +skipsdist=True +skip_missing_interpreters = True +envlist = lint, unit, analyze, integration + +[vars] +src_path = {toxinidir}/src/ +tst_path = {toxinidir}/tests/ +all_path = {[vars]src_path} {[vars]tst_path} + +[testenv] +basepython = python3.8 +setenv = + PYTHONPATH = {toxinidir}:{toxinidir}/lib:{[vars]src_path} + PYTHONBREAKPOINT=ipdb.set_trace +passenv = + PYTHONPATH + HOME + PATH + CHARM_BUILD_DIR + MODEL_SETTINGS + HTTP_PROXY + HTTPS_PROXY + NO_PROXY + +[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 + flake8 + flake8-docstrings + flake8-copyright + flake8-builtins + # prospector[with_everything] + pyproject-flake8 + pep8-naming + isort + codespell + yamllint +commands = + codespell {toxinidir}/*.yaml {toxinidir}/*.ini {toxinidir}/*.md \ + {toxinidir}/*.toml {toxinidir}/*.txt {toxinidir}/.github + # prospector -A -F -T + yamllint -d '\{extends: default, ignore: "build\n.tox" \}' . + # pflake8 wrapper supports config from pyproject.toml + pflake8 {[vars]all_path} + isort --check-only --diff {[vars]all_path} + black --check --diff {[vars]all_path} + +[testenv:unit] +description = Run unit tests +deps = + pytest + pytest-mock + pytest-cov + coverage[toml] + -r{toxinidir}/requirements.txt +commands = + pytest --ignore={[vars]tst_path}integration --cov={[vars]src_path} --cov-report=xml + coverage report --omit=tests/* + +[testenv:analyze] +description = Run analize +deps = + pylint==2.10.2 + -r{toxinidir}/requirements.txt +commands = + pylint -E {[vars]src_path} + +[testenv:security] +description = Run security tests +deps = + bandit + safety +commands = + bandit -r {[vars]src_path} + - safety check + +[testenv:integration] +description = Run integration tests +deps = + pytest + juju<3 + pytest-operator + -r{toxinidir}/requirements.txt +commands = + pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} --cloud microk8s -- 2.17.1