Add Keystone charm 74/13474/9
authorPatricia Reinoso <patricia.reinoso@canonical.com>
Wed, 31 May 2023 08:37:18 +0000 (08:37 +0000)
committerbeierlm <mark.beierl@canonical.com>
Fri, 2 Jun 2023 13:56:22 +0000 (15:56 +0200)
osm-keystone charm was in https://github.com/charmed-osm/keystone-operator

Change-Id: Iba2321b80dfe8aed79cf27d49883bcec671ff223
Signed-off-by: Patricia Reinoso <patricia.reinoso@canonical.com>
20 files changed:
devops-stages/stage-test.sh
installers/charm/osm-keystone/.gitignore [new file with mode: 0644]
installers/charm/osm-keystone/.jujuignore [new file with mode: 0644]
installers/charm/osm-keystone/CONTRIBUTING.md [new file with mode: 0644]
installers/charm/osm-keystone/LICENSE [new file with mode: 0644]
installers/charm/osm-keystone/README.md [new file with mode: 0644]
installers/charm/osm-keystone/actions.yaml [new file with mode: 0644]
installers/charm/osm-keystone/charmcraft.yaml [new file with mode: 0644]
installers/charm/osm-keystone/config.yaml [new file with mode: 0644]
installers/charm/osm-keystone/lib/charms/observability_libs/v0/kubernetes_service_patch.py [new file with mode: 0644]
installers/charm/osm-keystone/metadata.yaml [new file with mode: 0644]
installers/charm/osm-keystone/pyproject.toml [new file with mode: 0644]
installers/charm/osm-keystone/requirements.txt [new file with mode: 0644]
installers/charm/osm-keystone/src/charm.py [new file with mode: 0755]
installers/charm/osm-keystone/src/cluster.py [new file with mode: 0644]
installers/charm/osm-keystone/src/config.py [new file with mode: 0644]
installers/charm/osm-keystone/src/interfaces.py [new file with mode: 0644]
installers/charm/osm-keystone/tests/integration/test_charm.py [new file with mode: 0644]
installers/charm/osm-keystone/tests/unit/test_charm.py [new file with mode: 0644]
installers/charm/osm-keystone/tox.ini [new file with mode: 0644]

index 4635421..1e2f913 100755 (executable)
@@ -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 (file)
index 0000000..87d0a58
--- /dev/null
@@ -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 (file)
index 0000000..17c7a8b
--- /dev/null
@@ -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 (file)
index 0000000..3d86cf8
--- /dev/null
@@ -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="<root>=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 (file)
index 0000000..d645695
--- /dev/null
@@ -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 (file)
index 0000000..08761b9
--- /dev/null
@@ -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 (file)
index 0000000..85ed7e6
--- /dev/null
@@ -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 (file)
index 0000000..c8374f3
--- /dev/null
@@ -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 (file)
index 0000000..7312bb4
--- /dev/null
@@ -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://<user>:<password>@<mysql_host>:<mysql_port>/<database>
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 (file)
index 0000000..39b364b
--- /dev/null
@@ -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 (file)
index 0000000..61a412b
--- /dev/null
@@ -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 (file)
index 0000000..af62f24
--- /dev/null
@@ -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 (file)
index 0000000..4284431
--- /dev/null
@@ -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 (executable)
index 0000000..c368ade
--- /dev/null
@@ -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 (file)
index 0000000..f38adec
--- /dev/null
@@ -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 (file)
index 0000000..803d564
--- /dev/null
@@ -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<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]
diff --git a/installers/charm/osm-keystone/src/interfaces.py b/installers/charm/osm-keystone/src/interfaces.py
new file mode 100644 (file)
index 0000000..7b019dd
--- /dev/null
@@ -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:<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)
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 (file)
index 0000000..7e98542
--- /dev/null
@@ -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 (file)
index 0000000..7207b63
--- /dev/null
@@ -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 (file)
index 0000000..d08fe86
--- /dev/null
@@ -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