# 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
--- /dev/null
+#!/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
--- /dev/null
+#!/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
--- /dev/null
+# 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="<root>=INFO;unit=DEBUG"
+# Deploy the charm
+juju deploy ./keystone_ubuntu-22.04-amd64.charm \
+ --resource keystone-image=opensourcemano/keystone:testing-daily --series jammy
+```
--- /dev/null
+
+ 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.
--- /dev/null
+# 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.
--- /dev/null
+# 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.
--- /dev/null
+# 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
--- /dev/null
+# 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://<user>:<password>@<mysql_host>:<mysql_port>/<database>
--- /dev/null
+# 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()
--- /dev/null
+# 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
--- /dev/null
+# 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"]
--- /dev/null
+# 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
--- /dev/null
+#!/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)
--- /dev/null
+# 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
--- /dev/null
+#!/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<username>[_\w]+):(?P<password>[\w\W]+)",
+ r"(?P<host>[\-\.\w]+):(?P<port>\d+)",
+ r"(?P<database>[_\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]
--- /dev/null
+# 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:<root_password>@<mysql_host>:<mysql_port>/<database>
+ """
+ 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://<user>:<password>@<mysql_host>:<mysql_port>/<database>
+ """
+ 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)
--- /dev/null
+# 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"})
--- /dev/null
+# 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
--- /dev/null
+# 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