From f64121c61fb644b7288dd3d38ad3e527f79f45ab Mon Sep 17 00:00:00 2001 From: Liam Young Date: Fri, 22 Sep 2023 06:46:36 +0000 Subject: [PATCH] First Cut --- charms/keystone-ldap-k8s/.gitignore | 11 + charms/keystone-ldap-k8s/CONTRIBUTING.md | 60 +++++ charms/keystone-ldap-k8s/LICENSE | 202 ++++++++++++++++ charms/keystone-ldap-k8s/README.md | 26 ++ charms/keystone-ldap-k8s/charmcraft.yaml | 30 +++ charms/keystone-ldap-k8s/config.yaml | 224 ++++++++++++++++++ .../keystone_ldap_k8s/v0/domain_config.py | 190 +++++++++++++++ charms/keystone-ldap-k8s/metadata.yaml | 14 ++ charms/keystone-ldap-k8s/pyproject.toml | 33 +++ charms/keystone-ldap-k8s/requirements.txt | 17 ++ charms/keystone-ldap-k8s/src/charm.py | 151 ++++++++++++ .../src/templates/keystone.conf | 120 ++++++++++ .../keystone-ldap-k8s/test-requirements.txt | 17 ++ .../tests/integration/test_charm.py | 35 +++ .../tests/unit/test_charm.py | 75 ++++++ charms/keystone-ldap-k8s/tox.ini | 157 ++++++++++++ 16 files changed, 1362 insertions(+) create mode 100644 charms/keystone-ldap-k8s/.gitignore create mode 100644 charms/keystone-ldap-k8s/CONTRIBUTING.md create mode 100644 charms/keystone-ldap-k8s/LICENSE create mode 100644 charms/keystone-ldap-k8s/README.md create mode 100644 charms/keystone-ldap-k8s/charmcraft.yaml create mode 100644 charms/keystone-ldap-k8s/config.yaml create mode 100644 charms/keystone-ldap-k8s/lib/charms/keystone_ldap_k8s/v0/domain_config.py create mode 100644 charms/keystone-ldap-k8s/metadata.yaml create mode 100644 charms/keystone-ldap-k8s/pyproject.toml create mode 100644 charms/keystone-ldap-k8s/requirements.txt create mode 100755 charms/keystone-ldap-k8s/src/charm.py create mode 100644 charms/keystone-ldap-k8s/src/templates/keystone.conf create mode 100644 charms/keystone-ldap-k8s/test-requirements.txt create mode 100644 charms/keystone-ldap-k8s/tests/integration/test_charm.py create mode 100644 charms/keystone-ldap-k8s/tests/unit/test_charm.py create mode 100644 charms/keystone-ldap-k8s/tox.ini diff --git a/charms/keystone-ldap-k8s/.gitignore b/charms/keystone-ldap-k8s/.gitignore new file mode 100644 index 00000000..4df34f6a --- /dev/null +++ b/charms/keystone-ldap-k8s/.gitignore @@ -0,0 +1,11 @@ +venv/ +build/ +.idea/ +*.charm +.tox +venv +.coverage +__pycache__/ +*.py[cod] +**.swp +.stestr/ diff --git a/charms/keystone-ldap-k8s/CONTRIBUTING.md b/charms/keystone-ldap-k8s/CONTRIBUTING.md new file mode 100644 index 00000000..587443ec --- /dev/null +++ b/charms/keystone-ldap-k8s/CONTRIBUTING.md @@ -0,0 +1,60 @@ +# keystone-k8s + +## Developing + +Create and activate a virtualenv with the development requirements: + + virtualenv -p python3 venv + source venv/bin/activate + pip install -r requirements.txt + +## Code overview + +Get familiarise with [Charmed Operator Framework](https://juju.is/docs/sdk) +and [Sunbeam documentation](sunbeam-docs). + +keystone-k8s charm uses the ops\_sunbeam library and extends +OSBaseOperatorAPICharm from the library. + +The charm provides identity-service and identity-credentials relations +as a library to consume for other openstack charms and details are +documented [here](keystone-k8s-libs-docs). identity-service library +is consumed by charms that need to register to keystone catalog and +identity-credentials library is consumed by charms that want to create +cloud credentials. + +keystone-k8s charm consumes database relation to connect to database +and ingress-internal/ingress-public relation to get exposed over +internal and public networks. + +## Intended use case + +keystone-k8s charm deploys and configures OpenStack Identity service +on a kubernetes based environment. + +## Roadmap + +TODO + +## Testing + +The Python operator framework includes a very nice harness for testing +operator behaviour without full deployment. Run tests using command: + + tox -e py3 + +## Deployment + +This project uses tox for building and managing. To build the charm +run: + + tox -e build + +To deploy the local test instance: + + juju deploy ./keystone-k8s_ubuntu-20.04-amd64.charm --trust --resource keystone-image=ghcr.io/openstack-snaps/keystone:2023.1 + + + +[keystone-k8s-libs-docs]: https://charmhub.io/sunbeam-keystone-operator/libraries/identity_service +[sunbeam-docs]: https://opendev.org/openstack/charm-ops-sunbeam/src/branch/main/README.rst diff --git a/charms/keystone-ldap-k8s/LICENSE b/charms/keystone-ldap-k8s/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/charms/keystone-ldap-k8s/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/charms/keystone-ldap-k8s/README.md b/charms/keystone-ldap-k8s/README.md new file mode 100644 index 00000000..b1c41e5a --- /dev/null +++ b/charms/keystone-ldap-k8s/README.md @@ -0,0 +1,26 @@ + + +# keystone-ldap-k8s + +Charmhub package name: operator-template +More information: https://charmhub.io/keystone-ldap-k8s + +Describe your charm in one or two sentences. + +## Other resources + + + +- [Read more](https://example.com) + +- [Contributing](CONTRIBUTING.md) + +- See the [Juju SDK documentation](https://juju.is/docs/sdk) for more information about developing and improving charms. diff --git a/charms/keystone-ldap-k8s/charmcraft.yaml b/charms/keystone-ldap-k8s/charmcraft.yaml new file mode 100644 index 00000000..8c3cfc6f --- /dev/null +++ b/charms/keystone-ldap-k8s/charmcraft.yaml @@ -0,0 +1,30 @@ +type: "charm" +bases: + - build-on: + - name: "ubuntu" + channel: "22.04" + run-on: + - name: "ubuntu" + channel: "22.04" +parts: + update-certificates: + plugin: nil + override-build: | + apt update + apt install -y ca-certificates + update-ca-certificates + + charm: + after: [update-certificates] + build-packages: + - git + - libffi-dev + - libssl-dev + - pkg-config + - rustc + - cargo + charm-binary-python-packages: + - cryptography + - jsonschema + - jinja2 + - git+https://opendev.org/openstack/charm-ops-sunbeam#egg=ops_sunbeam diff --git a/charms/keystone-ldap-k8s/config.yaml b/charms/keystone-ldap-k8s/config.yaml new file mode 100644 index 00000000..37190374 --- /dev/null +++ b/charms/keystone-ldap-k8s/config.yaml @@ -0,0 +1,224 @@ +options: + domain-name: + type: string + default: + description: | + Name of the keystone domain to configure; defaults to the deployed + application name. + ldap-server: + type: string + default: + description: | + LDAP server URL for keystone LDAP identity backend. + + Examples: + ldap://10.10.10.10/ + ldaps://10.10.10.10/ + ldap://example.com:389,ldaps://ldaps.example.com:636 + ldap://active-directory-host.com:3268/ + ldaps://active-directory-host.com:3269/ + + An ldap:// URL will result in mandatory StartTLS usage if either the + charm's tls-ca-ldap option has been specified or if the 'certificates' + relation is present. + ldap-user: + type: string + default: + description: | + Username (Distinguished Name) used to bind to LDAP identity server. + For anonymous binding, leave ldap-user and ldap-password empty. + + Example: cn=admin,dc=test,dc=com + ldap-password: + type: string + default: + description: | + Password of the LDAP identity server. + For anonymous binding, leave ldap-user and ldap-password empty. + ldap-suffix: + type: string + default: + description: LDAP server suffix to be used by keystone. + ldap-config-flags: + type: string + default: + description: | + Additional LDAP configuration options. + For simple configurations use a comma separated string of key=value pairs. + "user_allow_create=False, user_allow_update=False, user_allow_delete=False" + For more complex configurations use a json like string with double quotes + and braces around all the options and single quotes around complex values. + "{user_tree_dn: 'DC=dc1,DC=ad,DC=example,DC=com', + user_allow_create: False, + user_allow_delete: False}" + See the README for more details. + + Note: The explicitly defined ldap-* charm config options take precedence + over the same LDAP config option also specified in ldap-config-flags. + + For example, if the LDAP config query_scope is defined in + ldap-query-scope as 'one' and in ldap-config-flags as + "{query_scope: 'sub'}" then the config query_scope is set to 'one'. + ldap-readonly: + type: boolean + default: True + description: LDAP identity server backend readonly to keystone. + tls-ca-ldap: + type: string + default: null + description: | + This option controls which certificate (or a chain) will be used to connect + to an ldap server(s) over TLS. Certificate contents should be either used + directly or included via include-file:// + An LDAP url should also be considered as ldaps and StartTLS are both valid + methods of using TLS (see RFC 4513) with StartTLS using a non-ldaps url which, + of course, still requires a CA certificate. + ldap-query-scope: + type: string + default: + description: | + This option controls the scope level of data presented through LDAP. + ldap-user-tree-dn: + type: string + default: + description: | + This option sets the search base to use for the users. + ldap-user-filter: + type: string + default: + description: | + This option sets the LDAP search filter to use for the users. + ldap-user-objectclass: + type: string + default: + description: | + This option sets the LDAP object class for users. + ldap-user-id-attribute: + type: string + default: + description: | + This option sets the LDAP attribute mapped to User IDs in keystone. + ldap-user-name-attribute: + type: string + default: + description: | + This option sets the LDAP attribute mapped to User names in keystone. + ldap-user-enabled-attribute: + type: string + default: + description: | + This option sets the LDAP attribute mapped to the user enabled + attribute in keystone. + ldap-user-enabled-invert: + type: boolean + default: + description: | + Setting this option to True allows LDAP servers to use lock attributes. + This option has no effect when ldap-user-enabled-mask or + ldap-user-enabled-emulation are in use. + ldap-user-enabled-mask: + type: int + default: + description: | + Bitmask integer to select which bit indicates the enabled value if + the LDAP server represents enabled as a bit on an integer rather + than as a discrete boolean. If the option is set to 0, the mask is + not used. This option is typically used when ldap-user-enabled-attribute + is set to 'userAccessControl'. + ldap-user-enabled-default: + type: string + default: + description: | + The default value to enable users. The LDAP servers can use boolean or + bit in the user enabled attribute to indicate if a user is enabled or + disabled. If boolean is used by the ldap schema, then the appropriate + value for this option is 'True' or 'False'. If bit is used by the ldap + schema, this option should match an appropriate integer value based on + ldap-user-enabled-mask. Please note the integer value should be specified + as a string in quotes. This option is typically used when + ldap-user-enabled-attribute is set to 'userAccountControl'. + + Example: + Configuration options to use for ldap schema with userAccountControl as + control attribute, uses bit 1 in control attribute to indicate + enablement. + + ldap-user-enabled-attribute = "userAccountControl" + ldap-user-enabled-mask = 2 + ldap-user-enabled-default = "512" + + ldap-user-enabled-default should be set to integer value that represents + a user being enabled. For Active Directory, 512 represents Normal Account. + + For more information on how to set up those config options, please refer + to the OpenStack docs on Keystone and LDAP integration at + https://docs.openstack.org/keystone/latest/admin/configuration.html#integrate-identity-back-end-with-ldap + + ldap-user-enabled-emulation: + type: boolean + default: + description: | + If enabled, keystone uses an alternative method to determine if a user + is enabled or not by checking if they are a member of the group defined + by the ldap-user-enabled_emulation-dn option. + ldap-user-enabled-emulation-dn: + type: string + default: + description: | + DN of the group entry to hold enabled users when using enabled + emulation. Setting this option has no effect when + ldap-user-enabled-emulation is False. + ldap-group-tree-dn: + type: string + default: + description: | + This option sets the search base to use for the groups. + ldap-group-objectclass: + type: string + default: + description: | + This option sets the LDAP object class for groups. + ldap-group-id-attribute: + type: string + default: + description: | + This option sets the LDAP attribute mapped to group IDs in keystone. + ldap-group-name-attribute: + type: string + default: + description: | + This option sets the LDAP attribute mapped to group names in keystone. + ldap-group-member-attribute: + type: string + default: + description: | + This option sets the LDAP attribute that indicates user is a member + of the group. + ldap-group-members-are-ids: + type: boolean + default: + description: | + Enable this option if the members of group object class are keystone + user IDs rather than LDAP DNs. + ldap-use-pool: + type: boolean + default: + description: | + This option enables LDAP connection pooling. + ldap-pool-size: + type: int + default: + description: | + This option sets the size of LDAP connection pool. + ldap-pool-retry-max: + type: int + default: + description: | + This option allows to set the maximum number of retry attempts to connect + to LDAP server before aborting. + ldap-pool-connection-timeout: + type: int + default: + description: | + The connection timeout to use when pooling LDAP connections. A value of + -1 means the connection will never timeout. diff --git a/charms/keystone-ldap-k8s/lib/charms/keystone_ldap_k8s/v0/domain_config.py b/charms/keystone-ldap-k8s/lib/charms/keystone_ldap_k8s/v0/domain_config.py new file mode 100644 index 00000000..21bc80af --- /dev/null +++ b/charms/keystone-ldap-k8s/lib/charms/keystone_ldap_k8s/v0/domain_config.py @@ -0,0 +1,190 @@ +"""TODO: Add a proper docstring here. + +This is a placeholder docstring for this charm library. Docstrings are +presented on Charmhub and updated whenever you push a new version of the +library. + +Complete documentation about creating and documenting libraries can be found +in the SDK docs at https://juju.is/docs/sdk/libraries. + +See `charmcraft publish-lib` and `charmcraft fetch-lib` for details of how to +share and consume charm libraries. They serve to enhance collaboration +between charmers. Use a charmer's libraries for classes that handle +integration with their charm. + +Bear in mind that new revisions of the different major API versions (v0, v1, +v2 etc) are maintained independently. You can continue to update v0 and v1 +after you have pushed v3. + +Markdown is supported, following the CommonMark specification. +""" + +import logging +from typing import ( + Optional, +) + +from ops.charm import ( + CharmBase, + RelationBrokenEvent, + RelationChangedEvent, + RelationEvent, +) +from ops.framework import ( + EventSource, + Object, + ObjectEvents, +) +from ops.model import ( + Relation, +) +import base64 +logger = logging.getLogger(__name__) + +# The unique Charmhub library identifier, never change it +LIBID = "589e0b16e4164e829aa8eb232628429c" + +# 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 = 1 + +class DomainConfigRequestEvent(RelationEvent): + """DomainConfigRequest Event.""" + pass + +class DomainConfigProviderEvents(ObjectEvents): + """Events class for `on`.""" + + remote_ready = EventSource(DomainConfigRequestEvent) + +class DomainConfigProvides(Object): + """DomainConfigProvides class.""" + + on = DomainConfigProviderEvents() + + def __init__(self, charm: CharmBase, relation_name: str): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_domain_config_relation_changed, + ) + + def _on_domain_config_relation_changed( + self, event: RelationChangedEvent + ): + """Handle DomainConfig relation changed.""" + logging.debug("DomainConfig relation changed") + self.on.remote_ready.emit(event.relation) + + def set_domain_info( + self, domain_name: str, config_contents: str + ) -> None: + """Set ceilometer configuration on the relation.""" + if not self.charm.unit.is_leader(): + logging.debug("Not a leader unit, skipping set config") + return + for relation in self.relations: + relation.data[self.charm.app]["domain-name"] = domain_name + relation.data[self.charm.app]["config-contents"] = base64.b64encode(config_contents.encode()).decode() + + @property + def relations(self): + return self.framework.model.relations[self.relation_name] + +class DomainConfigChangedEvent(RelationEvent): + """DomainConfigChanged Event.""" + + pass + + +class DomainConfigGoneAwayEvent(RelationEvent): + """DomainConfigGoneAway Event.""" + + pass + + +class DomainConfigRequirerEvents(ObjectEvents): + """Events class for `on`.""" + + config_changed = EventSource(DomainConfigChangedEvent) + goneaway = EventSource(DomainConfigGoneAwayEvent) + + +class DomainConfigRequires(Object): + """DomainConfigRequires class.""" + + on = DomainConfigRequirerEvents() + + def __init__(self, charm: CharmBase, relation_name: str): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_domain_config_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_domain_config_relation_broken, + ) + + def _on_domain_config_relation_changed( + self, event: RelationChangedEvent + ): + """Handle DomainConfig relation changed.""" + logging.debug("DomainConfig config data changed") + self.on.config_changed.emit(event.relation) + + def _on_domain_config_relation_broken( + self, event: RelationBrokenEvent + ): + """Handle DomainConfig relation changed.""" + logging.debug("DomainConfig on_broken") + self.on.goneaway.emit(event.relation) + + @property + def _domain_config_rel(self) -> Optional[Relation]: + """The ceilometer service relation.""" + return self.framework.model.get_relation(self.relation_name) + + def get_remote_app_data(self, key: str) -> Optional[str]: + """Return the value for the given key from remote app data.""" + if self._domain_config_rel: + data = self._domain_config_rel.data[ + self._domain_config_rel.app + ] + return data.get(key) + + return None + + @property + def domain_name(self) -> Optional[str]: + """Return the domain name.""" + return self.get_remote_app_data("domain-name") + + @property + def config_contents(self) -> Optional[str]: + """Return the config contents.""" + return base64.b64decode(self.get_remote_app_data("config-contents")).decode() + + def get_domain_configs(self): + configs = [] + for relation in self.relations: + domain_name = relation.data[relation.app].get("domain-name") + raw_config_contents = relation.data[relation.app].get("config-contents") + if not all([domain_name, raw_config_contents]): + continue + configs.append({ + "domain-name": domain_name, + "config-contents": base64.b64decode(raw_config_contents).decode()}) + return configs + + @property + def relations(self): + return self.framework.model.relations[self.relation_name] + diff --git a/charms/keystone-ldap-k8s/metadata.yaml b/charms/keystone-ldap-k8s/metadata.yaml new file mode 100644 index 00000000..0c6d8b9f --- /dev/null +++ b/charms/keystone-ldap-k8s/metadata.yaml @@ -0,0 +1,14 @@ +name: keystone-ldap-k8s +display-name: Keystone LDAP integration +summary: Keystone Domain backend for LDAP or Active Directory +description: | + Keystone support the use of domain specific identity drivers, + allowing different types of authentication backend to be deployed in a single Keystone + deployment. This charm supports use of LDAP or Active Directory domain backends, + with configuration details provided by charm configuration options. +peers: + peers: + interface: keystone-dc-peer +provides: + domain-config: + interface: keystone-domain-config diff --git a/charms/keystone-ldap-k8s/pyproject.toml b/charms/keystone-ldap-k8s/pyproject.toml new file mode 100644 index 00000000..2edc519a --- /dev/null +++ b/charms/keystone-ldap-k8s/pyproject.toml @@ -0,0 +1,33 @@ +# 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] +line_length = 99 +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" diff --git a/charms/keystone-ldap-k8s/requirements.txt b/charms/keystone-ldap-k8s/requirements.txt new file mode 100644 index 00000000..d4f4a3d0 --- /dev/null +++ b/charms/keystone-ldap-k8s/requirements.txt @@ -0,0 +1,17 @@ +# This file is managed centrally by release-tools and should not be modified +# within individual charm repos. See the 'global' dir contents for available +# choices of *requirements.txt files for OpenStack Charms: +# https://github.com/openstack-charmers/release-tools +# + +cryptography +jinja2 +jsonschema +lightkube +lightkube-models +ops +pwgen + +git+https://opendev.org/openstack/charm-ops-sunbeam#egg=ops_sunbeam + +python-keystoneclient # keystone-k8s diff --git a/charms/keystone-ldap-k8s/src/charm.py b/charms/keystone-ldap-k8s/src/charm.py new file mode 100755 index 00000000..073c927d --- /dev/null +++ b/charms/keystone-ldap-k8s/src/charm.py @@ -0,0 +1,151 @@ +#!/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. +# +# +# Learn more at: https://juju.is/docs/sdk + +"""Charm the service. + +Refer to the following post for a quick-start guide that will help you +develop a new k8s charm using the Operator Framework: + + https://discourse.charmhub.io/t/4208 +""" +import jinja2 +import logging +from typing import ( + Callable, + List, + Mapping, +) + +import ops.charm +from ops.main import main +from ops.model import ActiveStatus, BlockedStatus, WaitingStatus + +# Log messages can be retrieved using juju debug-log +logger = logging.getLogger(__name__) + +VALID_LOG_LEVELS = ["info", "debug", "warning", "error", "critical"] +import ops_sunbeam.charm as sunbeam_charm +import ops_sunbeam.relation_handlers as sunbeam_rhandlers +import charms.keystone_ldap_k8s.v0.domain_config as sunbeam_dc_svc +import ops_sunbeam.config_contexts as config_contexts +import json + +class LDAPConfigFlagsContext(config_contexts.ConfigContext): + """Configuration context for cinder parameters.""" + + def context(self) -> dict: + """Generate context information for cinder config.""" + config_flags = {} + config = self.charm.model.config.get + raw_config_flags = config("ldap-config-flags") + if raw_config_flags: + config_flags = json.loads(raw_config_flags) + return {'flags': config_flags} + + +class DomainConfigProvidesHandler(sunbeam_rhandlers.RelationHandler): + """Handler for identity credentials relation.""" + + def __init__( + self, + charm: ops.charm.CharmBase, + relation_name: str, + callback_f: Callable, + ): + super().__init__(charm, relation_name, callback_f) + + def setup_event_handler(self): + """Configure event handlers for a domain config relation.""" + logger.debug("Setting up domain config event handler") + self.domain_config = sunbeam_dc_svc.DomainConfigProvides( + self.charm, + self.relation_name, + ) + self.framework.observe( + self.domain_config.on.remote_ready, + self._on_domain_config_ready, + ) + return self.domain_config + + def _on_domain_config_ready(self, event) -> None: + """Handles domain config change events.""" + self.callback_f(event) + + @property + def ready(self) -> bool: + """Check if handler is ready.""" + return True + + +class KeystoneLDAPK8SCharm(sunbeam_charm.OSBaseOperatorCharm): + """Charm the service.""" + DOMAIN_CONFIG_RELATION_NAME = "domain-config" + + def __init__(self, *args): + super().__init__(*args) + self.send_domain_config() + + def get_relation_handlers( + self, handlers=None + ) -> List[sunbeam_rhandlers.RelationHandler]: + """Relation handlers for the service.""" + handlers = handlers or [] + if self.can_add_handler(self.DOMAIN_CONFIG_RELATION_NAME, handlers): + self.dc_handler = DomainConfigProvidesHandler( + self, + self.DOMAIN_CONFIG_RELATION_NAME, + self.send_domain_config, + ) + handlers.append(self.dc_handler) + return super().get_relation_handlers(handlers) + + + @property + def config_contexts(self) -> List[config_contexts.ConfigContext]: + """Configuration contexts for the operator.""" + contexts = super().config_contexts + contexts.append(LDAPConfigFlagsContext(self, "ldap_config_flags")) + return contexts + + + def send_domain_config(self, event=None): + try: + domain_name = self.config['domain-name'] + except KeyError: + return + loader = jinja2.FileSystemLoader(self.template_dir) + _tmpl_env = jinja2.Environment(loader=loader) + template = _tmpl_env.get_template("keystone.conf") + self.dc_handler.domain_config.set_domain_info( + domain_name=domain_name, + config_contents=template.render(self.contexts())) + + def configure_app_leader(self, event): + self.send_domain_config() + self.set_leader_ready() + + @property + def databases(self) -> Mapping[str, str]: + return {} + + def get_pebble_handlers(self): + return [] + +if __name__ == "__main__": # pragma: nocover + main(KeystoneLDAPK8SCharm) diff --git a/charms/keystone-ldap-k8s/src/templates/keystone.conf b/charms/keystone-ldap-k8s/src/templates/keystone.conf new file mode 100644 index 00000000..5e70827c --- /dev/null +++ b/charms/keystone-ldap-k8s/src/templates/keystone.conf @@ -0,0 +1,120 @@ +[ldap] +url = {{ options.ldap_server }} +{% if options.ldap_user and options.ldap_password -%} +user = {{ options.ldap_user }} +password = {{ options.ldap_password }} +{% endif -%} +suffix = {{ options.ldap_suffix }} + +user_allow_create = {{ not options.ldap_readonly }} +user_allow_update = {{ not options.ldap_readonly }} +user_allow_delete = {{ not options.ldap_readonly }} + +group_allow_create = {{ not options.ldap_readonly }} +group_allow_update = {{ not options.ldap_readonly }} +group_allow_delete = {{ not options.ldap_readonly }} + +{% if options.tls_ca_ldap -%} +use_tls = {{ options.use_tls }} +tls_req_cert = demand +tls_cacertfile = {{ options.backend_ca_file }} +{% endif -%} + +{% if options.ldap_query_scope -%} +query_scope = {{ options.ldap_query_scope }} +{% endif -%} + +{% if options.ldap_user_tree_dn -%} +user_tree_dn = {{ options.ldap_user_tree_dn }} +{% endif -%} + +{% if options.ldap_user_filter -%} +user_filter = {{ options.ldap_user_filter }} +{% endif -%} + +{% if options.ldap_user_objectclass -%} +user_objectclass = {{ options.ldap_user_objectclass }} +{% endif -%} + +{% if options.ldap_user_id_attribute -%} +user_id_attribute = {{ options.ldap_user_id_attribute }} +{% endif -%} + +{% if options.ldap_user_name_attribute -%} +user_name_attribute = {{ options.ldap_user_name_attribute }} +{% endif -%} + +{% if options.ldap_user_enabled_attribute -%} +user_enabled_attribute = {{ options.ldap_user_enabled_attribute }} +{% endif -%} + +{% if options.ldap_user_enabled_invert|length -%} +user_enabled_invert = {{ options.ldap_user_enabled_invert }} +{% endif -%} + +{% if options.ldap_user_enabled_mask|length -%} +user_enabled_mask = {{ options.ldap_user_enabled_mask }} +{% endif -%} + +{% if options.ldap_user_enabled_default -%} +user_enabled_default = {{ options.ldap_user_enabled_default }} +{% endif -%} + +{% if options.ldap_user_enabled_emulation|length -%} +user_enabled_emulation = {{ options.ldap_user_enabled_emulation }} +{% endif -%} + +{% if options.ldap_user_enabled_emulation_dn -%} +user_enabled_emulation_dn = {{ options.ldap_user_enabled_emulation_dn }} +{% endif -%} + +{% if options.ldap_group_tree_dn -%} +group_tree_dn = {{ options.ldap_group_tree_dn }} +{% endif -%} + +{% if options.ldap_group_objectclass -%} +group_objectclass = {{ options.ldap_group_objectclass }} +{% endif -%} + +{% if options.ldap_group_id_attribute -%} +group_id_attribute = {{ options.ldap_group_id_attribute }} +{% endif -%} + +{% if options.ldap_group_name_attribute -%} +group_name_attribute = {{ options.ldap_group_name_attribute }} +{% endif -%} + +{% if options.ldap_group_member_attribute -%} +group_member_attribute = {{ options.ldap_group_member_attribute }} +{% endif -%} + +{% if options.ldap_group_members_are_ids|length -%} +group_members_are_ids = {{ options.ldap_group_members_are_ids }} +{% endif -%} + +{% if options.ldap_use_pool|length -%} +use_pool = {{ options.ldap_use_pool }} +{% endif -%} + +{% if options.ldap_pool_size|length -%} +pool_size = {{ options.ldap_pool_size }} +{% endif -%} + +{% if options.ldap_pool_retry_max|length -%} +pool_retry_max = {{ options.ldap_pool_retry_max }} +{% endif -%} + +{% if options.ldap_pool_connection_timeout|length -%} +pool_connection_timeout = {{ options.ldap_pool_connection_timeout }} +{% endif -%} + +# User supplied configuration flags +{% if ldap_config_flags.flags -%} +{% for key, value in ldap_config_flags.flags.items() -%} +{{ key }} = {{ value }} +{% endfor -%} +{% endif -%} + +[identity] +driver = ldap + diff --git a/charms/keystone-ldap-k8s/test-requirements.txt b/charms/keystone-ldap-k8s/test-requirements.txt new file mode 100644 index 00000000..23e005bd --- /dev/null +++ b/charms/keystone-ldap-k8s/test-requirements.txt @@ -0,0 +1,17 @@ +# This file is managed centrally by release-tools and should not be modified +# within individual charm repos. See the 'global' dir contents for available +# choices of *requirements.txt files for OpenStack Charms: +# https://github.com/openstack-charmers/release-tools +# + +pwgen +coverage +mock +flake8 +stestr +git+https://github.com/openstack-charmers/zaza.git@libjuju-3.1#egg=zaza +git+https://github.com/openstack-charmers/zaza-openstack-tests.git#egg=zaza.openstack +git+https://opendev.org/openstack/tempest.git#egg=tempest +ops +# Subunit 1.4.3+ requires extras +extras diff --git a/charms/keystone-ldap-k8s/tests/integration/test_charm.py b/charms/keystone-ldap-k8s/tests/integration/test_charm.py new file mode 100644 index 00000000..18f24d39 --- /dev/null +++ b/charms/keystone-ldap-k8s/tests/integration/test_charm.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# Copyright 2023 liam +# See LICENSE file for licensing details. + +import asyncio +import logging +from pathlib import Path + +import pytest +import yaml +from pytest_operator.plugin import OpsTest + +logger = logging.getLogger(__name__) + +METADATA = yaml.safe_load(Path("./metadata.yaml").read_text()) +APP_NAME = METADATA["name"] + + +@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. + """ + # Build and deploy charm from local source folder + charm = await ops_test.build_charm(".") + resources = {"httpbin-image": METADATA["resources"]["httpbin-image"]["upstream-source"]} + + # Deploy the charm and wait for active/idle status + await asyncio.gather( + ops_test.model.deploy(await charm, resources=resources, application_name=APP_NAME), + ops_test.model.wait_for_idle( + apps=[APP_NAME], status="active", raise_on_blocked=True, timeout=1000 + ), + ) diff --git a/charms/keystone-ldap-k8s/tests/unit/test_charm.py b/charms/keystone-ldap-k8s/tests/unit/test_charm.py new file mode 100644 index 00000000..8b1b560c --- /dev/null +++ b/charms/keystone-ldap-k8s/tests/unit/test_charm.py @@ -0,0 +1,75 @@ +# Copyright 2023 liam +# See LICENSE file for licensing details. +# +# Learn more about testing at: https://juju.is/docs/sdk/testing + +import unittest + +import ops.testing +from ops.model import ActiveStatus, BlockedStatus, WaitingStatus +from ops.testing import Harness + +from charm import KeystoneLdapK8SCharm + + +class TestCharm(unittest.TestCase): + def setUp(self): + # Enable more accurate simulation of container networking. + # For more information, see https://juju.is/docs/sdk/testing#heading--simulate-can-connect + ops.testing.SIMULATE_CAN_CONNECT = True + self.addCleanup(setattr, ops.testing, "SIMULATE_CAN_CONNECT", False) + + self.harness = Harness(KeystoneLdapK8SCharm) + self.addCleanup(self.harness.cleanup) + self.harness.begin() + + def test_httpbin_pebble_ready(self): + # Expected plan after Pebble ready with default config + expected_plan = { + "services": { + "httpbin": { + "override": "replace", + "summary": "httpbin", + "command": "gunicorn -b 0.0.0.0:80 httpbin:app -k gevent", + "startup": "enabled", + "environment": {"GUNICORN_CMD_ARGS": "--log-level info"}, + } + }, + } + # Simulate the container coming up and emission of pebble-ready event + self.harness.container_pebble_ready("httpbin") + # Get the plan now we've run PebbleReady + updated_plan = self.harness.get_container_pebble_plan("httpbin").to_dict() + # Check we've got the plan we expected + self.assertEqual(expected_plan, updated_plan) + # Check the service was started + service = self.harness.model.unit.get_container("httpbin").get_service("httpbin") + self.assertTrue(service.is_running()) + # Ensure we set an ActiveStatus with no message + self.assertEqual(self.harness.model.unit.status, ActiveStatus()) + + def test_config_changed_valid_can_connect(self): + # Ensure the simulated Pebble API is reachable + self.harness.set_can_connect("httpbin", True) + # Trigger a config-changed event with an updated value + self.harness.update_config({"log-level": "debug"}) + # Get the plan now we've run PebbleReady + updated_plan = self.harness.get_container_pebble_plan("httpbin").to_dict() + updated_env = updated_plan["services"]["httpbin"]["environment"] + # Check the config change was effective + self.assertEqual(updated_env, {"GUNICORN_CMD_ARGS": "--log-level debug"}) + self.assertEqual(self.harness.model.unit.status, ActiveStatus()) + + def test_config_changed_valid_cannot_connect(self): + # Trigger a config-changed event with an updated value + self.harness.update_config({"log-level": "debug"}) + # Check the charm is in WaitingStatus + self.assertIsInstance(self.harness.model.unit.status, WaitingStatus) + + def test_config_changed_invalid(self): + # Ensure the simulated Pebble API is reachable + self.harness.set_can_connect("httpbin", True) + # Trigger a config-changed event with an updated value + self.harness.update_config({"log-level": "foobar"}) + # Check the charm is in BlockedStatus + self.assertIsInstance(self.harness.model.unit.status, BlockedStatus) diff --git a/charms/keystone-ldap-k8s/tox.ini b/charms/keystone-ldap-k8s/tox.ini new file mode 100644 index 00000000..1b005314 --- /dev/null +++ b/charms/keystone-ldap-k8s/tox.ini @@ -0,0 +1,157 @@ +# Source charm: ./tox.ini +# This file is managed centrally by release-tools and should not be modified +# within individual charm repos. See the 'global' dir contents for available +# choices of tox.ini for OpenStack Charms: +# https://github.com/openstack-charmers/release-tools + +[tox] +skipsdist = True +envlist = pep8,py3 +sitepackages = False +skip_missing_interpreters = False +minversion = 3.18.0 + +[vars] +src_path = {toxinidir}/src/ +tst_path = {toxinidir}/tests/ +lib_path = {toxinidir}/lib/ +pyproject_toml = {toxinidir}/pyproject.toml +all_path = {[vars]src_path} {[vars]tst_path} + +[testenv] +basepython = python3 +setenv = + PYTHONPATH = {toxinidir}:{[vars]lib_path}:{[vars]src_path} +passenv = + PYTHONPATH + HOME +install_command = + pip install {opts} {packages} +commands = stestr run --slowest {posargs} +allowlist_externals = + git + charmcraft + {toxinidir}/fetch-libs.sh + {toxinidir}/rename.sh +deps = + -r{toxinidir}/test-requirements.txt + +[testenv:fmt] +description = Apply coding style standards to code +deps = + black + isort +commands = + isort {[vars]all_path} --skip-glob {[vars]lib_path} --skip {toxinidir}/.tox + black --config {[vars]pyproject_toml} {[vars]all_path} --exclude {[vars]lib_path} + +[testenv:build] +basepython = python3 +deps = +commands = + charmcraft -v pack + {toxinidir}/rename.sh + +[testenv:fetch] +basepython = python3 +deps = +commands = + {toxinidir}/fetch-libs.sh + +[testenv:py3] +basepython = python3 +deps = + {[testenv]deps} + -r{toxinidir}/requirements.txt + +[testenv:py38] +basepython = python3.8 +deps = {[testenv:py3]deps} + +[testenv:py39] +basepython = python3.9 +deps = {[testenv:py3]deps} + +[testenv:py310] +basepython = python3.10 +deps = {[testenv:py3]deps} + +[testenv:cover] +basepython = python3 +deps = {[testenv:py3]deps} +setenv = + {[testenv]setenv} + PYTHON=coverage run +commands = + coverage erase + stestr run --slowest {posargs} + coverage combine + coverage html -d cover + coverage xml -o cover/coverage.xml + coverage report + +[testenv:pep8] +description = Alias for lint +deps = {[testenv:lint]deps} +commands = {[testenv:lint]commands} + +[testenv:lint] +description = Check code against coding style standards +deps = + black + flake8<6 + flake8-docstrings + flake8-copyright + flake8-builtins + pyproject-flake8 + pep8-naming + isort + codespell +commands = + codespell {[vars]all_path} + # pflake8 wrapper supports config from pyproject.toml + pflake8 --exclude {[vars]lib_path} --config {toxinidir}/pyproject.toml {[vars]all_path} + isort --check-only --diff {[vars]all_path} --skip-glob {[vars]lib_path} + black --config {[vars]pyproject_toml} --check --diff {[vars]all_path} --exclude {[vars]lib_path} + +[testenv:func-noop] +basepython = python3 +commands = + functest-run-suite --help + +[testenv:func] +basepython = python3 +commands = + functest-run-suite --keep-model + +[testenv:func-smoke] +basepython = python3 +setenv = + TEST_MODEL_SETTINGS = automatically-retry-hooks=true;default-series= + TEST_MAX_RESOLVE_COUNT = 5 +commands = + functest-run-suite --keep-model --smoke + +[testenv:func-dev] +basepython = python3 +commands = + functest-run-suite --keep-model --dev + +[testenv:func-target] +basepython = python3 +commands = + functest-run-suite --keep-model --bundle {posargs} + +[coverage:run] +branch = True +concurrency = multiprocessing +parallel = True +source = + . +omit = + .tox/* + tests/* + src/templates/* + +[flake8] +ignore=E226,W504