Implement cinder-volume as a snap
This change includes cinder-volume and cinder-volume-ceph to manager the cinder-volume service as snap that can be configured over multiple backends. Change-Id: Id520fc95710c8516aed5eae08cb20c8e54808cc7 Signed-off-by: Guillaume Boutry <guillaume.boutry@canonical.com>
This commit is contained in:
parent
4d4b4a41b0
commit
93eabbfa72
10
charms/cinder-volume-ceph/.sunbeam-build.yaml
Normal file
10
charms/cinder-volume-ceph/.sunbeam-build.yaml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
external-libraries:
|
||||||
|
- charms.rabbitmq_k8s.v0.rabbitmq
|
||||||
|
- charms.loki_k8s.v1.loki_push_api
|
||||||
|
- charms.tempo_k8s.v2.tracing
|
||||||
|
- charms.tempo_k8s.v1.charm_tracing
|
||||||
|
- charms.operator_libs_linux.v2.snap
|
||||||
|
internal-libraries:
|
||||||
|
- charms.keystone_k8s.v0.identity_credentials
|
||||||
|
- charms.cinder_volume.v0.cinder_volume
|
||||||
|
- charms.cinder_ceph_k8s.v0.ceph_access
|
54
charms/cinder-volume-ceph/CONTRIBUTING.md
Normal file
54
charms/cinder-volume-ceph/CONTRIBUTING.md
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# cinder-volume-ceph
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Create and activate a virtualenv with the development requirements:
|
||||||
|
|
||||||
|
virtualenv -p python3 venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
## Code overview
|
||||||
|
|
||||||
|
Get familiarise with [Charmed Operator Framework](https://juju.is/docs/sdk)
|
||||||
|
and [Sunbeam documentation](sunbeam-docs).
|
||||||
|
|
||||||
|
cinder-volume-ceph charm uses the ops\_sunbeam library and extends
|
||||||
|
OSBaseOperatorCharm from the library.
|
||||||
|
|
||||||
|
cinder-volume-ceph charm consumes database relation to connect to database,
|
||||||
|
amqp to connect to rabbitmq and ceph relation to connect to external ceph.
|
||||||
|
|
||||||
|
The charm starts cinder-volume service with integration with ceph as
|
||||||
|
storage backend.
|
||||||
|
|
||||||
|
## Intended use case
|
||||||
|
|
||||||
|
cinder-volume-ceph charm deploys and configures OpenStack Block storage service
|
||||||
|
with ceph as backend storage 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 ./cinder-volume-ceph.charm
|
||||||
|
|
||||||
|
<!-- LINKS -->
|
||||||
|
|
||||||
|
[sunbeam-docs]: https://opendev.org/openstack/charm-ops-sunbeam/src/branch/main/README.rst
|
202
charms/cinder-volume-ceph/LICENSE
Normal file
202
charms/cinder-volume-ceph/LICENSE
Normal file
@ -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.
|
58
charms/cinder-volume-ceph/README.md
Normal file
58
charms/cinder-volume-ceph/README.md
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# cinder-volume-ceph
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The cinder-volume-ceph is an operator to manage the Cinder service
|
||||||
|
integration with Ceph storage backend on a snap based deployment.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
cinder-volume-ceph is deployed using below command:
|
||||||
|
|
||||||
|
juju deploy cinder-volume-ceph --trust
|
||||||
|
|
||||||
|
Now connect the cinder-ceph application to cinder-volume and Ceph
|
||||||
|
services:
|
||||||
|
|
||||||
|
juju relate cinder-volume:cinder-volume cinder-ceph:cinder-volume
|
||||||
|
juju relate ceph-mon:ceph cinder-ceph:ceph
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
This section covers common and/or important configuration options. See file
|
||||||
|
`config.yaml` for the full list of options, along with their descriptions and
|
||||||
|
default values. See the [Juju documentation][juju-docs-config-apps] for details
|
||||||
|
on configuring applications.
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
|
||||||
|
This section covers Juju [actions][juju-docs-actions] supported by the charm.
|
||||||
|
Actions allow specific operations to be performed on a per-unit basis. To
|
||||||
|
display action descriptions run `juju actions cinderceph`. If the charm is not
|
||||||
|
deployed then see file `actions.yaml`.
|
||||||
|
|
||||||
|
## Relations
|
||||||
|
|
||||||
|
cinder-volume-ceph requires the following relations:
|
||||||
|
|
||||||
|
`cinder-volume`: To connect to Cinder service
|
||||||
|
`ceph`: To connect to Ceph storage backend
|
||||||
|
|
||||||
|
|
||||||
|
## 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](contributors-guide) for developer guidance.
|
||||||
|
|
||||||
|
## Bugs
|
||||||
|
|
||||||
|
Please report bugs on [Launchpad][lp-bugs-charm-cinder-volume-ceph].
|
||||||
|
|
||||||
|
<!-- LINKS -->
|
||||||
|
|
||||||
|
[contributors-guide]: https://opendev.org/openstack/charm-cinder-volume-ceph/src/branch/main/CONTRIBUTING.md
|
||||||
|
[juju-docs-actions]: https://jaas.ai/docs/actions
|
||||||
|
[juju-docs-config-apps]: https://juju.is/docs/configuring-applications
|
311
charms/cinder-volume-ceph/charmcraft.yaml
Normal file
311
charms/cinder-volume-ceph/charmcraft.yaml
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
type: charm
|
||||||
|
name: cinder-volume-ceph
|
||||||
|
summary: OpenStack volume service - Ceph backend
|
||||||
|
description: |
|
||||||
|
Cinder is the OpenStack project that provides volume management for
|
||||||
|
instances. This charm provides integration with Ceph storage
|
||||||
|
backends.
|
||||||
|
assumes:
|
||||||
|
- juju >= 3.1
|
||||||
|
links:
|
||||||
|
source:
|
||||||
|
- https://opendev.org/openstack/sunbeam-charms
|
||||||
|
issues:
|
||||||
|
- https://bugs.launchpad.net/sunbeam-charms
|
||||||
|
|
||||||
|
base: ubuntu@24.04
|
||||||
|
platforms:
|
||||||
|
amd64:
|
||||||
|
|
||||||
|
subordinate: true
|
||||||
|
|
||||||
|
config:
|
||||||
|
options:
|
||||||
|
ceph-osd-replication-count:
|
||||||
|
default: 3
|
||||||
|
type: int
|
||||||
|
description: |
|
||||||
|
This value dictates the number of replicas ceph must make of any
|
||||||
|
object it stores within the cinder rbd pool. Of course, this only
|
||||||
|
applies if using Ceph as a backend store. Note that once the cinder
|
||||||
|
rbd pool has been created, changing this value will not have any
|
||||||
|
effect (although it can be changed in ceph by manually configuring
|
||||||
|
your ceph cluster).
|
||||||
|
ceph-pool-weight:
|
||||||
|
type: int
|
||||||
|
default: 20
|
||||||
|
description: |
|
||||||
|
Defines a relative weighting of the pool as a percentage of the total
|
||||||
|
amount of data in the Ceph cluster. This effectively weights the number
|
||||||
|
of placement groups for the pool created to be appropriately portioned
|
||||||
|
to the amount of data expected. For example, if the ephemeral volumes
|
||||||
|
for the OpenStack compute instances are expected to take up 20% of the
|
||||||
|
overall configuration then this value would be specified as 20. Note -
|
||||||
|
it is important to choose an appropriate value for the pool weight as
|
||||||
|
this directly affects the number of placement groups which will be
|
||||||
|
created for the pool. The number of placement groups for a pool can
|
||||||
|
only be increased, never decreased - so it is important to identify the
|
||||||
|
percent of data that will likely reside in the pool.
|
||||||
|
volume-backend-name:
|
||||||
|
default: null
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
Volume backend name for the backend. The default value is the
|
||||||
|
application name in the Juju model, e.g. "cinder-ceph-mybackend"
|
||||||
|
if it's deployed as `juju deploy cinder-ceph cinder-ceph-mybackend`.
|
||||||
|
A common backend name can be set to multiple backends with the
|
||||||
|
same characters so that those can be treated as a single virtual
|
||||||
|
backend associated with a single volume type.
|
||||||
|
backend-availability-zone:
|
||||||
|
default: null
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
Availability zone name of this volume backend. If set, it will
|
||||||
|
override the default availability zone. Supported for Pike or
|
||||||
|
newer releases.
|
||||||
|
restrict-ceph-pools:
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
description: |
|
||||||
|
Optionally restrict Ceph key permissions to access pools as required.
|
||||||
|
rbd-pool-name:
|
||||||
|
default: null
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
Optionally specify an existing rbd pool that cinder should map to.
|
||||||
|
rbd-flatten-volume-from-snapshot:
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
description: |
|
||||||
|
Flatten volumes created from snapshots to remove dependency from
|
||||||
|
volume to snapshot.
|
||||||
|
rbd-mirroring-mode:
|
||||||
|
type: string
|
||||||
|
default: pool
|
||||||
|
description: |
|
||||||
|
The RBD mirroring mode used for the Ceph pool. This option is only used
|
||||||
|
with 'replicated' pool type, as it's not supported for 'erasure-coded'
|
||||||
|
pool type - valid values: 'pool' and 'image'
|
||||||
|
pool-type:
|
||||||
|
type: string
|
||||||
|
default: replicated
|
||||||
|
description: |
|
||||||
|
Ceph pool type to use for storage - valid values include `replicated`
|
||||||
|
and `erasure-coded`.
|
||||||
|
ec-profile-name:
|
||||||
|
type: string
|
||||||
|
default: null
|
||||||
|
description: |
|
||||||
|
Name for the EC profile to be created for the EC pools. If not defined
|
||||||
|
a profile name will be generated based on the name of the pool used by
|
||||||
|
the application.
|
||||||
|
ec-rbd-metadata-pool:
|
||||||
|
type: string
|
||||||
|
default: null
|
||||||
|
description: |
|
||||||
|
Name of the metadata pool to be created (for RBD use-cases). If not
|
||||||
|
defined a metadata pool name will be generated based on the name of
|
||||||
|
the data pool used by the application. The metadata pool is always
|
||||||
|
replicated, not erasure coded.
|
||||||
|
ec-profile-k:
|
||||||
|
type: int
|
||||||
|
default: 1
|
||||||
|
description: |
|
||||||
|
Number of data chunks that will be used for EC data pool. K+M factors
|
||||||
|
should never be greater than the number of available zones (or hosts)
|
||||||
|
for balancing.
|
||||||
|
ec-profile-m:
|
||||||
|
type: int
|
||||||
|
default: 2
|
||||||
|
description: |
|
||||||
|
Number of coding chunks that will be used for EC data pool. K+M factors
|
||||||
|
should never be greater than the number of available zones (or hosts)
|
||||||
|
for balancing.
|
||||||
|
ec-profile-locality:
|
||||||
|
type: int
|
||||||
|
default: null
|
||||||
|
description: |
|
||||||
|
(lrc plugin - l) Group the coding and data chunks into sets of size l.
|
||||||
|
For instance, for k=4 and m=2, when l=3 two groups of three are created.
|
||||||
|
Each set can be recovered without reading chunks from another set. Note
|
||||||
|
that using the lrc plugin does incur more raw storage usage than isa or
|
||||||
|
jerasure in order to reduce the cost of recovery operations.
|
||||||
|
ec-profile-crush-locality:
|
||||||
|
type: string
|
||||||
|
default: null
|
||||||
|
description: |
|
||||||
|
(lrc plugin) The type of the crush bucket in which each set of chunks
|
||||||
|
defined by l will be stored. For instance, if it is set to rack, each
|
||||||
|
group of l chunks will be placed in a different rack. It is used to
|
||||||
|
create a CRUSH rule step such as step choose rack. If it is not set,
|
||||||
|
no such grouping is done.
|
||||||
|
ec-profile-durability-estimator:
|
||||||
|
type: int
|
||||||
|
default: null
|
||||||
|
description: |
|
||||||
|
(shec plugin - c) The number of parity chunks each of which includes
|
||||||
|
each data chunk in its calculation range. The number is used as a
|
||||||
|
durability estimator. For instance, if c=2, 2 OSDs can be down
|
||||||
|
without losing data.
|
||||||
|
ec-profile-helper-chunks:
|
||||||
|
type: int
|
||||||
|
default: null
|
||||||
|
description: |
|
||||||
|
(clay plugin - d) Number of OSDs requested to send data during
|
||||||
|
recovery of a single chunk. d needs to be chosen such that
|
||||||
|
k+1 <= d <= k+m-1. Larger the d, the better the savings.
|
||||||
|
ec-profile-scalar-mds:
|
||||||
|
type: string
|
||||||
|
default: null
|
||||||
|
description: |
|
||||||
|
(clay plugin) specifies the plugin that is used as a building
|
||||||
|
block in the layered construction. It can be one of jerasure,
|
||||||
|
isa, shec (defaults to jerasure).
|
||||||
|
ec-profile-plugin:
|
||||||
|
type: string
|
||||||
|
default: jerasure
|
||||||
|
description: |
|
||||||
|
EC plugin to use for this applications pool. The following list of
|
||||||
|
plugins acceptable - jerasure, lrc, isa, shec, clay.
|
||||||
|
ec-profile-technique:
|
||||||
|
type: string
|
||||||
|
default: null
|
||||||
|
description: |
|
||||||
|
EC profile technique used for this applications pool - will be
|
||||||
|
validated based on the plugin configured via ec-profile-plugin.
|
||||||
|
Supported techniques are `reed_sol_van`, `reed_sol_r6_op`,
|
||||||
|
`cauchy_orig`, `cauchy_good`, `liber8tion` for jerasure,
|
||||||
|
`reed_sol_van`, `cauchy` for isa and `single`, `multiple`
|
||||||
|
for shec.
|
||||||
|
ec-profile-device-class:
|
||||||
|
type: string
|
||||||
|
default: null
|
||||||
|
description: |
|
||||||
|
Device class from CRUSH map to use for placement groups for
|
||||||
|
erasure profile - valid values: ssd, hdd or nvme (or leave
|
||||||
|
unset to not use a device class).
|
||||||
|
bluestore-compression-algorithm:
|
||||||
|
type: string
|
||||||
|
default: null
|
||||||
|
description: |
|
||||||
|
Compressor to use (if any) for pools requested by this charm.
|
||||||
|
.
|
||||||
|
NOTE: The ceph-osd charm sets a global default for this value (defaults
|
||||||
|
to 'lz4' unless configured by the end user) which will be used unless
|
||||||
|
specified for individual pools.
|
||||||
|
bluestore-compression-mode:
|
||||||
|
type: string
|
||||||
|
default: null
|
||||||
|
description: |
|
||||||
|
Policy for using compression on pools requested by this charm.
|
||||||
|
.
|
||||||
|
'none' means never use compression.
|
||||||
|
'passive' means use compression when clients hint that data is
|
||||||
|
compressible.
|
||||||
|
'aggressive' means use compression unless clients hint that
|
||||||
|
data is not compressible.
|
||||||
|
'force' means use compression under all circumstances even if the clients
|
||||||
|
hint that the data is not compressible.
|
||||||
|
bluestore-compression-required-ratio:
|
||||||
|
type: float
|
||||||
|
default: null
|
||||||
|
description: |
|
||||||
|
The ratio of the size of the data chunk after compression relative to the
|
||||||
|
original size must be at least this small in order to store the
|
||||||
|
compressed version on pools requested by this charm.
|
||||||
|
bluestore-compression-min-blob-size:
|
||||||
|
type: int
|
||||||
|
default: null
|
||||||
|
description: |
|
||||||
|
Chunks smaller than this are never compressed on pools requested by
|
||||||
|
this charm.
|
||||||
|
bluestore-compression-min-blob-size-hdd:
|
||||||
|
type: int
|
||||||
|
default: null
|
||||||
|
description: |
|
||||||
|
Value of bluestore compression min blob size for rotational media on
|
||||||
|
pools requested by this charm.
|
||||||
|
bluestore-compression-min-blob-size-ssd:
|
||||||
|
type: int
|
||||||
|
default: null
|
||||||
|
description: |
|
||||||
|
Value of bluestore compression min blob size for solid state media on
|
||||||
|
pools requested by this charm.
|
||||||
|
bluestore-compression-max-blob-size:
|
||||||
|
type: int
|
||||||
|
default: null
|
||||||
|
description: |
|
||||||
|
Chunks larger than this are broken into smaller blobs sizing bluestore
|
||||||
|
compression max blob size before being compressed on pools requested by
|
||||||
|
this charm.
|
||||||
|
bluestore-compression-max-blob-size-hdd:
|
||||||
|
type: int
|
||||||
|
default: null
|
||||||
|
description: |
|
||||||
|
Value of bluestore compression max blob size for rotational media on
|
||||||
|
pools requested by this charm.
|
||||||
|
bluestore-compression-max-blob-size-ssd:
|
||||||
|
type: int
|
||||||
|
default: null
|
||||||
|
description: |
|
||||||
|
Value of bluestore compression max blob size for solid state media on
|
||||||
|
pools requested by this charm.
|
||||||
|
image-volume-cache-enabled:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
description: |
|
||||||
|
Enable the image volume cache.
|
||||||
|
image-volume-cache-max-size-gb:
|
||||||
|
type: int
|
||||||
|
default: 0
|
||||||
|
description: |
|
||||||
|
Max size of the image volume cache in GB. 0 means unlimited.
|
||||||
|
image-volume-cache-max-count:
|
||||||
|
type: int
|
||||||
|
default: 0
|
||||||
|
description: |
|
||||||
|
Max number of entries allowed in the image volume cache. 0 means
|
||||||
|
unlimited.
|
||||||
|
|
||||||
|
requires:
|
||||||
|
ceph:
|
||||||
|
interface: ceph-client
|
||||||
|
cinder-volume:
|
||||||
|
interface: cinder-volume
|
||||||
|
scope: container
|
||||||
|
limit: 1
|
||||||
|
tracing:
|
||||||
|
interface: tracing
|
||||||
|
optional: true
|
||||||
|
limit: 1
|
||||||
|
|
||||||
|
provides:
|
||||||
|
ceph-access:
|
||||||
|
interface: cinder-ceph-key
|
||||||
|
|
||||||
|
peers:
|
||||||
|
peers:
|
||||||
|
interface: cinder-peer
|
||||||
|
|
||||||
|
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
|
||||||
|
- pydantic
|
||||||
|
- jinja2
|
3
charms/cinder-volume-ceph/rebuild
Normal file
3
charms/cinder-volume-ceph/rebuild
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# This file is used to trigger a build.
|
||||||
|
# Change uuid to trigger a new build.
|
||||||
|
37af2d20-53dc-11ef-97a3-b37540f14c92
|
27
charms/cinder-volume-ceph/requirements.txt
Normal file
27
charms/cinder-volume-ceph/requirements.txt
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# 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
|
||||||
|
pydantic
|
||||||
|
lightkube
|
||||||
|
lightkube-models
|
||||||
|
requests # Drop - not needed in storage backend interface.
|
||||||
|
ops
|
||||||
|
|
||||||
|
git+https://opendev.org/openstack/charm-ops-interface-tls-certificates#egg=interface_tls_certificates
|
||||||
|
|
||||||
|
# Note: Required for cinder-k8s, cinder-ceph-k8s, glance-k8s, nova-k8s
|
||||||
|
git+https://opendev.org/openstack/charm-ops-interface-ceph-client#egg=interface_ceph_client
|
||||||
|
# Charmhelpers is only present as interface_ceph_client uses it.
|
||||||
|
git+https://github.com/juju/charm-helpers.git#egg=charmhelpers
|
||||||
|
|
||||||
|
# TODO
|
||||||
|
requests # Drop - not needed in storage backend interface.
|
||||||
|
netifaces # Drop when charmhelpers dependency is removed.
|
||||||
|
|
||||||
|
# From ops_sunbeam
|
||||||
|
tenacity
|
297
charms/cinder-volume-ceph/src/charm.py
Executable file
297
charms/cinder-volume-ceph/src/charm.py
Executable file
@ -0,0 +1,297 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright 2025 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.
|
||||||
|
|
||||||
|
"""Cinder Ceph Operator Charm.
|
||||||
|
|
||||||
|
This charm provide Cinder <-> Ceph integration as part
|
||||||
|
of an OpenStack deployment
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import (
|
||||||
|
Callable,
|
||||||
|
Mapping,
|
||||||
|
)
|
||||||
|
|
||||||
|
import charms.cinder_ceph_k8s.v0.ceph_access as sunbeam_ceph_access # noqa
|
||||||
|
import ops
|
||||||
|
import ops.charm
|
||||||
|
import ops_sunbeam.charm as charm
|
||||||
|
import ops_sunbeam.config_contexts as config_contexts
|
||||||
|
import ops_sunbeam.guard as sunbeam_guard
|
||||||
|
import ops_sunbeam.relation_handlers as relation_handlers
|
||||||
|
import ops_sunbeam.relation_handlers as sunbeam_rhandlers
|
||||||
|
import ops_sunbeam.tracing as sunbeam_tracing
|
||||||
|
from ops.model import (
|
||||||
|
Relation,
|
||||||
|
SecretRotate,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@sunbeam_tracing.trace_type
|
||||||
|
class CinderCephConfigurationContext(config_contexts.ConfigContext):
|
||||||
|
"""Configuration context for cinder parameters."""
|
||||||
|
|
||||||
|
charm: "CinderVolumeCephOperatorCharm"
|
||||||
|
|
||||||
|
def context(self) -> dict:
|
||||||
|
"""Generate context information for cinder config."""
|
||||||
|
config = self.charm.model.config.get
|
||||||
|
data_pool_name = config("rbd-pool-name") or self.charm.app.name
|
||||||
|
if config("pool-type") == sunbeam_rhandlers.ERASURE_CODED:
|
||||||
|
pool_name = (
|
||||||
|
config("ec-rbd-metadata-pool") or f"{data_pool_name}-metadata"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pool_name = data_pool_name
|
||||||
|
backend_name = config("volume-backend-name") or self.charm.app.name
|
||||||
|
return {
|
||||||
|
"rbd_pool": pool_name,
|
||||||
|
"rbd_user": self.charm.app.name,
|
||||||
|
"backend_name": backend_name,
|
||||||
|
"backend_availability_zone": config("backend-availability-zone"),
|
||||||
|
"secret_uuid": self.charm.get_secret_uuid() or "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@sunbeam_tracing.trace_type
|
||||||
|
class CephAccessProvidesHandler(sunbeam_rhandlers.RelationHandler):
|
||||||
|
"""Handler for identity service relation."""
|
||||||
|
|
||||||
|
interface: sunbeam_ceph_access.CephAccessProvides
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
charm: charm.OSBaseOperatorCharm,
|
||||||
|
relation_name: str,
|
||||||
|
callback_f: Callable,
|
||||||
|
):
|
||||||
|
super().__init__(charm, relation_name, callback_f)
|
||||||
|
|
||||||
|
def setup_event_handler(self):
|
||||||
|
"""Configure event handlers for an Identity service relation."""
|
||||||
|
logger.debug("Setting up Ceph Access event handler")
|
||||||
|
ceph_access_svc = sunbeam_tracing.trace_type(
|
||||||
|
sunbeam_ceph_access.CephAccessProvides
|
||||||
|
)(
|
||||||
|
self.charm,
|
||||||
|
self.relation_name,
|
||||||
|
)
|
||||||
|
self.framework.observe(
|
||||||
|
ceph_access_svc.on.ready_ceph_access_clients,
|
||||||
|
self._on_ceph_access_ready,
|
||||||
|
)
|
||||||
|
return ceph_access_svc
|
||||||
|
|
||||||
|
def _on_ceph_access_ready(self, event) -> None:
|
||||||
|
"""Handles AMQP change events."""
|
||||||
|
# Ready is only emitted when the interface considers
|
||||||
|
# that the relation is complete.
|
||||||
|
self.callback_f(event)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
"""Report if relation is ready."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@sunbeam_tracing.trace_sunbeam_charm
|
||||||
|
class CinderVolumeCephOperatorCharm(charm.OSCinderVolumeDriverOperatorCharm):
|
||||||
|
"""Cinder/Ceph Operator charm."""
|
||||||
|
|
||||||
|
service_name = "cinder-volume-ceph"
|
||||||
|
|
||||||
|
client_secret_key = "secret-uuid"
|
||||||
|
|
||||||
|
ceph_access_relation_name = "ceph-access"
|
||||||
|
|
||||||
|
def configure_charm(self, event: ops.EventBase):
|
||||||
|
"""Catchall handler to configure charm services."""
|
||||||
|
super().configure_charm(event)
|
||||||
|
if self.has_ceph_relation() and self.ceph.ready:
|
||||||
|
logger.info("CONFIG changed and ceph ready: calling request pools")
|
||||||
|
self.ceph.request_pools(event)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def backend_key(self) -> str:
|
||||||
|
"""Return the backend key."""
|
||||||
|
return "ceph." + self.model.app.name
|
||||||
|
|
||||||
|
def get_relation_handlers(
|
||||||
|
self, handlers: list[relation_handlers.RelationHandler] | None = None
|
||||||
|
) -> list[relation_handlers.RelationHandler]:
|
||||||
|
"""Relation handlers for the service."""
|
||||||
|
handlers = handlers or []
|
||||||
|
self.ceph = relation_handlers.CephClientHandler(
|
||||||
|
self,
|
||||||
|
"ceph",
|
||||||
|
self.configure_charm,
|
||||||
|
allow_ec_overwrites=True,
|
||||||
|
app_name="rbd",
|
||||||
|
mandatory="ceph" in self.mandatory_relations,
|
||||||
|
)
|
||||||
|
handlers.append(self.ceph)
|
||||||
|
self.ceph_access = CephAccessProvidesHandler(
|
||||||
|
self,
|
||||||
|
"ceph-access",
|
||||||
|
self.process_ceph_access_client_event,
|
||||||
|
) # type: ignore
|
||||||
|
handlers.append(self.ceph_access)
|
||||||
|
return super().get_relation_handlers(handlers)
|
||||||
|
|
||||||
|
def has_ceph_relation(self) -> bool:
|
||||||
|
"""Returns whether or not the application has been related to Ceph.
|
||||||
|
|
||||||
|
:return: True if the ceph relation has been made, False otherwise.
|
||||||
|
"""
|
||||||
|
return self.model.get_relation("ceph") is not None
|
||||||
|
|
||||||
|
def get_backend_configuration(self) -> Mapping:
|
||||||
|
"""Return the backend configuration."""
|
||||||
|
try:
|
||||||
|
contexts = self.contexts()
|
||||||
|
return {
|
||||||
|
"volume-backend-name": contexts.cinder_ceph.backend_name,
|
||||||
|
"backend-availability-zone": contexts.cinder_ceph.backend_availability_zone,
|
||||||
|
"mon-hosts": contexts.ceph.mon_hosts,
|
||||||
|
"rbd-pool": contexts.cinder_ceph.rbd_pool,
|
||||||
|
"rbd-user": contexts.cinder_ceph.rbd_user,
|
||||||
|
"rbd-secret-uuid": contexts.cinder_ceph.secret_uuid,
|
||||||
|
"rbd-key": contexts.ceph.key,
|
||||||
|
"auth": contexts.ceph.auth,
|
||||||
|
}
|
||||||
|
except AttributeError as e:
|
||||||
|
raise sunbeam_guard.WaitingExceptionError(
|
||||||
|
"Data missing: {}".format(e.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config_contexts(self) -> list[config_contexts.ConfigContext]:
|
||||||
|
"""Configuration contexts for the operator."""
|
||||||
|
contexts = super().config_contexts
|
||||||
|
contexts.append(CinderCephConfigurationContext(self, "cinder_ceph"))
|
||||||
|
return contexts
|
||||||
|
|
||||||
|
def _set_or_update_rbd_secret(
|
||||||
|
self,
|
||||||
|
ceph_key: str,
|
||||||
|
scope: dict = {},
|
||||||
|
rotate: SecretRotate = SecretRotate.NEVER,
|
||||||
|
) -> str:
|
||||||
|
"""Create ceph access secret or update it.
|
||||||
|
|
||||||
|
Create ceph access secret or if it already exists check the contents
|
||||||
|
and update them if needed.
|
||||||
|
"""
|
||||||
|
rbd_secret_uuid_id = self.peers.get_app_data(self.client_secret_key)
|
||||||
|
if rbd_secret_uuid_id:
|
||||||
|
secret = self.model.get_secret(id=rbd_secret_uuid_id)
|
||||||
|
secret_data = secret.get_content(refresh=True)
|
||||||
|
if secret_data.get("key") != ceph_key:
|
||||||
|
secret_data["key"] = ceph_key
|
||||||
|
secret.set_content(secret_data)
|
||||||
|
else:
|
||||||
|
secret = self.model.app.add_secret(
|
||||||
|
{
|
||||||
|
"uuid": str(uuid.uuid4()),
|
||||||
|
"key": ceph_key,
|
||||||
|
},
|
||||||
|
label=self.client_secret_key,
|
||||||
|
rotate=rotate,
|
||||||
|
)
|
||||||
|
self.peers.set_app_data(
|
||||||
|
{
|
||||||
|
self.client_secret_key: secret.id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if "relation" in scope:
|
||||||
|
secret.grant(scope["relation"])
|
||||||
|
|
||||||
|
return secret.id
|
||||||
|
|
||||||
|
def get_secret_uuid(self) -> str | None:
|
||||||
|
"""Get the secret uuid."""
|
||||||
|
uuid = None
|
||||||
|
rbd_secret_uuid_id = self.peers.get_app_data(self.client_secret_key)
|
||||||
|
if rbd_secret_uuid_id:
|
||||||
|
secret = self.model.get_secret(id=rbd_secret_uuid_id)
|
||||||
|
secret_data = secret.get_content(refresh=True)
|
||||||
|
uuid = secret_data["uuid"]
|
||||||
|
return uuid
|
||||||
|
|
||||||
|
def configure_app_leader(self, event: ops.framework.EventBase):
|
||||||
|
"""Run global app setup.
|
||||||
|
|
||||||
|
These are tasks that should only be run once per application and only
|
||||||
|
the leader runs them.
|
||||||
|
"""
|
||||||
|
if self.ceph.ready:
|
||||||
|
self._set_or_update_rbd_secret(self.ceph.key)
|
||||||
|
self.set_leader_ready()
|
||||||
|
self.broadcast_ceph_access_credentials()
|
||||||
|
else:
|
||||||
|
raise sunbeam_guard.WaitingExceptionError(
|
||||||
|
"Ceph relation not ready"
|
||||||
|
)
|
||||||
|
|
||||||
|
def can_service_requests(self) -> bool:
|
||||||
|
"""Check if unit can process client requests."""
|
||||||
|
if self.bootstrapped() and self.unit.is_leader():
|
||||||
|
logger.debug("Can service client requests")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"Cannot service client requests. "
|
||||||
|
"Bootstrapped: {} Leader {}".format(
|
||||||
|
self.bootstrapped(), self.unit.is_leader()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_ceph_access_credentials(self, relation: Relation):
|
||||||
|
"""Send clients a link to the secret and grant them access."""
|
||||||
|
rbd_secret_uuid_id = self.peers.get_app_data(self.client_secret_key)
|
||||||
|
secret = self.model.get_secret(id=rbd_secret_uuid_id)
|
||||||
|
secret.grant(relation)
|
||||||
|
self.ceph_access.interface.set_ceph_access_credentials(
|
||||||
|
self.ceph_access_relation_name, relation.id, rbd_secret_uuid_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def process_ceph_access_client_event(self, event: ops.framework.EventBase):
|
||||||
|
"""Inform a single client of the access data."""
|
||||||
|
self.broadcast_ceph_access_credentials(relation_id=event.relation.id)
|
||||||
|
|
||||||
|
def broadcast_ceph_access_credentials(
|
||||||
|
self, relation_id: str | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Send ceph access data to clients."""
|
||||||
|
logger.debug("Checking for outstanding client requests")
|
||||||
|
if not self.can_service_requests():
|
||||||
|
return
|
||||||
|
for relation in self.framework.model.relations[
|
||||||
|
self.ceph_access_relation_name
|
||||||
|
]:
|
||||||
|
if relation_id and relation.id == relation_id:
|
||||||
|
self.send_ceph_access_credentials(relation)
|
||||||
|
elif not relation_id:
|
||||||
|
self.send_ceph_access_credentials(relation)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: nocover
|
||||||
|
ops.main(CinderVolumeCephOperatorCharm)
|
16
charms/cinder-volume-ceph/tests/unit/__init__.py
Normal file
16
charms/cinder-volume-ceph/tests/unit/__init__.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2025 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.
|
||||||
|
|
||||||
|
"""Unit testing module for Cinder Volume Ceph operator."""
|
@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# Copyright 2025 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.
|
||||||
|
|
||||||
|
"""Unit tests for Cinder Ceph operator charm class."""
|
||||||
|
|
||||||
|
from unittest.mock import (
|
||||||
|
MagicMock,
|
||||||
|
Mock,
|
||||||
|
patch,
|
||||||
|
)
|
||||||
|
|
||||||
|
import charm
|
||||||
|
import ops.testing
|
||||||
|
import ops_sunbeam.test_utils as test_utils
|
||||||
|
|
||||||
|
|
||||||
|
class _CinderVolumeCephOperatorCharm(charm.CinderVolumeCephOperatorCharm):
|
||||||
|
"""Charm wrapper for test usage."""
|
||||||
|
|
||||||
|
openstack_release = "wallaby"
|
||||||
|
|
||||||
|
def __init__(self, framework):
|
||||||
|
self.seen_events = []
|
||||||
|
super().__init__(framework)
|
||||||
|
|
||||||
|
def _log_event(self, event):
|
||||||
|
self.seen_events.append(type(event).__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def add_complete_cinder_volume_relation(harness: ops.testing.Harness) -> int:
|
||||||
|
"""Add a complete cinder-volume relation to the charm."""
|
||||||
|
return harness.add_relation(
|
||||||
|
"cinder-volume",
|
||||||
|
"cinder-volume",
|
||||||
|
unit_data={
|
||||||
|
"snap-name": "cinder-volume",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCinderCephOperatorCharm(test_utils.CharmTestCase):
|
||||||
|
"""Test cases for CinderCephOperatorCharm class."""
|
||||||
|
|
||||||
|
PATCHES = []
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Setup fixtures ready for testing."""
|
||||||
|
super().setUp(charm, self.PATCHES)
|
||||||
|
self.mock_event = MagicMock()
|
||||||
|
self.snap = Mock()
|
||||||
|
snap_patch = patch.object(
|
||||||
|
_CinderVolumeCephOperatorCharm,
|
||||||
|
"_import_snap",
|
||||||
|
Mock(return_value=self.snap),
|
||||||
|
)
|
||||||
|
snap_patch.start()
|
||||||
|
self.harness = test_utils.get_harness(
|
||||||
|
_CinderVolumeCephOperatorCharm,
|
||||||
|
container_calls=self.container_calls,
|
||||||
|
)
|
||||||
|
mock_get_platform = patch(
|
||||||
|
"charmhelpers.osplatform.get_platform", return_value="ubuntu"
|
||||||
|
)
|
||||||
|
mock_get_platform.start()
|
||||||
|
|
||||||
|
self.addCleanup(mock_get_platform.stop)
|
||||||
|
self.addCleanup(snap_patch.stop)
|
||||||
|
self.addCleanup(self.harness.cleanup)
|
||||||
|
|
||||||
|
def test_all_relations(self):
|
||||||
|
"""Test charm in context of full set of relations."""
|
||||||
|
self.harness.begin_with_initial_hooks()
|
||||||
|
test_utils.add_complete_ceph_relation(self.harness)
|
||||||
|
add_complete_cinder_volume_relation(self.harness)
|
||||||
|
self.assertSetEqual(
|
||||||
|
self.harness.charm.get_mandatory_relations_not_ready(
|
||||||
|
self.mock_event
|
||||||
|
),
|
||||||
|
set(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ceph_access(self):
|
||||||
|
"""Test charm provides secret via ceph-access."""
|
||||||
|
cinder_volume_snap_mock = MagicMock()
|
||||||
|
cinder_volume_snap_mock.present = False
|
||||||
|
self.snap.SnapState.Latest = "latest"
|
||||||
|
self.snap.SnapCache.return_value = {
|
||||||
|
"cinder-volume": cinder_volume_snap_mock
|
||||||
|
}
|
||||||
|
self.harness.begin_with_initial_hooks()
|
||||||
|
self.harness.set_leader()
|
||||||
|
test_utils.add_complete_ceph_relation(self.harness)
|
||||||
|
add_complete_cinder_volume_relation(self.harness)
|
||||||
|
access_rel = self.harness.add_relation(
|
||||||
|
"ceph-access", "openstack-hypervisor", unit_data={"oui": "non"}
|
||||||
|
)
|
||||||
|
self.assertSetEqual(
|
||||||
|
self.harness.charm.get_mandatory_relations_not_ready(
|
||||||
|
self.mock_event
|
||||||
|
),
|
||||||
|
set(),
|
||||||
|
)
|
||||||
|
expect_settings = {
|
||||||
|
"ceph.cinder-volume-ceph": {
|
||||||
|
"volume-backend-name": "cinder-volume-ceph",
|
||||||
|
"backend-availability-zone": None,
|
||||||
|
"mon-hosts": "192.0.2.2",
|
||||||
|
"rbd-pool": "cinder-volume-ceph",
|
||||||
|
"rbd-user": "cinder-volume-ceph",
|
||||||
|
"rbd-secret-uuid": "unknown",
|
||||||
|
"rbd-key": "AQBUfpVeNl7CHxAA8/f6WTcYFxW2dJ5VyvWmJg==",
|
||||||
|
"auth": "cephx",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cinder_volume_snap_mock.set.assert_any_call(
|
||||||
|
expect_settings, typed=True
|
||||||
|
)
|
||||||
|
rel_data = self.harness.get_relation_data(
|
||||||
|
access_rel, self.harness.charm.unit.app.name
|
||||||
|
)
|
||||||
|
self.assertRegex(rel_data["access-credentials"], "^secret:.*")
|
1
charms/cinder-volume/.gitignore
vendored
Normal file
1
charms/cinder-volume/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
!lib/charms/cinder_volume/
|
11
charms/cinder-volume/.sunbeam-build.yaml
Normal file
11
charms/cinder-volume/.sunbeam-build.yaml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
external-libraries:
|
||||||
|
- charms.operator_libs_linux.v2.snap
|
||||||
|
- charms.data_platform_libs.v0.data_interfaces
|
||||||
|
- charms.rabbitmq_k8s.v0.rabbitmq
|
||||||
|
- charms.loki_k8s.v1.loki_push_api
|
||||||
|
- charms.tempo_k8s.v2.tracing
|
||||||
|
- charms.tempo_k8s.v1.charm_tracing
|
||||||
|
internal-libraries:
|
||||||
|
- charms.keystone_k8s.v0.identity_credentials
|
||||||
|
- charms.cinder_k8s.v0.storage_backend
|
||||||
|
templates: []
|
52
charms/cinder-volume/CONTRIBUTING.md
Normal file
52
charms/cinder-volume/CONTRIBUTING.md
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# cinder-volume
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Create and activate a virtualenv with the development requirements:
|
||||||
|
|
||||||
|
virtualenv -p python3 venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
## Code overview
|
||||||
|
|
||||||
|
Get familiarise with [Charmed Operator Framework](https://juju.is/docs/sdk)
|
||||||
|
and [Sunbeam documentation](sunbeam-docs).
|
||||||
|
|
||||||
|
cinder-volume charm uses the ops\_sunbeam library and extends
|
||||||
|
OSBaseOperatorCharm from the library.
|
||||||
|
|
||||||
|
cinder-volume charm consumes database relation to connect to database,
|
||||||
|
amqp to connect to rabbitmq.
|
||||||
|
|
||||||
|
The charm starts cinder-volume service.
|
||||||
|
|
||||||
|
## Intended use case
|
||||||
|
|
||||||
|
cinder-volume charm deploys and configures OpenStack Block storage service.
|
||||||
|
|
||||||
|
## 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 ./cinder-volume.charm
|
||||||
|
|
||||||
|
<!-- LINKS -->
|
||||||
|
|
||||||
|
[sunbeam-docs]: https://opendev.org/openstack/charm-ops-sunbeam/src/branch/main/README.rst
|
202
charms/cinder-volume/LICENSE
Normal file
202
charms/cinder-volume/LICENSE
Normal file
@ -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.
|
55
charms/cinder-volume/README.md
Normal file
55
charms/cinder-volume/README.md
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
# cinder-volume
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The cinder-volume is an operator to manage the Cinder-volume service
|
||||||
|
in a snap based deployment.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
cinder-volume is deployed using below command:
|
||||||
|
|
||||||
|
juju deploy cinder-volume
|
||||||
|
|
||||||
|
Now connect the cinder-volume application to database, messaging and Ceph
|
||||||
|
services:
|
||||||
|
|
||||||
|
juju relate mysql:database cinder-volume:database
|
||||||
|
juju relate rabbitmq:amqp cinder-volume:amqp
|
||||||
|
juju relate keystone:identity-credentials cinder-volume:identity-credentials
|
||||||
|
juju relate cinder:storage-backend cinder-volume:storage-backend
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
This section covers common and/or important configuration options. See file
|
||||||
|
`config.yaml` for the full list of options, along with their descriptions and
|
||||||
|
default values. See the [Juju documentation][juju-docs-config-apps] for details
|
||||||
|
on configuring applications.
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
|
||||||
|
This section covers Juju [actions][juju-docs-actions] supported by the charm.
|
||||||
|
Actions allow specific operations to be performed on a per-unit basis. To
|
||||||
|
display action descriptions run `juju actions cinderceph`. If the charm is not
|
||||||
|
deployed then see file `actions.yaml`.
|
||||||
|
|
||||||
|
## Relations
|
||||||
|
|
||||||
|
cinder-volume requires the following relations:
|
||||||
|
|
||||||
|
`amqp`: To connect to RabbitMQ
|
||||||
|
`database`: To connect to MySQL
|
||||||
|
`identity-credentials`: To connect to 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](contributors-guide) for developer guidance.
|
||||||
|
|
||||||
|
<!-- LINKS -->
|
||||||
|
|
||||||
|
[juju-docs-actions]: https://jaas.ai/docs/actions
|
||||||
|
[juju-docs-config-apps]: https://juju.is/docs/configuring-applications
|
106
charms/cinder-volume/charmcraft.yaml
Normal file
106
charms/cinder-volume/charmcraft.yaml
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
type: charm
|
||||||
|
name: cinder-volume
|
||||||
|
summary: OpenStack volume service
|
||||||
|
description: |
|
||||||
|
Cinder is the OpenStack project that provides volume management for
|
||||||
|
instances. This charm provides Cinder Volume service.
|
||||||
|
assumes:
|
||||||
|
- juju >= 3.1
|
||||||
|
links:
|
||||||
|
source:
|
||||||
|
- https://opendev.org/openstack/sunbeam-charms
|
||||||
|
issues:
|
||||||
|
- https://bugs.launchpad.net/sunbeam-charms
|
||||||
|
|
||||||
|
base: ubuntu@24.04
|
||||||
|
platforms:
|
||||||
|
amd64:
|
||||||
|
|
||||||
|
config:
|
||||||
|
options:
|
||||||
|
debug:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
description: Enable debug logging.
|
||||||
|
snap-name:
|
||||||
|
default: cinder-volume
|
||||||
|
type: string
|
||||||
|
description: Name of the snap to install.
|
||||||
|
snap-channel:
|
||||||
|
default: 2024.1/edge
|
||||||
|
type: string
|
||||||
|
rabbit-user:
|
||||||
|
type: string
|
||||||
|
default: null
|
||||||
|
description: Username to request access on rabbitmq-server.
|
||||||
|
rabbit-vhost:
|
||||||
|
type: string
|
||||||
|
default: null
|
||||||
|
description: RabbitMQ virtual host to request access on rabbitmq-server.
|
||||||
|
enable-telemetry-notifications:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
description: Enable notifications to send to telemetry.
|
||||||
|
image-volume-cache-enabled:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
description: |
|
||||||
|
Enable the image volume cache.
|
||||||
|
image-volume-cache-max-size-gb:
|
||||||
|
type: int
|
||||||
|
default: 0
|
||||||
|
description: |
|
||||||
|
Max size of the image volume cache in GB. 0 means unlimited.
|
||||||
|
image-volume-cache-max-count:
|
||||||
|
type: int
|
||||||
|
default: 0
|
||||||
|
description: |
|
||||||
|
Max number of entries allowed in the image volume cache. 0 means
|
||||||
|
unlimited.
|
||||||
|
default-volume-type:
|
||||||
|
type: string
|
||||||
|
default: null
|
||||||
|
description: |
|
||||||
|
Default volume type to use when creating volumes.
|
||||||
|
|
||||||
|
requires:
|
||||||
|
amqp:
|
||||||
|
interface: rabbitmq
|
||||||
|
database:
|
||||||
|
interface: mysql_client
|
||||||
|
limit: 1
|
||||||
|
identity-credentials:
|
||||||
|
interface: keystone-credentials
|
||||||
|
tracing:
|
||||||
|
interface: tracing
|
||||||
|
optional: true
|
||||||
|
limit: 1
|
||||||
|
|
||||||
|
provides:
|
||||||
|
storage-backend:
|
||||||
|
interface: cinder-backend
|
||||||
|
cinder-volume:
|
||||||
|
interface: cinder-volume
|
||||||
|
|
||||||
|
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
|
||||||
|
- pydantic
|
||||||
|
- jinja2
|
@ -0,0 +1,270 @@
|
|||||||
|
"""CinderVolume Provides and Requires module.
|
||||||
|
|
||||||
|
This library contains the Requires and Provides classes for handling
|
||||||
|
the cinder-volume interface.
|
||||||
|
|
||||||
|
Import `CinderVolumeRequires` in your charm, with the charm object and the
|
||||||
|
relation name:
|
||||||
|
- self
|
||||||
|
- "cinder-volume"
|
||||||
|
- backend_key
|
||||||
|
|
||||||
|
Three events are also available to respond to:
|
||||||
|
- connected
|
||||||
|
- ready
|
||||||
|
- goneaway
|
||||||
|
|
||||||
|
A basic example showing the usage of this relation follows:
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
from charms.cinder_volume.v0.cinder_volume import CinderVolumeRequires
|
||||||
|
|
||||||
|
class CinderVolumeDriver(CharmBase):
|
||||||
|
def __init__(self, *args):
|
||||||
|
super().__init__(*args)
|
||||||
|
# CinderVolume Requires
|
||||||
|
self.cinder_volume = CinderVolumeRequires(
|
||||||
|
self,
|
||||||
|
relation_name="cinder-volume",
|
||||||
|
backend_key="ceph.monoceph",
|
||||||
|
)
|
||||||
|
self.framework.observe(
|
||||||
|
self.cinder_volume.on.connected, self._on_cinder_volume_connected
|
||||||
|
)
|
||||||
|
self.framework.observe(
|
||||||
|
self.cinder_volume.on.ready, self._on_cinder_volume_ready
|
||||||
|
)
|
||||||
|
self.framework.observe(
|
||||||
|
self.cinder_volume.on.goneaway, self._on_cinder_volume_goneaway
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_cinder_volume_connected(self, event):
|
||||||
|
'''React to the CinderVolume connected event.
|
||||||
|
|
||||||
|
This event happens when CinderVolume relation is added to the
|
||||||
|
model before credentials etc have been provided.
|
||||||
|
'''
|
||||||
|
# Do something before the relation is complete
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_cinder_volume_ready(self, event):
|
||||||
|
'''React to the CinderVolume ready event.
|
||||||
|
|
||||||
|
This event happens when an CinderVolume relation is ready.
|
||||||
|
'''
|
||||||
|
# CinderVolume Relation is ready. Configure services or suchlike
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_cinder_volume_goneaway(self, event):
|
||||||
|
'''React to the CinderVolume goneaway event.
|
||||||
|
|
||||||
|
This event happens when an CinderVolume relation is broken.
|
||||||
|
'''
|
||||||
|
# CinderVolume Relation has goneaway. shutdown services or suchlike
|
||||||
|
pass
|
||||||
|
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import ops
|
||||||
|
|
||||||
|
|
||||||
|
# The unique Charmhub library identifier, never change it
|
||||||
|
LIBID = "9aa142db811f4f8588a257d7dc6dff86"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BACKEND_KEY = "backend"
|
||||||
|
SNAP_KEY = "snap-name"
|
||||||
|
|
||||||
|
|
||||||
|
class CinderVolumeConnectedEvent(ops.RelationJoinedEvent):
|
||||||
|
"""CinderVolume connected Event."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CinderVolumeReadyEvent(ops.RelationChangedEvent):
|
||||||
|
"""CinderVolume ready for use Event."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CinderVolumeGoneAwayEvent(ops.RelationBrokenEvent):
|
||||||
|
"""CinderVolume relation has gone-away Event"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CinderVolumeRequiresEvents(ops.ObjectEvents):
|
||||||
|
"""Events class for `on`"""
|
||||||
|
|
||||||
|
connected = ops.EventSource(CinderVolumeConnectedEvent)
|
||||||
|
ready = ops.EventSource(CinderVolumeReadyEvent)
|
||||||
|
goneaway = ops.EventSource(CinderVolumeGoneAwayEvent)
|
||||||
|
|
||||||
|
|
||||||
|
def remote_unit(relation: ops.Relation) -> ops.Unit | None:
|
||||||
|
if len(relation.units) == 0:
|
||||||
|
return None
|
||||||
|
return list(relation.units)[0]
|
||||||
|
|
||||||
|
|
||||||
|
class CinderVolumeRequires(ops.Object):
|
||||||
|
"""
|
||||||
|
CinderVolumeRequires class
|
||||||
|
"""
|
||||||
|
|
||||||
|
on = CinderVolumeRequiresEvents() # type: ignore
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, charm: ops.CharmBase, relation_name: str, backend_key: str
|
||||||
|
):
|
||||||
|
super().__init__(charm, relation_name)
|
||||||
|
self.charm = charm
|
||||||
|
self.relation_name = relation_name
|
||||||
|
self.backend_key = backend_key
|
||||||
|
rel_observer = self.charm.on[relation_name]
|
||||||
|
self.framework.observe(
|
||||||
|
rel_observer.relation_joined,
|
||||||
|
self._on_cinder_volume_relation_joined,
|
||||||
|
)
|
||||||
|
self.framework.observe(
|
||||||
|
rel_observer.relation_changed,
|
||||||
|
self._on_cinder_volume_relation_changed,
|
||||||
|
)
|
||||||
|
self.framework.observe(
|
||||||
|
rel_observer.relation_departed,
|
||||||
|
self._on_cinder_volume_relation_changed,
|
||||||
|
)
|
||||||
|
self.framework.observe(
|
||||||
|
rel_observer.relation_broken,
|
||||||
|
self._on_cinder_volume_relation_broken,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_cinder_volume_relation_joined(self, event):
|
||||||
|
"""CinderVolume relation joined."""
|
||||||
|
logging.debug("CinderVolumeRequires on_joined")
|
||||||
|
self.on.connected.emit(event.relation)
|
||||||
|
|
||||||
|
def _on_cinder_volume_relation_changed(self, event):
|
||||||
|
"""CinderVolume relation changed."""
|
||||||
|
logging.debug("CinderVolumeRequires on_changed")
|
||||||
|
if self.provider_ready():
|
||||||
|
self.on.ready.emit(event.relation)
|
||||||
|
|
||||||
|
def _on_cinder_volume_relation_broken(self, event):
|
||||||
|
"""CinderVolume relation broken."""
|
||||||
|
logging.debug("CinderVolumeRequires on_broken")
|
||||||
|
self.on.goneaway.emit(event.relation)
|
||||||
|
|
||||||
|
def snap_name(self) -> str | None:
|
||||||
|
"""Return the snap name."""
|
||||||
|
relation = self.model.get_relation(self.relation_name)
|
||||||
|
if relation is None:
|
||||||
|
return None
|
||||||
|
sub_unit = remote_unit(relation)
|
||||||
|
if sub_unit is None:
|
||||||
|
logger.debug("No remote unit yet")
|
||||||
|
return None
|
||||||
|
return relation.data[sub_unit].get(SNAP_KEY)
|
||||||
|
|
||||||
|
def provider_ready(self) -> bool:
|
||||||
|
return self.snap_name() is not None
|
||||||
|
|
||||||
|
def set_ready(self) -> None:
|
||||||
|
"""Communicate Cinder backend is ready."""
|
||||||
|
logging.debug("Signaling backend has been configured")
|
||||||
|
relation = self.model.get_relation(self.relation_name)
|
||||||
|
if relation is not None:
|
||||||
|
relation.data[self.model.unit][BACKEND_KEY] = self.backend_key
|
||||||
|
|
||||||
|
|
||||||
|
class DriverReadyEvent(ops.RelationChangedEvent):
|
||||||
|
"""Driver Ready Event."""
|
||||||
|
|
||||||
|
|
||||||
|
class DriverGoneEvent(ops.RelationBrokenEvent):
|
||||||
|
"""Driver Gone Event."""
|
||||||
|
|
||||||
|
|
||||||
|
class CinderVolumeClientEvents(ops.ObjectEvents):
|
||||||
|
"""Events class for `on`"""
|
||||||
|
|
||||||
|
driver_ready = ops.EventSource(DriverReadyEvent)
|
||||||
|
driver_gone = ops.EventSource(DriverGoneEvent)
|
||||||
|
|
||||||
|
|
||||||
|
class CinderVolumeProvides(ops.Object):
|
||||||
|
"""
|
||||||
|
CinderVolumeProvides class
|
||||||
|
"""
|
||||||
|
|
||||||
|
on = CinderVolumeClientEvents() # type: ignore
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, charm: ops.CharmBase, relation_name: str, snap_name: str
|
||||||
|
):
|
||||||
|
super().__init__(charm, relation_name)
|
||||||
|
self.charm = charm
|
||||||
|
self.relation_name = relation_name
|
||||||
|
self.snap_name = snap_name
|
||||||
|
rel_observer = self.charm.on[relation_name]
|
||||||
|
self.framework.observe(
|
||||||
|
rel_observer.relation_joined,
|
||||||
|
self._on_cinder_volume_relation_joined,
|
||||||
|
)
|
||||||
|
self.framework.observe(
|
||||||
|
rel_observer.relation_changed,
|
||||||
|
self._on_cinder_volume_relation_changed,
|
||||||
|
)
|
||||||
|
self.framework.observe(
|
||||||
|
rel_observer.relation_broken,
|
||||||
|
self._on_cinder_volume_relation_broken,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_cinder_volume_relation_joined(
|
||||||
|
self, event: ops.RelationJoinedEvent
|
||||||
|
):
|
||||||
|
"""Handle CinderVolume joined."""
|
||||||
|
logging.debug("CinderVolumeProvides on_joined")
|
||||||
|
self.publish_snap(event.relation)
|
||||||
|
|
||||||
|
def _on_cinder_volume_relation_changed(
|
||||||
|
self, event: ops.RelationChangedEvent
|
||||||
|
):
|
||||||
|
"""Handle CinderVolume changed."""
|
||||||
|
logging.debug("CinderVolumeProvides on_changed")
|
||||||
|
if self.requirer_ready(event.relation):
|
||||||
|
self.on.driver_ready.emit(event.relation)
|
||||||
|
|
||||||
|
def _on_cinder_volume_relation_broken(
|
||||||
|
self, event: ops.RelationBrokenEvent
|
||||||
|
):
|
||||||
|
"""Handle CinderVolume broken."""
|
||||||
|
logging.debug("CinderVolumeProvides on_departed")
|
||||||
|
self.on.driver_gone.emit(event.relation)
|
||||||
|
|
||||||
|
def requirer_backend(self, relation: ops.Relation) -> str | None:
|
||||||
|
sub_unit = remote_unit(relation)
|
||||||
|
if sub_unit is None:
|
||||||
|
logger.debug("No remote unit yet")
|
||||||
|
return None
|
||||||
|
return relation.data[sub_unit].get(BACKEND_KEY)
|
||||||
|
|
||||||
|
def requirer_ready(self, relation: ops.Relation) -> bool:
|
||||||
|
return self.requirer_backend(relation) is not None
|
||||||
|
|
||||||
|
def publish_snap(self, relation: ops.Relation):
|
||||||
|
"""Publish snap name to relation."""
|
||||||
|
relation.data[self.model.unit][SNAP_KEY] = self.snap_name
|
3
charms/cinder-volume/rebuild
Normal file
3
charms/cinder-volume/rebuild
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# This file is used to trigger a build.
|
||||||
|
# Change uuid to trigger a new build.
|
||||||
|
37af2d20-53dc-11ef-97a3-b37540f14c92
|
21
charms/cinder-volume/requirements.txt
Normal file
21
charms/cinder-volume/requirements.txt
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# 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
|
||||||
|
pydantic
|
||||||
|
lightkube
|
||||||
|
lightkube-models
|
||||||
|
requests # Drop - not needed in storage backend interface.
|
||||||
|
ops
|
||||||
|
|
||||||
|
git+https://opendev.org/openstack/charm-ops-interface-tls-certificates#egg=interface_tls_certificates
|
||||||
|
|
||||||
|
# TODO
|
||||||
|
requests # Drop - not needed in storage backend interface.
|
||||||
|
|
||||||
|
# From ops_sunbeam
|
||||||
|
tenacity
|
285
charms/cinder-volume/src/charm.py
Executable file
285
charms/cinder-volume/src/charm.py
Executable file
@ -0,0 +1,285 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright 2025 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.
|
||||||
|
|
||||||
|
"""Cinder Volume Operator Charm.
|
||||||
|
|
||||||
|
This charm provide Cinder Volume capabilities for OpenStack.
|
||||||
|
This charm is responsible for managing the cinder-volume snap, actual
|
||||||
|
backend configurations are managed by the subordinate charms.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import typing
|
||||||
|
from typing import (
|
||||||
|
Mapping,
|
||||||
|
)
|
||||||
|
|
||||||
|
import charms.cinder_k8s.v0.storage_backend as sunbeam_storage_backend # noqa
|
||||||
|
import charms.cinder_volume.v0.cinder_volume as sunbeam_cinder_volume # noqa
|
||||||
|
import charms.operator_libs_linux.v2.snap as snap
|
||||||
|
import ops
|
||||||
|
import ops.charm
|
||||||
|
import ops_sunbeam.charm as charm
|
||||||
|
import ops_sunbeam.guard as sunbeam_guard
|
||||||
|
import ops_sunbeam.relation_handlers as relation_handlers
|
||||||
|
import ops_sunbeam.relation_handlers as sunbeam_rhandlers
|
||||||
|
import ops_sunbeam.tracing as sunbeam_tracing
|
||||||
|
from ops_sunbeam import (
|
||||||
|
compound_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@sunbeam_tracing.trace_type
|
||||||
|
class StorageBackendProvidesHandler(sunbeam_rhandlers.RelationHandler):
|
||||||
|
"""Relation handler for storage-backend interface type."""
|
||||||
|
|
||||||
|
interface: sunbeam_storage_backend.StorageBackendProvides
|
||||||
|
|
||||||
|
def setup_event_handler(self):
|
||||||
|
"""Configure event handlers for an storage-backend relation."""
|
||||||
|
logger.debug("Setting up Identity Service event handler")
|
||||||
|
sb_svc = sunbeam_tracing.trace_type(
|
||||||
|
sunbeam_storage_backend.StorageBackendProvides
|
||||||
|
)(
|
||||||
|
self.charm,
|
||||||
|
self.relation_name,
|
||||||
|
)
|
||||||
|
self.framework.observe(sb_svc.on.api_ready, self._on_ready)
|
||||||
|
return sb_svc
|
||||||
|
|
||||||
|
def _on_ready(self, event) -> None:
|
||||||
|
"""Handles AMQP change events."""
|
||||||
|
# Ready is only emitted when the interface considers
|
||||||
|
# that the relation is complete (indicated by a password)
|
||||||
|
self.callback_f(event)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
"""Check whether storage-backend interface is ready for use."""
|
||||||
|
return self.interface.remote_ready()
|
||||||
|
|
||||||
|
|
||||||
|
class CinderVolumeProviderHandler(sunbeam_rhandlers.RelationHandler):
|
||||||
|
"""Relation handler for cinder-volume interface type."""
|
||||||
|
|
||||||
|
interface: sunbeam_cinder_volume.CinderVolumeProvides
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
charm: "CinderVolumeOperatorCharm",
|
||||||
|
relation_name: str,
|
||||||
|
snap: str,
|
||||||
|
callback_f: typing.Callable,
|
||||||
|
mandatory: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self._snap = snap
|
||||||
|
super().__init__(charm, relation_name, callback_f, mandatory)
|
||||||
|
|
||||||
|
def setup_event_handler(self):
|
||||||
|
"""Configure event handlers for an cinder-volume relation."""
|
||||||
|
logger.debug("Setting up Identity Service event handler")
|
||||||
|
cinder_volume = sunbeam_tracing.trace_type(
|
||||||
|
sunbeam_cinder_volume.CinderVolumeProvides
|
||||||
|
)(
|
||||||
|
self.charm,
|
||||||
|
self.relation_name,
|
||||||
|
self._snap,
|
||||||
|
)
|
||||||
|
self.framework.observe(cinder_volume.on.driver_ready, self._on_event)
|
||||||
|
self.framework.observe(cinder_volume.on.driver_gone, self._on_event)
|
||||||
|
return cinder_volume
|
||||||
|
|
||||||
|
def _on_event(self, event: ops.RelationEvent) -> None:
|
||||||
|
"""Handles cinder-volume change events."""
|
||||||
|
self.callback_f(event)
|
||||||
|
|
||||||
|
def update_relation_data(self):
|
||||||
|
"""Publish snap name to all related cinder-volume interfaces."""
|
||||||
|
for relation in self.model.relations[self.relation_name]:
|
||||||
|
self.interface.publish_snap(relation)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
"""Check whether cinder-volume interface is ready for use."""
|
||||||
|
relations = self.model.relations[self.relation_name]
|
||||||
|
if not relations:
|
||||||
|
return False
|
||||||
|
for relation in relations:
|
||||||
|
if not self.interface.requirer_ready(relation):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def backends(self) -> typing.Sequence[str]:
|
||||||
|
"""Return a list of backends."""
|
||||||
|
backends = []
|
||||||
|
for relation in self.model.relations[self.relation_name]:
|
||||||
|
if backend := self.interface.requirer_backend(relation):
|
||||||
|
backends.append(backend)
|
||||||
|
return backends
|
||||||
|
|
||||||
|
|
||||||
|
@sunbeam_tracing.trace_sunbeam_charm
|
||||||
|
class CinderVolumeOperatorCharm(charm.OSBaseOperatorCharmSnap):
|
||||||
|
"""Cinder Volume Operator charm."""
|
||||||
|
|
||||||
|
service_name = "cinder-volume"
|
||||||
|
|
||||||
|
mandatory_relations = {
|
||||||
|
"storage-backend",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, framework):
|
||||||
|
super().__init__(framework)
|
||||||
|
self._state.set_default(api_ready=False, backends=[])
|
||||||
|
self._backend_status = compound_status.Status("backends", priority=10)
|
||||||
|
self.status_pool.add(self._backend_status)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def snap_name(self) -> str:
|
||||||
|
"""Return snap name."""
|
||||||
|
return str(self.model.config["snap-name"])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def snap_channel(self) -> str:
|
||||||
|
"""Return snap channel."""
|
||||||
|
return str(self.model.config["snap-channel"])
|
||||||
|
|
||||||
|
def get_relation_handlers(
|
||||||
|
self, handlers: list[relation_handlers.RelationHandler] | None = None
|
||||||
|
) -> list[relation_handlers.RelationHandler]:
|
||||||
|
"""Relation handlers for the service."""
|
||||||
|
handlers = super().get_relation_handlers()
|
||||||
|
self.sb_svc = StorageBackendProvidesHandler(
|
||||||
|
self,
|
||||||
|
"storage-backend",
|
||||||
|
self.api_ready,
|
||||||
|
"storage-backend" in self.mandatory_relations,
|
||||||
|
)
|
||||||
|
handlers.append(self.sb_svc)
|
||||||
|
self.cinder_volume = CinderVolumeProviderHandler(
|
||||||
|
self,
|
||||||
|
"cinder-volume",
|
||||||
|
str(self.model.config["snap-name"]),
|
||||||
|
self.backend_changes,
|
||||||
|
"cinder-volume" in self.mandatory_relations,
|
||||||
|
)
|
||||||
|
handlers.append(self.cinder_volume)
|
||||||
|
return handlers
|
||||||
|
|
||||||
|
def api_ready(self, event) -> None:
|
||||||
|
"""Event handler for bootstrap of service when api services are ready."""
|
||||||
|
self._state.api_ready = True
|
||||||
|
self.configure_charm(event)
|
||||||
|
|
||||||
|
def _find_duplicates(self, backends: typing.Sequence[str]) -> set[str]:
|
||||||
|
"""Find duplicates in a list of backends."""
|
||||||
|
seen = set()
|
||||||
|
duplicates = set()
|
||||||
|
for backend in backends:
|
||||||
|
if backend in seen:
|
||||||
|
duplicates.add(backend)
|
||||||
|
seen.add(backend)
|
||||||
|
return duplicates
|
||||||
|
|
||||||
|
def backend_changes(self, event: ops.RelationEvent) -> None:
|
||||||
|
"""Event handler for backend changes."""
|
||||||
|
relation_backends = self.cinder_volume.backends()
|
||||||
|
|
||||||
|
if duplicates := self._find_duplicates(relation_backends):
|
||||||
|
logger.warning(
|
||||||
|
"Same instance of `cinder-volume` cannot"
|
||||||
|
" serve the same backend multiple times."
|
||||||
|
)
|
||||||
|
raise sunbeam_guard.BlockedExceptionError(
|
||||||
|
f"Duplicate backends: {duplicates}"
|
||||||
|
)
|
||||||
|
|
||||||
|
state_backends: set[str] = set(self._state.backends) # type: ignore
|
||||||
|
|
||||||
|
if leftovers := state_backends.difference(relation_backends):
|
||||||
|
logger.debug(
|
||||||
|
"Removing backends %s from state",
|
||||||
|
leftovers,
|
||||||
|
)
|
||||||
|
for backend in leftovers:
|
||||||
|
self.remove_backend(backend)
|
||||||
|
state_backends.remove(backend)
|
||||||
|
self._state.backends = sorted(state_backends.union(relation_backends))
|
||||||
|
self.configure_charm(event)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def databases(self) -> Mapping[str, str]:
|
||||||
|
"""Provide database name for cinder services."""
|
||||||
|
return {"database": "cinder"}
|
||||||
|
|
||||||
|
def configure_snap(self, event) -> None:
|
||||||
|
"""Run configuration on snap."""
|
||||||
|
config = self.model.config.get
|
||||||
|
try:
|
||||||
|
contexts = self.contexts()
|
||||||
|
snap_data = {
|
||||||
|
"rabbitmq.url": contexts.amqp.transport_url,
|
||||||
|
"database.url": contexts.database.connection,
|
||||||
|
"cinder.project-id": contexts.identity_credentials.project_id,
|
||||||
|
"cinder.user-id": contexts.identity_credentials.username,
|
||||||
|
"cinder.cluster": self.app.name,
|
||||||
|
"cinder.image-volume-cache-enabled": config(
|
||||||
|
"image-volume-cache-enabled"
|
||||||
|
),
|
||||||
|
"cinder.image-volume-cache-max-size-gb": config(
|
||||||
|
"image-volume-cache-max-size-gb"
|
||||||
|
),
|
||||||
|
"cinder.image-volume-cache-max-count": config(
|
||||||
|
"image-volume-cache-max-count"
|
||||||
|
),
|
||||||
|
"cinder.default-volume-type": config("default-volume-type"),
|
||||||
|
"settings.debug": self.model.config["debug"],
|
||||||
|
"settings.enable-telemetry-notifications": self.model.config[
|
||||||
|
"enable-telemetry-notifications"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
except AttributeError as e:
|
||||||
|
raise sunbeam_guard.WaitingExceptionError(
|
||||||
|
"Data missing: {}".format(e.name)
|
||||||
|
)
|
||||||
|
self.set_snap_data(snap_data)
|
||||||
|
self.check_serving_backends()
|
||||||
|
|
||||||
|
def check_serving_backends(self):
|
||||||
|
"""Check if backends are ready to serve."""
|
||||||
|
if not self.cinder_volume.backends():
|
||||||
|
msg = "Waiting for backends"
|
||||||
|
self._backend_status.set(ops.WaitingStatus(msg))
|
||||||
|
raise sunbeam_guard.WaitingExceptionError(msg)
|
||||||
|
self._backend_status.set(ops.ActiveStatus())
|
||||||
|
|
||||||
|
def remove_backend(self, backend: str):
|
||||||
|
"""Remove backend from snap."""
|
||||||
|
cinder_volume = self.get_snap()
|
||||||
|
try:
|
||||||
|
cinder_volume.unset(backend)
|
||||||
|
except snap.SnapError as e:
|
||||||
|
logger.debug(
|
||||||
|
"Failed to remove backend %s from snap: %s",
|
||||||
|
backend,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: nocover
|
||||||
|
ops.main(CinderVolumeOperatorCharm)
|
16
charms/cinder-volume/tests/unit/__init__.py
Normal file
16
charms/cinder-volume/tests/unit/__init__.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2025 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.
|
||||||
|
|
||||||
|
"""Unit testing module for Cinder Volume operator."""
|
202
charms/cinder-volume/tests/unit/test_cinder_volume_charm.py
Normal file
202
charms/cinder-volume/tests/unit/test_cinder_volume_charm.py
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
# Copyright 2025 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.
|
||||||
|
|
||||||
|
"""Tests for Openstack hypervisor charm."""
|
||||||
|
|
||||||
|
from unittest.mock import (
|
||||||
|
MagicMock,
|
||||||
|
Mock,
|
||||||
|
patch,
|
||||||
|
)
|
||||||
|
|
||||||
|
import charm
|
||||||
|
import ops_sunbeam.test_utils as test_utils
|
||||||
|
|
||||||
|
|
||||||
|
class _CinderVolumeOperatorCharm(charm.CinderVolumeOperatorCharm):
|
||||||
|
"""Neutron test charm."""
|
||||||
|
|
||||||
|
def __init__(self, framework):
|
||||||
|
"""Setup event logging."""
|
||||||
|
self.seen_events = []
|
||||||
|
super().__init__(framework)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCharm(test_utils.CharmTestCase):
|
||||||
|
"""Test charm to test relations."""
|
||||||
|
|
||||||
|
PATCHES = []
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Setup OpenStack Hypervisor tests."""
|
||||||
|
super().setUp(charm, self.PATCHES)
|
||||||
|
self.snap = Mock()
|
||||||
|
snap_patch = patch.object(
|
||||||
|
_CinderVolumeOperatorCharm,
|
||||||
|
"_import_snap",
|
||||||
|
Mock(return_value=self.snap),
|
||||||
|
)
|
||||||
|
snap_patch.start()
|
||||||
|
self.harness = test_utils.get_harness(
|
||||||
|
_CinderVolumeOperatorCharm,
|
||||||
|
container_calls=self.container_calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
# clean up events that were dynamically defined,
|
||||||
|
# otherwise we get issues because they'll be redefined,
|
||||||
|
# which is not allowed.
|
||||||
|
from charms.data_platform_libs.v0.data_interfaces import (
|
||||||
|
DatabaseRequiresEvents,
|
||||||
|
)
|
||||||
|
|
||||||
|
for attr in (
|
||||||
|
"database_database_created",
|
||||||
|
"database_endpoints_changed",
|
||||||
|
"database_read_only_endpoints_changed",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
delattr(DatabaseRequiresEvents, attr)
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
self.addCleanup(snap_patch.stop)
|
||||||
|
self.addCleanup(self.harness.cleanup)
|
||||||
|
|
||||||
|
def initial_setup(self):
|
||||||
|
"""Setting up relations."""
|
||||||
|
self.harness.update_config({"snap-channel": "essex/stable"})
|
||||||
|
self.harness.begin_with_initial_hooks()
|
||||||
|
|
||||||
|
def all_required_relations_setup(self):
|
||||||
|
"""Setting up all the required relations."""
|
||||||
|
self.initial_setup()
|
||||||
|
test_utils.add_complete_amqp_relation(self.harness)
|
||||||
|
test_utils.add_complete_identity_credentials_relation(self.harness)
|
||||||
|
test_utils.add_complete_db_relation(self.harness)
|
||||||
|
self.harness.add_relation(
|
||||||
|
"storage-backend",
|
||||||
|
"cinder",
|
||||||
|
app_data={
|
||||||
|
"ready": "true",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_mandatory_relations(self):
|
||||||
|
"""Test all the charms relations."""
|
||||||
|
cinder_volume_snap_mock = MagicMock()
|
||||||
|
cinder_volume_snap_mock.present = False
|
||||||
|
self.snap.SnapState.Latest = "latest"
|
||||||
|
self.snap.SnapCache.return_value = {
|
||||||
|
"cinder-volume": cinder_volume_snap_mock
|
||||||
|
}
|
||||||
|
self.initial_setup()
|
||||||
|
self.harness.set_leader()
|
||||||
|
|
||||||
|
test_utils.add_complete_amqp_relation(self.harness)
|
||||||
|
test_utils.add_complete_identity_credentials_relation(self.harness)
|
||||||
|
test_utils.add_complete_db_relation(self.harness)
|
||||||
|
# Add nova-service relation
|
||||||
|
self.harness.add_relation(
|
||||||
|
"storage-backend",
|
||||||
|
"cinder",
|
||||||
|
app_data={
|
||||||
|
"ready": "true",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
cinder_volume_snap_mock.ensure.assert_any_call(
|
||||||
|
"latest", channel="essex/stable"
|
||||||
|
)
|
||||||
|
expect_settings = {
|
||||||
|
"rabbitmq.url": "rabbit://cinder-volume:rabbit.pass@rabbithost1.local:5672/openstack",
|
||||||
|
"database.url": "mysql+pymysql://foo:hardpassword@10.0.0.10/cinder",
|
||||||
|
"cinder.project-id": "uproj-id",
|
||||||
|
"cinder.user-id": "username",
|
||||||
|
"cinder.image-volume-cache-enabled": False,
|
||||||
|
"cinder.image-volume-cache-max-size-gb": 0,
|
||||||
|
"cinder.image-volume-cache-max-count": 0,
|
||||||
|
"cinder.default-volume-type": None,
|
||||||
|
"cinder.cluster": "cinder-volume",
|
||||||
|
"settings.debug": False,
|
||||||
|
"settings.enable-telemetry-notifications": False,
|
||||||
|
}
|
||||||
|
cinder_volume_snap_mock.set.assert_any_call(
|
||||||
|
expect_settings, typed=True
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.harness.charm.status.message(), "Waiting for backends"
|
||||||
|
)
|
||||||
|
self.assertEqual(self.harness.charm.status.status.name, "waiting")
|
||||||
|
|
||||||
|
def test_all_relations(self):
|
||||||
|
"""Test all the charms relations."""
|
||||||
|
cinder_volume_snap_mock = MagicMock()
|
||||||
|
cinder_volume_snap_mock.present = False
|
||||||
|
self.snap.SnapState.Latest = "latest"
|
||||||
|
self.snap.SnapCache.return_value = {
|
||||||
|
"cinder-volume": cinder_volume_snap_mock
|
||||||
|
}
|
||||||
|
self.all_required_relations_setup()
|
||||||
|
|
||||||
|
self.assertEqual(self.harness.charm._state.backends, [])
|
||||||
|
self.harness.add_relation(
|
||||||
|
"cinder-volume",
|
||||||
|
"cinder-volume-ceph",
|
||||||
|
unit_data={"backend": "ceph.monostack"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(self.harness.charm.status.message(), "")
|
||||||
|
self.assertEqual(self.harness.charm.status.status.name, "active")
|
||||||
|
self.assertEqual(
|
||||||
|
self.harness.charm._state.backends, ["ceph.monostack"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_backend_leaving(self):
|
||||||
|
"""Ensure correct behavior when a backend leaves."""
|
||||||
|
cinder_volume_snap_mock = MagicMock()
|
||||||
|
cinder_volume_snap_mock.present = False
|
||||||
|
self.snap.SnapState.Latest = "latest"
|
||||||
|
self.snap.SnapCache.return_value = {
|
||||||
|
"cinder-volume": cinder_volume_snap_mock
|
||||||
|
}
|
||||||
|
self.all_required_relations_setup()
|
||||||
|
|
||||||
|
slow_id = self.harness.add_relation(
|
||||||
|
"cinder-volume",
|
||||||
|
"cinder-volume-ceph-slow",
|
||||||
|
unit_data={"backend": "ceph.slow"},
|
||||||
|
)
|
||||||
|
fast_id = self.harness.add_relation(
|
||||||
|
"cinder-volume",
|
||||||
|
"cinder-volume-ceph-fast",
|
||||||
|
unit_data={"backend": "ceph.fast"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(self.harness.charm.status.message(), "")
|
||||||
|
self.assertEqual(self.harness.charm.status.status.name, "active")
|
||||||
|
self.assertEqual(
|
||||||
|
self.harness.charm._state.backends,
|
||||||
|
sorted(["ceph.slow", "ceph.fast"]),
|
||||||
|
)
|
||||||
|
self.harness.remove_relation(fast_id)
|
||||||
|
self.assertEqual(self.harness.charm._state.backends, ["ceph.slow"])
|
||||||
|
cinder_volume_snap_mock.unset.assert_any_call("ceph.fast")
|
||||||
|
self.assertEqual(self.harness.charm.status.message(), "")
|
||||||
|
self.assertEqual(self.harness.charm.status.status.name, "active")
|
||||||
|
|
||||||
|
self.harness.remove_relation(slow_id)
|
||||||
|
self.assertEqual(self.harness.charm._state.backends, [])
|
||||||
|
cinder_volume_snap_mock.unset.assert_any_call("ceph.slow")
|
||||||
|
self.assertEqual(
|
||||||
|
self.harness.charm.status.message(), "Waiting for backends"
|
||||||
|
)
|
||||||
|
self.assertEqual(self.harness.charm.status.status.name, "waiting")
|
@ -331,10 +331,14 @@ LIBAPI = 0
|
|||||||
|
|
||||||
# Increment this PATCH version before using `charmcraft publish-lib` or reset
|
# Increment this PATCH version before using `charmcraft publish-lib` or reset
|
||||||
# to 0 if you are raising the major API version
|
# to 0 if you are raising the major API version
|
||||||
LIBPATCH = 37
|
LIBPATCH = 41
|
||||||
|
|
||||||
PYDEPS = ["ops>=2.0.0"]
|
PYDEPS = ["ops>=2.0.0"]
|
||||||
|
|
||||||
|
# Starting from what LIBPATCH number to apply legacy solutions
|
||||||
|
# v0.17 was the last version without secrets
|
||||||
|
LEGACY_SUPPORT_FROM = 17
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
Diff = namedtuple("Diff", "added changed deleted")
|
Diff = namedtuple("Diff", "added changed deleted")
|
||||||
@ -351,36 +355,16 @@ REQ_SECRET_FIELDS = "requested-secrets"
|
|||||||
GROUP_MAPPING_FIELD = "secret_group_mapping"
|
GROUP_MAPPING_FIELD = "secret_group_mapping"
|
||||||
GROUP_SEPARATOR = "@"
|
GROUP_SEPARATOR = "@"
|
||||||
|
|
||||||
|
MODEL_ERRORS = {
|
||||||
class SecretGroup(str):
|
"not_leader": "this unit is not the leader",
|
||||||
"""Secret groups specific type."""
|
"no_label_and_uri": "ERROR either URI or label should be used for getting an owned secret but not both",
|
||||||
|
"owner_no_refresh": "ERROR secret owner cannot use --refresh",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class SecretGroupsAggregate(str):
|
##############################################################################
|
||||||
"""Secret groups with option to extend with additional constants."""
|
# Exceptions
|
||||||
|
##############################################################################
|
||||||
def __init__(self):
|
|
||||||
self.USER = SecretGroup("user")
|
|
||||||
self.TLS = SecretGroup("tls")
|
|
||||||
self.EXTRA = SecretGroup("extra")
|
|
||||||
|
|
||||||
def __setattr__(self, name, value):
|
|
||||||
"""Setting internal constants."""
|
|
||||||
if name in self.__dict__:
|
|
||||||
raise RuntimeError("Can't set constant!")
|
|
||||||
else:
|
|
||||||
super().__setattr__(name, SecretGroup(value))
|
|
||||||
|
|
||||||
def groups(self) -> list:
|
|
||||||
"""Return the list of stored SecretGroups."""
|
|
||||||
return list(self.__dict__.values())
|
|
||||||
|
|
||||||
def get_group(self, group: str) -> Optional[SecretGroup]:
|
|
||||||
"""If the input str translates to a group name, return that."""
|
|
||||||
return SecretGroup(group) if group in self.groups() else None
|
|
||||||
|
|
||||||
|
|
||||||
SECRET_GROUPS = SecretGroupsAggregate()
|
|
||||||
|
|
||||||
|
|
||||||
class DataInterfacesError(Exception):
|
class DataInterfacesError(Exception):
|
||||||
@ -407,6 +391,19 @@ class IllegalOperationError(DataInterfacesError):
|
|||||||
"""To be used when an operation is not allowed to be performed."""
|
"""To be used when an operation is not allowed to be performed."""
|
||||||
|
|
||||||
|
|
||||||
|
class PrematureDataAccessError(DataInterfacesError):
|
||||||
|
"""To be raised when the Relation Data may be accessed (written) before protocol init complete."""
|
||||||
|
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
# Global helpers / utilities
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
# Databag handling and comparison methods
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
|
||||||
def get_encoded_dict(
|
def get_encoded_dict(
|
||||||
relation: Relation, member: Union[Unit, Application], field: str
|
relation: Relation, member: Union[Unit, Application], field: str
|
||||||
) -> Optional[Dict[str, str]]:
|
) -> Optional[Dict[str, str]]:
|
||||||
@ -482,6 +479,11 @@ def diff(event: RelationChangedEvent, bucket: Optional[Union[Unit, Application]]
|
|||||||
return Diff(added, changed, deleted)
|
return Diff(added, changed, deleted)
|
||||||
|
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
# Module decorators
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
|
||||||
def leader_only(f):
|
def leader_only(f):
|
||||||
"""Decorator to ensure that only leader can perform given operation."""
|
"""Decorator to ensure that only leader can perform given operation."""
|
||||||
|
|
||||||
@ -536,6 +538,36 @@ def either_static_or_dynamic_secrets(f):
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_apply_from_version(version: int) -> Callable:
|
||||||
|
"""Decorator to decide whether to apply a legacy function or not.
|
||||||
|
|
||||||
|
Based on LEGACY_SUPPORT_FROM module variable value, the importer charm may only want
|
||||||
|
to apply legacy solutions starting from a specific LIBPATCH.
|
||||||
|
|
||||||
|
NOTE: All 'legacy' functions have to be defined and called in a way that they return `None`.
|
||||||
|
This results in cleaner and more secure execution flows in case the function may be disabled.
|
||||||
|
This requirement implicitly means that legacy functions change the internal state strictly,
|
||||||
|
don't return information.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(f: Callable[..., None]):
|
||||||
|
"""Signature is ensuring None return value."""
|
||||||
|
f.legacy_version = version
|
||||||
|
|
||||||
|
def wrapper(self, *args, **kwargs) -> None:
|
||||||
|
if version >= LEGACY_SUPPORT_FROM:
|
||||||
|
return f(self, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
# Helper classes
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
|
||||||
class Scope(Enum):
|
class Scope(Enum):
|
||||||
"""Peer relations scope."""
|
"""Peer relations scope."""
|
||||||
|
|
||||||
@ -543,17 +575,45 @@ class Scope(Enum):
|
|||||||
UNIT = "unit"
|
UNIT = "unit"
|
||||||
|
|
||||||
|
|
||||||
################################################################################
|
class SecretGroup(str):
|
||||||
# Secrets internal caching
|
"""Secret groups specific type."""
|
||||||
################################################################################
|
|
||||||
|
|
||||||
|
class SecretGroupsAggregate(str):
|
||||||
|
"""Secret groups with option to extend with additional constants."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.USER = SecretGroup("user")
|
||||||
|
self.TLS = SecretGroup("tls")
|
||||||
|
self.EXTRA = SecretGroup("extra")
|
||||||
|
|
||||||
|
def __setattr__(self, name, value):
|
||||||
|
"""Setting internal constants."""
|
||||||
|
if name in self.__dict__:
|
||||||
|
raise RuntimeError("Can't set constant!")
|
||||||
|
else:
|
||||||
|
super().__setattr__(name, SecretGroup(value))
|
||||||
|
|
||||||
|
def groups(self) -> list:
|
||||||
|
"""Return the list of stored SecretGroups."""
|
||||||
|
return list(self.__dict__.values())
|
||||||
|
|
||||||
|
def get_group(self, group: str) -> Optional[SecretGroup]:
|
||||||
|
"""If the input str translates to a group name, return that."""
|
||||||
|
return SecretGroup(group) if group in self.groups() else None
|
||||||
|
|
||||||
|
|
||||||
|
SECRET_GROUPS = SecretGroupsAggregate()
|
||||||
|
|
||||||
|
|
||||||
class CachedSecret:
|
class CachedSecret:
|
||||||
"""Locally cache a secret.
|
"""Locally cache a secret.
|
||||||
|
|
||||||
The data structure is precisely re-using/simulating as in the actual Secret Storage
|
The data structure is precisely reusing/simulating as in the actual Secret Storage
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
KNOWN_MODEL_ERRORS = [MODEL_ERRORS["no_label_and_uri"], MODEL_ERRORS["owner_no_refresh"]]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
model: Model,
|
model: Model,
|
||||||
@ -571,6 +631,95 @@ class CachedSecret:
|
|||||||
self.legacy_labels = legacy_labels
|
self.legacy_labels = legacy_labels
|
||||||
self.current_label = None
|
self.current_label = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def meta(self) -> Optional[Secret]:
|
||||||
|
"""Getting cached secret meta-information."""
|
||||||
|
if not self._secret_meta:
|
||||||
|
if not (self._secret_uri or self.label):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._secret_meta = self._model.get_secret(label=self.label)
|
||||||
|
except SecretNotFoundError:
|
||||||
|
# Falling back to seeking for potential legacy labels
|
||||||
|
self._legacy_compat_find_secret_by_old_label()
|
||||||
|
|
||||||
|
# If still not found, to be checked by URI, to be labelled with the proposed label
|
||||||
|
if not self._secret_meta and self._secret_uri:
|
||||||
|
self._secret_meta = self._model.get_secret(id=self._secret_uri, label=self.label)
|
||||||
|
return self._secret_meta
|
||||||
|
|
||||||
|
##########################################################################
|
||||||
|
# Backwards compatibility / Upgrades
|
||||||
|
##########################################################################
|
||||||
|
# These functions are used to keep backwards compatibility on rolling upgrades
|
||||||
|
# Policy:
|
||||||
|
# All data is kept intact until the first write operation. (This allows a minimal
|
||||||
|
# grace period during which rollbacks are fully safe. For more info see the spec.)
|
||||||
|
# All data involves:
|
||||||
|
# - databag contents
|
||||||
|
# - secrets content
|
||||||
|
# - secret labels (!!!)
|
||||||
|
# Legacy functions must return None, and leave an equally consistent state whether
|
||||||
|
# they are executed or skipped (as a high enough versioned execution environment may
|
||||||
|
# not require so)
|
||||||
|
|
||||||
|
# Compatibility
|
||||||
|
|
||||||
|
@legacy_apply_from_version(34)
|
||||||
|
def _legacy_compat_find_secret_by_old_label(self) -> None:
|
||||||
|
"""Compatibility function, allowing to find a secret by a legacy label.
|
||||||
|
|
||||||
|
This functionality is typically needed when secret labels changed over an upgrade.
|
||||||
|
Until the first write operation, we need to maintain data as it was, including keeping
|
||||||
|
the old secret label. In order to keep track of the old label currently used to access
|
||||||
|
the secret, and additional 'current_label' field is being defined.
|
||||||
|
"""
|
||||||
|
for label in self.legacy_labels:
|
||||||
|
try:
|
||||||
|
self._secret_meta = self._model.get_secret(label=label)
|
||||||
|
except SecretNotFoundError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
if label != self.label:
|
||||||
|
self.current_label = label
|
||||||
|
return
|
||||||
|
|
||||||
|
# Migrations
|
||||||
|
|
||||||
|
@legacy_apply_from_version(34)
|
||||||
|
def _legacy_migration_to_new_label_if_needed(self) -> None:
|
||||||
|
"""Helper function to re-create the secret with a different label.
|
||||||
|
|
||||||
|
Juju does not provide a way to change secret labels.
|
||||||
|
Thus whenever moving from secrets version that involves secret label changes,
|
||||||
|
we "re-create" the existing secret, and attach the new label to the new
|
||||||
|
secret, to be used from then on.
|
||||||
|
|
||||||
|
Note: we replace the old secret with a new one "in place", as we can't
|
||||||
|
easily switch the containing SecretCache structure to point to a new secret.
|
||||||
|
Instead we are changing the 'self' (CachedSecret) object to point to the
|
||||||
|
new instance.
|
||||||
|
"""
|
||||||
|
if not self.current_label or not (self.meta and self._secret_meta):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create a new secret with the new label
|
||||||
|
content = self._secret_meta.get_content()
|
||||||
|
self._secret_uri = None
|
||||||
|
|
||||||
|
# It will be nice to have the possibility to check if we are the owners of the secret...
|
||||||
|
try:
|
||||||
|
self._secret_meta = self.add_secret(content, label=self.label)
|
||||||
|
except ModelError as err:
|
||||||
|
if MODEL_ERRORS["not_leader"] not in str(err):
|
||||||
|
raise
|
||||||
|
self.current_label = None
|
||||||
|
|
||||||
|
##########################################################################
|
||||||
|
# Public functions
|
||||||
|
##########################################################################
|
||||||
|
|
||||||
def add_secret(
|
def add_secret(
|
||||||
self,
|
self,
|
||||||
content: Dict[str, str],
|
content: Dict[str, str],
|
||||||
@ -593,28 +742,6 @@ class CachedSecret:
|
|||||||
self._secret_meta = secret
|
self._secret_meta = secret
|
||||||
return self._secret_meta
|
return self._secret_meta
|
||||||
|
|
||||||
@property
|
|
||||||
def meta(self) -> Optional[Secret]:
|
|
||||||
"""Getting cached secret meta-information."""
|
|
||||||
if not self._secret_meta:
|
|
||||||
if not (self._secret_uri or self.label):
|
|
||||||
return
|
|
||||||
|
|
||||||
for label in [self.label] + self.legacy_labels:
|
|
||||||
try:
|
|
||||||
self._secret_meta = self._model.get_secret(label=label)
|
|
||||||
except SecretNotFoundError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
if label != self.label:
|
|
||||||
self.current_label = label
|
|
||||||
break
|
|
||||||
|
|
||||||
# If still not found, to be checked by URI, to be labelled with the proposed label
|
|
||||||
if not self._secret_meta and self._secret_uri:
|
|
||||||
self._secret_meta = self._model.get_secret(id=self._secret_uri, label=self.label)
|
|
||||||
return self._secret_meta
|
|
||||||
|
|
||||||
def get_content(self) -> Dict[str, str]:
|
def get_content(self) -> Dict[str, str]:
|
||||||
"""Getting cached secret content."""
|
"""Getting cached secret content."""
|
||||||
if not self._secret_content:
|
if not self._secret_content:
|
||||||
@ -624,35 +751,14 @@ class CachedSecret:
|
|||||||
except (ValueError, ModelError) as err:
|
except (ValueError, ModelError) as err:
|
||||||
# https://bugs.launchpad.net/juju/+bug/2042596
|
# https://bugs.launchpad.net/juju/+bug/2042596
|
||||||
# Only triggered when 'refresh' is set
|
# Only triggered when 'refresh' is set
|
||||||
known_model_errors = [
|
|
||||||
"ERROR either URI or label should be used for getting an owned secret but not both",
|
|
||||||
"ERROR secret owner cannot use --refresh",
|
|
||||||
]
|
|
||||||
if isinstance(err, ModelError) and not any(
|
if isinstance(err, ModelError) and not any(
|
||||||
msg in str(err) for msg in known_model_errors
|
msg in str(err) for msg in self.KNOWN_MODEL_ERRORS
|
||||||
):
|
):
|
||||||
raise
|
raise
|
||||||
# Due to: ValueError: Secret owner cannot use refresh=True
|
# Due to: ValueError: Secret owner cannot use refresh=True
|
||||||
self._secret_content = self.meta.get_content()
|
self._secret_content = self.meta.get_content()
|
||||||
return self._secret_content
|
return self._secret_content
|
||||||
|
|
||||||
def _move_to_new_label_if_needed(self):
|
|
||||||
"""Helper function to re-create the secret with a different label."""
|
|
||||||
if not self.current_label or not (self.meta and self._secret_meta):
|
|
||||||
return
|
|
||||||
|
|
||||||
# Create a new secret with the new label
|
|
||||||
content = self._secret_meta.get_content()
|
|
||||||
self._secret_uri = None
|
|
||||||
|
|
||||||
# I wish we could just check if we are the owners of the secret...
|
|
||||||
try:
|
|
||||||
self._secret_meta = self.add_secret(content, label=self.label)
|
|
||||||
except ModelError as err:
|
|
||||||
if "this unit is not the leader" not in str(err):
|
|
||||||
raise
|
|
||||||
self.current_label = None
|
|
||||||
|
|
||||||
def set_content(self, content: Dict[str, str]) -> None:
|
def set_content(self, content: Dict[str, str]) -> None:
|
||||||
"""Setting cached secret content."""
|
"""Setting cached secret content."""
|
||||||
if not self.meta:
|
if not self.meta:
|
||||||
@ -663,7 +769,7 @@ class CachedSecret:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if content:
|
if content:
|
||||||
self._move_to_new_label_if_needed()
|
self._legacy_migration_to_new_label_if_needed()
|
||||||
self.meta.set_content(content)
|
self.meta.set_content(content)
|
||||||
self._secret_content = content
|
self._secret_content = content
|
||||||
else:
|
else:
|
||||||
@ -926,6 +1032,23 @@ class Data(ABC):
|
|||||||
"""Delete data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app."""
|
"""Delete data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
# Optional overrides
|
||||||
|
|
||||||
|
def _legacy_apply_on_fetch(self) -> None:
|
||||||
|
"""This function should provide a list of compatibility functions to be applied when fetching (legacy) data."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _legacy_apply_on_update(self, fields: List[str]) -> None:
|
||||||
|
"""This function should provide a list of compatibility functions to be applied when writing data.
|
||||||
|
|
||||||
|
Since data may be at a legacy version, migration may be mandatory.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _legacy_apply_on_delete(self, fields: List[str]) -> None:
|
||||||
|
"""This function should provide a list of compatibility functions to be applied when deleting (legacy) data."""
|
||||||
|
pass
|
||||||
|
|
||||||
# Internal helper methods
|
# Internal helper methods
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -1178,6 +1301,16 @@ class Data(ABC):
|
|||||||
|
|
||||||
return relation
|
return relation
|
||||||
|
|
||||||
|
def get_secret_uri(self, relation: Relation, group: SecretGroup) -> Optional[str]:
|
||||||
|
"""Get the secret URI for the corresponding group."""
|
||||||
|
secret_field = self._generate_secret_field_name(group)
|
||||||
|
return relation.data[self.component].get(secret_field)
|
||||||
|
|
||||||
|
def set_secret_uri(self, relation: Relation, group: SecretGroup, secret_uri: str) -> None:
|
||||||
|
"""Set the secret URI for the corresponding group."""
|
||||||
|
secret_field = self._generate_secret_field_name(group)
|
||||||
|
relation.data[self.component][secret_field] = secret_uri
|
||||||
|
|
||||||
def fetch_relation_data(
|
def fetch_relation_data(
|
||||||
self,
|
self,
|
||||||
relation_ids: Optional[List[int]] = None,
|
relation_ids: Optional[List[int]] = None,
|
||||||
@ -1194,6 +1327,8 @@ class Data(ABC):
|
|||||||
a dict of the values stored in the relation data bag
|
a dict of the values stored in the relation data bag
|
||||||
for all relation instances (indexed by the relation ID).
|
for all relation instances (indexed by the relation ID).
|
||||||
"""
|
"""
|
||||||
|
self._legacy_apply_on_fetch()
|
||||||
|
|
||||||
if not relation_name:
|
if not relation_name:
|
||||||
relation_name = self.relation_name
|
relation_name = self.relation_name
|
||||||
|
|
||||||
@ -1232,6 +1367,8 @@ class Data(ABC):
|
|||||||
NOTE: Since only the leader can read the relation's 'this_app'-side
|
NOTE: Since only the leader can read the relation's 'this_app'-side
|
||||||
Application databag, the functionality is limited to leaders
|
Application databag, the functionality is limited to leaders
|
||||||
"""
|
"""
|
||||||
|
self._legacy_apply_on_fetch()
|
||||||
|
|
||||||
if not relation_name:
|
if not relation_name:
|
||||||
relation_name = self.relation_name
|
relation_name = self.relation_name
|
||||||
|
|
||||||
@ -1263,6 +1400,8 @@ class Data(ABC):
|
|||||||
@leader_only
|
@leader_only
|
||||||
def update_relation_data(self, relation_id: int, data: dict) -> None:
|
def update_relation_data(self, relation_id: int, data: dict) -> None:
|
||||||
"""Update the data within the relation."""
|
"""Update the data within the relation."""
|
||||||
|
self._legacy_apply_on_update(list(data.keys()))
|
||||||
|
|
||||||
relation_name = self.relation_name
|
relation_name = self.relation_name
|
||||||
relation = self.get_relation(relation_name, relation_id)
|
relation = self.get_relation(relation_name, relation_id)
|
||||||
return self._update_relation_data(relation, data)
|
return self._update_relation_data(relation, data)
|
||||||
@ -1270,6 +1409,8 @@ class Data(ABC):
|
|||||||
@leader_only
|
@leader_only
|
||||||
def delete_relation_data(self, relation_id: int, fields: List[str]) -> None:
|
def delete_relation_data(self, relation_id: int, fields: List[str]) -> None:
|
||||||
"""Remove field from the relation."""
|
"""Remove field from the relation."""
|
||||||
|
self._legacy_apply_on_delete(fields)
|
||||||
|
|
||||||
relation_name = self.relation_name
|
relation_name = self.relation_name
|
||||||
relation = self.get_relation(relation_name, relation_id)
|
relation = self.get_relation(relation_name, relation_id)
|
||||||
return self._delete_relation_data(relation, fields)
|
return self._delete_relation_data(relation, fields)
|
||||||
@ -1316,6 +1457,8 @@ class EventHandlers(Object):
|
|||||||
class ProviderData(Data):
|
class ProviderData(Data):
|
||||||
"""Base provides-side of the data products relation."""
|
"""Base provides-side of the data products relation."""
|
||||||
|
|
||||||
|
RESOURCE_FIELD = "database"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
model: Model,
|
model: Model,
|
||||||
@ -1336,8 +1479,7 @@ class ProviderData(Data):
|
|||||||
uri_to_databag=True,
|
uri_to_databag=True,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Add a new Juju Secret that will be registered in the relation databag."""
|
"""Add a new Juju Secret that will be registered in the relation databag."""
|
||||||
secret_field = self._generate_secret_field_name(group_mapping)
|
if uri_to_databag and self.get_secret_uri(relation, group_mapping):
|
||||||
if uri_to_databag and relation.data[self.component].get(secret_field):
|
|
||||||
logging.error("Secret for relation %s already exists, not adding again", relation.id)
|
logging.error("Secret for relation %s already exists, not adding again", relation.id)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -1348,7 +1490,7 @@ class ProviderData(Data):
|
|||||||
|
|
||||||
# According to lint we may not have a Secret ID
|
# According to lint we may not have a Secret ID
|
||||||
if uri_to_databag and secret.meta and secret.meta.id:
|
if uri_to_databag and secret.meta and secret.meta.id:
|
||||||
relation.data[self.component][secret_field] = secret.meta.id
|
self.set_secret_uri(relation, group_mapping, secret.meta.id)
|
||||||
|
|
||||||
# Return the content that was added
|
# Return the content that was added
|
||||||
return True
|
return True
|
||||||
@ -1449,8 +1591,7 @@ class ProviderData(Data):
|
|||||||
if not relation:
|
if not relation:
|
||||||
return
|
return
|
||||||
|
|
||||||
secret_field = self._generate_secret_field_name(group_mapping)
|
if secret_uri := self.get_secret_uri(relation, group_mapping):
|
||||||
if secret_uri := relation.data[self.local_app].get(secret_field):
|
|
||||||
return self.secrets.get(label, secret_uri)
|
return self.secrets.get(label, secret_uri)
|
||||||
|
|
||||||
def _fetch_specific_relation_data(
|
def _fetch_specific_relation_data(
|
||||||
@ -1483,6 +1624,15 @@ class ProviderData(Data):
|
|||||||
def _update_relation_data(self, relation: Relation, data: Dict[str, str]) -> None:
|
def _update_relation_data(self, relation: Relation, data: Dict[str, str]) -> None:
|
||||||
"""Set values for fields not caring whether it's a secret or not."""
|
"""Set values for fields not caring whether it's a secret or not."""
|
||||||
req_secret_fields = []
|
req_secret_fields = []
|
||||||
|
|
||||||
|
keys = set(data.keys())
|
||||||
|
if self.fetch_relation_field(relation.id, self.RESOURCE_FIELD) is None and (
|
||||||
|
keys - {"endpoints", "read-only-endpoints", "replset"}
|
||||||
|
):
|
||||||
|
raise PrematureDataAccessError(
|
||||||
|
"Premature access to relation data, update is forbidden before the connection is initialized."
|
||||||
|
)
|
||||||
|
|
||||||
if relation.app:
|
if relation.app:
|
||||||
req_secret_fields = get_encoded_list(relation, relation.app, REQ_SECRET_FIELDS)
|
req_secret_fields = get_encoded_list(relation, relation.app, REQ_SECRET_FIELDS)
|
||||||
|
|
||||||
@ -1603,11 +1753,10 @@ class RequirerData(Data):
|
|||||||
|
|
||||||
for group in SECRET_GROUPS.groups():
|
for group in SECRET_GROUPS.groups():
|
||||||
secret_field = self._generate_secret_field_name(group)
|
secret_field = self._generate_secret_field_name(group)
|
||||||
if secret_field in params_name_list:
|
if secret_field in params_name_list and (
|
||||||
if secret_uri := relation.data[relation.app].get(secret_field):
|
secret_uri := self.get_secret_uri(relation, group)
|
||||||
self._register_secret_to_relation(
|
):
|
||||||
relation.name, relation.id, secret_uri, group
|
self._register_secret_to_relation(relation.name, relation.id, secret_uri, group)
|
||||||
)
|
|
||||||
|
|
||||||
def _is_resource_created_for_relation(self, relation: Relation) -> bool:
|
def _is_resource_created_for_relation(self, relation: Relation) -> bool:
|
||||||
if not relation.app:
|
if not relation.app:
|
||||||
@ -1618,6 +1767,17 @@ class RequirerData(Data):
|
|||||||
)
|
)
|
||||||
return bool(data.get("username")) and bool(data.get("password"))
|
return bool(data.get("username")) and bool(data.get("password"))
|
||||||
|
|
||||||
|
# Public functions
|
||||||
|
|
||||||
|
def get_secret_uri(self, relation: Relation, group: SecretGroup) -> Optional[str]:
|
||||||
|
"""Getting relation secret URI for the corresponding Secret Group."""
|
||||||
|
secret_field = self._generate_secret_field_name(group)
|
||||||
|
return relation.data[relation.app].get(secret_field)
|
||||||
|
|
||||||
|
def set_secret_uri(self, relation: Relation, group: SecretGroup, uri: str) -> None:
|
||||||
|
"""Setting relation secret URI is not possible for a Requirer."""
|
||||||
|
raise NotImplementedError("Requirer can not change the relation secret URI.")
|
||||||
|
|
||||||
def is_resource_created(self, relation_id: Optional[int] = None) -> bool:
|
def is_resource_created(self, relation_id: Optional[int] = None) -> bool:
|
||||||
"""Check if the resource has been created.
|
"""Check if the resource has been created.
|
||||||
|
|
||||||
@ -1768,7 +1928,6 @@ class DataPeerData(RequirerData, ProviderData):
|
|||||||
secret_field_name: Optional[str] = None,
|
secret_field_name: Optional[str] = None,
|
||||||
deleted_label: Optional[str] = None,
|
deleted_label: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""Manager of base client relations."""
|
|
||||||
RequirerData.__init__(
|
RequirerData.__init__(
|
||||||
self,
|
self,
|
||||||
model,
|
model,
|
||||||
@ -1779,6 +1938,11 @@ class DataPeerData(RequirerData, ProviderData):
|
|||||||
self.secret_field_name = secret_field_name if secret_field_name else self.SECRET_FIELD_NAME
|
self.secret_field_name = secret_field_name if secret_field_name else self.SECRET_FIELD_NAME
|
||||||
self.deleted_label = deleted_label
|
self.deleted_label = deleted_label
|
||||||
self._secret_label_map = {}
|
self._secret_label_map = {}
|
||||||
|
|
||||||
|
# Legacy information holders
|
||||||
|
self._legacy_labels = []
|
||||||
|
self._legacy_secret_uri = None
|
||||||
|
|
||||||
# Secrets that are being dynamically added within the scope of this event handler run
|
# Secrets that are being dynamically added within the scope of this event handler run
|
||||||
self._new_secrets = []
|
self._new_secrets = []
|
||||||
self._additional_secret_group_mapping = additional_secret_group_mapping
|
self._additional_secret_group_mapping = additional_secret_group_mapping
|
||||||
@ -1853,10 +2017,12 @@ class DataPeerData(RequirerData, ProviderData):
|
|||||||
value: The string value of the secret
|
value: The string value of the secret
|
||||||
group_mapping: The name of the "secret group", in case the field is to be added to an existing secret
|
group_mapping: The name of the "secret group", in case the field is to be added to an existing secret
|
||||||
"""
|
"""
|
||||||
|
self._legacy_apply_on_update([field])
|
||||||
|
|
||||||
full_field = self._field_to_internal_name(field, group_mapping)
|
full_field = self._field_to_internal_name(field, group_mapping)
|
||||||
if self.secrets_enabled and full_field not in self.current_secret_fields:
|
if self.secrets_enabled and full_field not in self.current_secret_fields:
|
||||||
self._new_secrets.append(full_field)
|
self._new_secrets.append(full_field)
|
||||||
if self._no_group_with_databag(field, full_field):
|
if self.valid_field_pattern(field, full_field):
|
||||||
self.update_relation_data(relation_id, {full_field: value})
|
self.update_relation_data(relation_id, {full_field: value})
|
||||||
|
|
||||||
# Unlike for set_secret(), there's no harm using this operation with static secrets
|
# Unlike for set_secret(), there's no harm using this operation with static secrets
|
||||||
@ -1869,6 +2035,8 @@ class DataPeerData(RequirerData, ProviderData):
|
|||||||
group_mapping: Optional[SecretGroup] = None,
|
group_mapping: Optional[SecretGroup] = None,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""Public interface method to fetch secrets only."""
|
"""Public interface method to fetch secrets only."""
|
||||||
|
self._legacy_apply_on_fetch()
|
||||||
|
|
||||||
full_field = self._field_to_internal_name(field, group_mapping)
|
full_field = self._field_to_internal_name(field, group_mapping)
|
||||||
if (
|
if (
|
||||||
self.secrets_enabled
|
self.secrets_enabled
|
||||||
@ -1876,7 +2044,7 @@ class DataPeerData(RequirerData, ProviderData):
|
|||||||
and field not in self.current_secret_fields
|
and field not in self.current_secret_fields
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
if self._no_group_with_databag(field, full_field):
|
if self.valid_field_pattern(field, full_field):
|
||||||
return self.fetch_my_relation_field(relation_id, full_field)
|
return self.fetch_my_relation_field(relation_id, full_field)
|
||||||
|
|
||||||
@dynamic_secrets_only
|
@dynamic_secrets_only
|
||||||
@ -1887,14 +2055,19 @@ class DataPeerData(RequirerData, ProviderData):
|
|||||||
group_mapping: Optional[SecretGroup] = None,
|
group_mapping: Optional[SecretGroup] = None,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""Public interface method to delete secrets only."""
|
"""Public interface method to delete secrets only."""
|
||||||
|
self._legacy_apply_on_delete([field])
|
||||||
|
|
||||||
full_field = self._field_to_internal_name(field, group_mapping)
|
full_field = self._field_to_internal_name(field, group_mapping)
|
||||||
if self.secrets_enabled and full_field not in self.current_secret_fields:
|
if self.secrets_enabled and full_field not in self.current_secret_fields:
|
||||||
logger.warning(f"Secret {field} from group {group_mapping} was not found")
|
logger.warning(f"Secret {field} from group {group_mapping} was not found")
|
||||||
return
|
return
|
||||||
if self._no_group_with_databag(field, full_field):
|
|
||||||
|
if self.valid_field_pattern(field, full_field):
|
||||||
self.delete_relation_data(relation_id, [full_field])
|
self.delete_relation_data(relation_id, [full_field])
|
||||||
|
|
||||||
|
##########################################################################
|
||||||
# Helpers
|
# Helpers
|
||||||
|
##########################################################################
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _field_to_internal_name(field: str, group: Optional[SecretGroup]) -> str:
|
def _field_to_internal_name(field: str, group: Optional[SecretGroup]) -> str:
|
||||||
@ -1936,10 +2109,69 @@ class DataPeerData(RequirerData, ProviderData):
|
|||||||
if k in self.secret_fields
|
if k in self.secret_fields
|
||||||
}
|
}
|
||||||
|
|
||||||
# Backwards compatibility
|
def valid_field_pattern(self, field: str, full_field: str) -> bool:
|
||||||
|
"""Check that no secret group is attempted to be used together without secrets being enabled.
|
||||||
|
|
||||||
|
Secrets groups are impossible to use with versions that are not yet supporting secrets.
|
||||||
|
"""
|
||||||
|
if not self.secrets_enabled and full_field != field:
|
||||||
|
logger.error(
|
||||||
|
f"Can't access {full_field}: no secrets available (i.e. no secret groups either)."
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
##########################################################################
|
||||||
|
# Backwards compatibility / Upgrades
|
||||||
|
##########################################################################
|
||||||
|
# These functions are used to keep backwards compatibility on upgrades
|
||||||
|
# Policy:
|
||||||
|
# All data is kept intact until the first write operation. (This allows a minimal
|
||||||
|
# grace period during which rollbacks are fully safe. For more info see spec.)
|
||||||
|
# All data involves:
|
||||||
|
# - databag
|
||||||
|
# - secrets content
|
||||||
|
# - secret labels (!!!)
|
||||||
|
# Legacy functions must return None, and leave an equally consistent state whether
|
||||||
|
# they are executed or skipped (as a high enough versioned execution environment may
|
||||||
|
# not require so)
|
||||||
|
|
||||||
|
# Full legacy stack for each operation
|
||||||
|
|
||||||
|
def _legacy_apply_on_fetch(self) -> None:
|
||||||
|
"""All legacy functions to be applied on fetch."""
|
||||||
|
relation = self._model.relations[self.relation_name][0]
|
||||||
|
self._legacy_compat_generate_prev_labels()
|
||||||
|
self._legacy_compat_secret_uri_from_databag(relation)
|
||||||
|
|
||||||
|
def _legacy_apply_on_update(self, fields) -> None:
|
||||||
|
"""All legacy functions to be applied on update."""
|
||||||
|
relation = self._model.relations[self.relation_name][0]
|
||||||
|
self._legacy_compat_generate_prev_labels()
|
||||||
|
self._legacy_compat_secret_uri_from_databag(relation)
|
||||||
|
self._legacy_migration_remove_secret_from_databag(relation, fields)
|
||||||
|
self._legacy_migration_remove_secret_field_name_from_databag(relation)
|
||||||
|
|
||||||
|
def _legacy_apply_on_delete(self, fields) -> None:
|
||||||
|
"""All legacy functions to be applied on delete."""
|
||||||
|
relation = self._model.relations[self.relation_name][0]
|
||||||
|
self._legacy_compat_generate_prev_labels()
|
||||||
|
self._legacy_compat_secret_uri_from_databag(relation)
|
||||||
|
self._legacy_compat_check_deleted_label(relation, fields)
|
||||||
|
|
||||||
|
# Compatibility
|
||||||
|
|
||||||
|
@legacy_apply_from_version(18)
|
||||||
|
def _legacy_compat_check_deleted_label(self, relation, fields) -> None:
|
||||||
|
"""Helper function for legacy behavior.
|
||||||
|
|
||||||
|
As long as https://bugs.launchpad.net/juju/+bug/2028094 wasn't fixed,
|
||||||
|
we did not delete fields but rather kept them in the secret with a string value
|
||||||
|
expressing invalidity. This function is maintainnig that behavior when needed.
|
||||||
|
"""
|
||||||
|
if not self.deleted_label:
|
||||||
|
return
|
||||||
|
|
||||||
def _check_deleted_label(self, relation, fields) -> None:
|
|
||||||
"""Helper function for legacy behavior."""
|
|
||||||
current_data = self.fetch_my_relation_data([relation.id], fields)
|
current_data = self.fetch_my_relation_data([relation.id], fields)
|
||||||
if current_data is not None:
|
if current_data is not None:
|
||||||
# Check if the secret we wanna delete actually exists
|
# Check if the secret we wanna delete actually exists
|
||||||
@ -1952,7 +2184,43 @@ class DataPeerData(RequirerData, ProviderData):
|
|||||||
", ".join(non_existent),
|
", ".join(non_existent),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _remove_secret_from_databag(self, relation, fields: List[str]) -> None:
|
@legacy_apply_from_version(18)
|
||||||
|
def _legacy_compat_secret_uri_from_databag(self, relation) -> None:
|
||||||
|
"""Fetching the secret URI from the databag, in case stored there."""
|
||||||
|
self._legacy_secret_uri = relation.data[self.component].get(
|
||||||
|
self._generate_secret_field_name(), None
|
||||||
|
)
|
||||||
|
|
||||||
|
@legacy_apply_from_version(34)
|
||||||
|
def _legacy_compat_generate_prev_labels(self) -> None:
|
||||||
|
"""Generator for legacy secret label names, for backwards compatibility.
|
||||||
|
|
||||||
|
Secret label is part of the data that MUST be maintained across rolling upgrades.
|
||||||
|
In case there may be a change on a secret label, the old label must be recognized
|
||||||
|
after upgrades, and left intact until the first write operation -- when we roll over
|
||||||
|
to the new label.
|
||||||
|
|
||||||
|
This function keeps "memory" of previously used secret labels.
|
||||||
|
NOTE: Return value takes decorator into account -- all 'legacy' functions may return `None`
|
||||||
|
|
||||||
|
v0.34 (rev69): Fixing issue https://github.com/canonical/data-platform-libs/issues/155
|
||||||
|
meant moving from '<app_name>.<scope>' (i.e. 'mysql.app', 'mysql.unit')
|
||||||
|
to labels '<relation_name>.<app_name>.<scope>' (like 'peer.mysql.app')
|
||||||
|
"""
|
||||||
|
if self._legacy_labels:
|
||||||
|
return
|
||||||
|
|
||||||
|
result = []
|
||||||
|
members = [self._model.app.name]
|
||||||
|
if self.scope:
|
||||||
|
members.append(self.scope.value)
|
||||||
|
result.append(f"{'.'.join(members)}")
|
||||||
|
self._legacy_labels = result
|
||||||
|
|
||||||
|
# Migration
|
||||||
|
|
||||||
|
@legacy_apply_from_version(18)
|
||||||
|
def _legacy_migration_remove_secret_from_databag(self, relation, fields: List[str]) -> None:
|
||||||
"""For Rolling Upgrades -- when moving from databag to secrets usage.
|
"""For Rolling Upgrades -- when moving from databag to secrets usage.
|
||||||
|
|
||||||
Practically what happens here is to remove stuff from the databag that is
|
Practically what happens here is to remove stuff from the databag that is
|
||||||
@ -1966,10 +2234,16 @@ class DataPeerData(RequirerData, ProviderData):
|
|||||||
if self._fetch_relation_data_without_secrets(self.component, relation, [field]):
|
if self._fetch_relation_data_without_secrets(self.component, relation, [field]):
|
||||||
self._delete_relation_data_without_secrets(self.component, relation, [field])
|
self._delete_relation_data_without_secrets(self.component, relation, [field])
|
||||||
|
|
||||||
def _remove_secret_field_name_from_databag(self, relation) -> None:
|
@legacy_apply_from_version(18)
|
||||||
|
def _legacy_migration_remove_secret_field_name_from_databag(self, relation) -> None:
|
||||||
"""Making sure that the old databag URI is gone.
|
"""Making sure that the old databag URI is gone.
|
||||||
|
|
||||||
This action should not be executed more than once.
|
This action should not be executed more than once.
|
||||||
|
|
||||||
|
There was a phase (before moving secrets usage to libs) when charms saved the peer
|
||||||
|
secret URI to the databag, and used this URI from then on to retrieve their secret.
|
||||||
|
When upgrading to charm versions using this library, we need to add a label to the
|
||||||
|
secret and access it via label from than on, and remove the old traces from the databag.
|
||||||
"""
|
"""
|
||||||
# Nothing to do if 'internal-secret' is not in the databag
|
# Nothing to do if 'internal-secret' is not in the databag
|
||||||
if not (relation.data[self.component].get(self._generate_secret_field_name())):
|
if not (relation.data[self.component].get(self._generate_secret_field_name())):
|
||||||
@ -1985,25 +2259,9 @@ class DataPeerData(RequirerData, ProviderData):
|
|||||||
# Databag reference to the secret URI can be removed, now that it's labelled
|
# Databag reference to the secret URI can be removed, now that it's labelled
|
||||||
relation.data[self.component].pop(self._generate_secret_field_name(), None)
|
relation.data[self.component].pop(self._generate_secret_field_name(), None)
|
||||||
|
|
||||||
def _previous_labels(self) -> List[str]:
|
##########################################################################
|
||||||
"""Generator for legacy secret label names, for backwards compatibility."""
|
|
||||||
result = []
|
|
||||||
members = [self._model.app.name]
|
|
||||||
if self.scope:
|
|
||||||
members.append(self.scope.value)
|
|
||||||
result.append(f"{'.'.join(members)}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _no_group_with_databag(self, field: str, full_field: str) -> bool:
|
|
||||||
"""Check that no secret group is attempted to be used together with databag."""
|
|
||||||
if not self.secrets_enabled and full_field != field:
|
|
||||||
logger.error(
|
|
||||||
f"Can't access {full_field}: no secrets available (i.e. no secret groups either)."
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Event handlers
|
# Event handlers
|
||||||
|
##########################################################################
|
||||||
|
|
||||||
def _on_relation_changed_event(self, event: RelationChangedEvent) -> None:
|
def _on_relation_changed_event(self, event: RelationChangedEvent) -> None:
|
||||||
"""Event emitted when the relation has changed."""
|
"""Event emitted when the relation has changed."""
|
||||||
@ -2013,7 +2271,9 @@ class DataPeerData(RequirerData, ProviderData):
|
|||||||
"""Event emitted when the secret has changed."""
|
"""Event emitted when the secret has changed."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
##########################################################################
|
||||||
# Overrides of Relation Data handling functions
|
# Overrides of Relation Data handling functions
|
||||||
|
##########################################################################
|
||||||
|
|
||||||
def _generate_secret_label(
|
def _generate_secret_label(
|
||||||
self, relation_name: str, relation_id: int, group_mapping: SecretGroup
|
self, relation_name: str, relation_id: int, group_mapping: SecretGroup
|
||||||
@ -2050,13 +2310,14 @@ class DataPeerData(RequirerData, ProviderData):
|
|||||||
return
|
return
|
||||||
|
|
||||||
label = self._generate_secret_label(relation_name, relation_id, group_mapping)
|
label = self._generate_secret_label(relation_name, relation_id, group_mapping)
|
||||||
secret_uri = relation.data[self.component].get(self._generate_secret_field_name(), None)
|
|
||||||
|
|
||||||
# URI or legacy label is only to applied when moving single legacy secret to a (new) label
|
# URI or legacy label is only to applied when moving single legacy secret to a (new) label
|
||||||
if group_mapping == SECRET_GROUPS.EXTRA:
|
if group_mapping == SECRET_GROUPS.EXTRA:
|
||||||
# Fetching the secret with fallback to URI (in case label is not yet known)
|
# Fetching the secret with fallback to URI (in case label is not yet known)
|
||||||
# Label would we "stuck" on the secret in case it is found
|
# Label would we "stuck" on the secret in case it is found
|
||||||
return self.secrets.get(label, secret_uri, legacy_labels=self._previous_labels())
|
return self.secrets.get(
|
||||||
|
label, self._legacy_secret_uri, legacy_labels=self._legacy_labels
|
||||||
|
)
|
||||||
return self.secrets.get(label)
|
return self.secrets.get(label)
|
||||||
|
|
||||||
def _get_group_secret_contents(
|
def _get_group_secret_contents(
|
||||||
@ -2086,7 +2347,6 @@ class DataPeerData(RequirerData, ProviderData):
|
|||||||
@either_static_or_dynamic_secrets
|
@either_static_or_dynamic_secrets
|
||||||
def _update_relation_data(self, relation: Relation, data: Dict[str, str]) -> None:
|
def _update_relation_data(self, relation: Relation, data: Dict[str, str]) -> None:
|
||||||
"""Update data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app."""
|
"""Update data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app."""
|
||||||
self._remove_secret_from_databag(relation, list(data.keys()))
|
|
||||||
_, normal_fields = self._process_secret_fields(
|
_, normal_fields = self._process_secret_fields(
|
||||||
relation,
|
relation,
|
||||||
self.secret_fields,
|
self.secret_fields,
|
||||||
@ -2095,7 +2355,6 @@ class DataPeerData(RequirerData, ProviderData):
|
|||||||
data=data,
|
data=data,
|
||||||
uri_to_databag=False,
|
uri_to_databag=False,
|
||||||
)
|
)
|
||||||
self._remove_secret_field_name_from_databag(relation)
|
|
||||||
|
|
||||||
normal_content = {k: v for k, v in data.items() if k in normal_fields}
|
normal_content = {k: v for k, v in data.items() if k in normal_fields}
|
||||||
self._update_relation_data_without_secrets(self.component, relation, normal_content)
|
self._update_relation_data_without_secrets(self.component, relation, normal_content)
|
||||||
@ -2104,9 +2363,6 @@ class DataPeerData(RequirerData, ProviderData):
|
|||||||
def _delete_relation_data(self, relation: Relation, fields: List[str]) -> None:
|
def _delete_relation_data(self, relation: Relation, fields: List[str]) -> None:
|
||||||
"""Delete data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app."""
|
"""Delete data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app."""
|
||||||
if self.secret_fields and self.deleted_label:
|
if self.secret_fields and self.deleted_label:
|
||||||
# Legacy, backwards compatibility
|
|
||||||
self._check_deleted_label(relation, fields)
|
|
||||||
|
|
||||||
_, normal_fields = self._process_secret_fields(
|
_, normal_fields = self._process_secret_fields(
|
||||||
relation,
|
relation,
|
||||||
self.secret_fields,
|
self.secret_fields,
|
||||||
@ -2141,7 +2397,9 @@ class DataPeerData(RequirerData, ProviderData):
|
|||||||
"fetch_my_relation_data() and fetch_my_relation_field()"
|
"fetch_my_relation_data() and fetch_my_relation_field()"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
##########################################################################
|
||||||
# Public functions -- inherited
|
# Public functions -- inherited
|
||||||
|
##########################################################################
|
||||||
|
|
||||||
fetch_my_relation_data = Data.fetch_my_relation_data
|
fetch_my_relation_data = Data.fetch_my_relation_data
|
||||||
fetch_my_relation_field = Data.fetch_my_relation_field
|
fetch_my_relation_field = Data.fetch_my_relation_field
|
||||||
@ -2606,6 +2864,14 @@ class DatabaseProviderData(ProviderData):
|
|||||||
"""
|
"""
|
||||||
self.update_relation_data(relation_id, {"version": version})
|
self.update_relation_data(relation_id, {"version": version})
|
||||||
|
|
||||||
|
def set_subordinated(self, relation_id: int) -> None:
|
||||||
|
"""Raises the subordinated flag in the application relation databag.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
relation_id: the identifier for a particular relation.
|
||||||
|
"""
|
||||||
|
self.update_relation_data(relation_id, {"subordinated": "true"})
|
||||||
|
|
||||||
|
|
||||||
class DatabaseProviderEventHandlers(EventHandlers):
|
class DatabaseProviderEventHandlers(EventHandlers):
|
||||||
"""Provider-side of the database relation handlers."""
|
"""Provider-side of the database relation handlers."""
|
||||||
@ -2842,6 +3108,21 @@ class DatabaseRequirerEventHandlers(RequirerEventHandlers):
|
|||||||
|
|
||||||
def _on_relation_changed_event(self, event: RelationChangedEvent) -> None:
|
def _on_relation_changed_event(self, event: RelationChangedEvent) -> None:
|
||||||
"""Event emitted when the database relation has changed."""
|
"""Event emitted when the database relation has changed."""
|
||||||
|
is_subordinate = False
|
||||||
|
remote_unit_data = None
|
||||||
|
for key in event.relation.data.keys():
|
||||||
|
if isinstance(key, Unit) and not key.name.startswith(self.charm.app.name):
|
||||||
|
remote_unit_data = event.relation.data[key]
|
||||||
|
elif isinstance(key, Application) and key.name != self.charm.app.name:
|
||||||
|
is_subordinate = event.relation.data[key].get("subordinated") == "true"
|
||||||
|
|
||||||
|
if is_subordinate:
|
||||||
|
if not remote_unit_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
if remote_unit_data.get("state") != "ready":
|
||||||
|
return
|
||||||
|
|
||||||
# Check which data has changed to emit customs events.
|
# Check which data has changed to emit customs events.
|
||||||
diff = self._diff(event)
|
diff = self._diff(event)
|
||||||
|
|
||||||
@ -3023,6 +3304,8 @@ class KafkaRequiresEvents(CharmEvents):
|
|||||||
class KafkaProviderData(ProviderData):
|
class KafkaProviderData(ProviderData):
|
||||||
"""Provider-side of the Kafka relation."""
|
"""Provider-side of the Kafka relation."""
|
||||||
|
|
||||||
|
RESOURCE_FIELD = "topic"
|
||||||
|
|
||||||
def __init__(self, model: Model, relation_name: str) -> None:
|
def __init__(self, model: Model, relation_name: str) -> None:
|
||||||
super().__init__(model, relation_name)
|
super().__init__(model, relation_name)
|
||||||
|
|
||||||
@ -3272,6 +3555,8 @@ class OpenSearchRequiresEvents(CharmEvents):
|
|||||||
class OpenSearchProvidesData(ProviderData):
|
class OpenSearchProvidesData(ProviderData):
|
||||||
"""Provider-side of the OpenSearch relation."""
|
"""Provider-side of the OpenSearch relation."""
|
||||||
|
|
||||||
|
RESOURCE_FIELD = "index"
|
||||||
|
|
||||||
def __init__(self, model: Model, relation_name: str) -> None:
|
def __init__(self, model: Model, relation_name: str) -> None:
|
||||||
super().__init__(model, relation_name)
|
super().__init__(model, relation_name)
|
||||||
|
|
||||||
|
@ -29,11 +29,13 @@ defines the pebble layers, manages pushing configuration to the
|
|||||||
containers and managing the service running in the container.
|
containers and managing the service running in the container.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import functools
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import urllib
|
import urllib
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from typing import (
|
from typing import (
|
||||||
|
TYPE_CHECKING,
|
||||||
List,
|
List,
|
||||||
Mapping,
|
Mapping,
|
||||||
Optional,
|
Optional,
|
||||||
@ -70,6 +72,9 @@ from ops.model import (
|
|||||||
MaintenanceStatus,
|
MaintenanceStatus,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import charms.operator_libs_linux.v2.snap as snap
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -182,6 +187,7 @@ class OSBaseOperatorCharm(
|
|||||||
self.configure_charm,
|
self.configure_charm,
|
||||||
database_name,
|
database_name,
|
||||||
relation_name in self.mandatory_relations,
|
relation_name in self.mandatory_relations,
|
||||||
|
external_access=self.remote_external_access,
|
||||||
)
|
)
|
||||||
self.dbs[relation_name] = db
|
self.dbs[relation_name] = db
|
||||||
handlers.append(db)
|
handlers.append(db)
|
||||||
@ -459,7 +465,11 @@ class OSBaseOperatorCharm(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(event, RelationBrokenEvent):
|
if isinstance(event, RelationBrokenEvent):
|
||||||
_is_broken = True
|
_is_broken = event.relation.name in (
|
||||||
|
"database",
|
||||||
|
"api-database",
|
||||||
|
"cell-database",
|
||||||
|
)
|
||||||
case "ingress-public" | "ingress-internal":
|
case "ingress-public" | "ingress-internal":
|
||||||
from charms.traefik_k8s.v2.ingress import (
|
from charms.traefik_k8s.v2.ingress import (
|
||||||
IngressPerAppRevokedEvent,
|
IngressPerAppRevokedEvent,
|
||||||
@ -1120,3 +1130,186 @@ class OSBaseOperatorAPICharm(OSBaseOperatorCharmK8S):
|
|||||||
url.fragment,
|
url.fragment,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OSBaseOperatorCharmSnap(OSBaseOperatorCharm):
|
||||||
|
"""Base charm class for snap based charms."""
|
||||||
|
|
||||||
|
def __init__(self, framework):
|
||||||
|
super().__init__(framework)
|
||||||
|
self.snap_module = self._import_snap()
|
||||||
|
|
||||||
|
self.framework.observe(
|
||||||
|
self.on.install,
|
||||||
|
self._on_install,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _import_snap(self):
|
||||||
|
import charms.operator_libs_linux.v2.snap as snap
|
||||||
|
|
||||||
|
return snap
|
||||||
|
|
||||||
|
def _on_install(self, _: ops.InstallEvent):
|
||||||
|
"""Run install on this unit."""
|
||||||
|
self.ensure_snap_present()
|
||||||
|
|
||||||
|
@functools.cache
|
||||||
|
def get_snap(self) -> "snap.Snap":
|
||||||
|
"""Return snap object."""
|
||||||
|
return self.snap_module.SnapCache()[self.snap_name]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def snap_name(self) -> str:
|
||||||
|
"""Return snap name."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@property
|
||||||
|
def snap_channel(self) -> str:
|
||||||
|
"""Return snap channel."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def ensure_snap_present(self):
|
||||||
|
"""Install snap if it is not already present."""
|
||||||
|
try:
|
||||||
|
snap_svc = self.get_snap()
|
||||||
|
|
||||||
|
if not snap_svc.present:
|
||||||
|
snap_svc.ensure(
|
||||||
|
self.snap_module.SnapState.Latest,
|
||||||
|
channel=self.snap_channel,
|
||||||
|
)
|
||||||
|
except self.snap_module.SnapError as e:
|
||||||
|
logger.error(
|
||||||
|
"An exception occurred when installing %s. Reason: %s",
|
||||||
|
self.snap_name,
|
||||||
|
e.message,
|
||||||
|
)
|
||||||
|
|
||||||
|
def ensure_services_running(self, enable: bool = True) -> None:
|
||||||
|
"""Ensure snap services are up."""
|
||||||
|
snap_svc = self.get_snap()
|
||||||
|
snap_svc.start(enable=enable)
|
||||||
|
|
||||||
|
def stop_services(self, relation: set[str] | None = None) -> None:
|
||||||
|
"""Stop snap services."""
|
||||||
|
snap_svc = self.get_snap()
|
||||||
|
snap_svc.stop(disable=True)
|
||||||
|
|
||||||
|
def set_snap_data(self, snap_data: Mapping, namespace: str | None = None):
|
||||||
|
"""Set snap data on local snap.
|
||||||
|
|
||||||
|
Setting keys with 3 level or more of indentation is not yet supported.
|
||||||
|
`namespace` offers the possibility to work as if it was supported.
|
||||||
|
"""
|
||||||
|
snap_svc = self.get_snap()
|
||||||
|
new_settings = {}
|
||||||
|
try:
|
||||||
|
old_settings = snap_svc.get(namespace, typed=True)
|
||||||
|
except self.snap_module.SnapError:
|
||||||
|
old_settings = {}
|
||||||
|
|
||||||
|
for key, new_value in snap_data.items():
|
||||||
|
key_split = key.split(".")
|
||||||
|
if len(key_split) == 2:
|
||||||
|
group, subkey = key_split
|
||||||
|
old_value = old_settings.get(group, {}).get(subkey)
|
||||||
|
else:
|
||||||
|
old_value = old_settings.get(key)
|
||||||
|
if old_value is not None and old_value != new_value:
|
||||||
|
new_settings[key] = new_value
|
||||||
|
# Setting a value to None will unset the value from the snap,
|
||||||
|
# which will fail if the value was never set.
|
||||||
|
elif new_value is not None:
|
||||||
|
new_settings[key] = new_value
|
||||||
|
|
||||||
|
if new_settings:
|
||||||
|
if namespace is not None:
|
||||||
|
new_settings = {namespace: new_settings}
|
||||||
|
logger.debug(f"Applying new snap settings {new_settings}")
|
||||||
|
snap_svc.set(new_settings, typed=True)
|
||||||
|
else:
|
||||||
|
logger.debug("Snap settings do not need updating")
|
||||||
|
|
||||||
|
def configure_snap(self, event: ops.EventBase) -> None:
|
||||||
|
"""Run configuration on managed snap."""
|
||||||
|
|
||||||
|
def configure_unit(self, event: ops.EventBase) -> None:
|
||||||
|
"""Run configuration on this unit."""
|
||||||
|
self.ensure_snap_present()
|
||||||
|
self.check_leader_ready()
|
||||||
|
self.check_relation_handlers_ready(event)
|
||||||
|
self.configure_snap(event)
|
||||||
|
self.ensure_services_running()
|
||||||
|
self._state.unit_bootstrapped = True
|
||||||
|
|
||||||
|
|
||||||
|
class OSCinderVolumeDriverOperatorCharm(OSBaseOperatorCharmSnap):
|
||||||
|
"""Base class charms for Cinder volume drivers.
|
||||||
|
|
||||||
|
Operators implementing this class are subordinates charm that are not
|
||||||
|
responsible for installing / managing the snap.
|
||||||
|
Their only duty is to provide a backend configuration to the
|
||||||
|
snap managed by the principal unit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, framework: ops.Framework):
|
||||||
|
super().__init__(framework)
|
||||||
|
self._state.set_default(volume_ready=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def backend_key(self) -> str:
|
||||||
|
"""Key for backend configuration."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def ensure_snap_present(self):
|
||||||
|
"""No-op."""
|
||||||
|
|
||||||
|
def ensure_services_running(self, enable: bool = True) -> None:
|
||||||
|
"""No-op."""
|
||||||
|
|
||||||
|
def stop_services(self, relation: set[str] | None = None) -> None:
|
||||||
|
"""No-op."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def snap_name(self) -> str:
|
||||||
|
"""Return snap name."""
|
||||||
|
snap_name = self.cinder_volume.interface.snap_name()
|
||||||
|
|
||||||
|
if snap_name is None:
|
||||||
|
raise sunbeam_guard.WaitingExceptionError(
|
||||||
|
"Waiting for snap name from cinder-volume relation"
|
||||||
|
)
|
||||||
|
|
||||||
|
return snap_name
|
||||||
|
|
||||||
|
def get_relation_handlers(
|
||||||
|
self, handlers: list[sunbeam_rhandlers.RelationHandler] | None = None
|
||||||
|
) -> list[sunbeam_rhandlers.RelationHandler]:
|
||||||
|
"""Relation handlers for the service."""
|
||||||
|
handlers = handlers or []
|
||||||
|
self.cinder_volume = sunbeam_rhandlers.CinderVolumeRequiresHandler(
|
||||||
|
self,
|
||||||
|
"cinder-volume",
|
||||||
|
self.backend_key,
|
||||||
|
self.volume_ready,
|
||||||
|
mandatory="cinder-volume" in self.mandatory_relations,
|
||||||
|
)
|
||||||
|
handlers.append(self.cinder_volume)
|
||||||
|
return super().get_relation_handlers(handlers)
|
||||||
|
|
||||||
|
def volume_ready(self, event) -> None:
|
||||||
|
"""Event handler for bootstrap of service when api services are ready."""
|
||||||
|
self._state.volume_ready = True
|
||||||
|
self.configure_charm(event)
|
||||||
|
|
||||||
|
def configure_snap(self, event: ops.EventBase) -> None:
|
||||||
|
"""Configure backend for cinder volume driver."""
|
||||||
|
if not bool(self._state.volume_ready):
|
||||||
|
raise sunbeam_guard.WaitingExceptionError("Volume not ready")
|
||||||
|
backend_context = self.get_backend_configuration()
|
||||||
|
self.set_snap_data(backend_context, namespace=self.backend_key)
|
||||||
|
self.cinder_volume.interface.set_ready()
|
||||||
|
|
||||||
|
def get_backend_configuration(self) -> Mapping:
|
||||||
|
"""Get backend configuration."""
|
||||||
|
raise NotImplementedError
|
||||||
|
@ -53,6 +53,7 @@ if typing.TYPE_CHECKING:
|
|||||||
import charms.ceilometer_k8s.v0.ceilometer_service as ceilometer_service
|
import charms.ceilometer_k8s.v0.ceilometer_service as ceilometer_service
|
||||||
import charms.certificate_transfer_interface.v0.certificate_transfer as certificate_transfer
|
import charms.certificate_transfer_interface.v0.certificate_transfer as certificate_transfer
|
||||||
import charms.cinder_ceph_k8s.v0.ceph_access as ceph_access
|
import charms.cinder_ceph_k8s.v0.ceph_access as ceph_access
|
||||||
|
import charms.cinder_volume.v0.cinder_volume as sunbeam_cinder_volume
|
||||||
import charms.data_platform_libs.v0.data_interfaces as data_interfaces
|
import charms.data_platform_libs.v0.data_interfaces as data_interfaces
|
||||||
import charms.gnocchi_k8s.v0.gnocchi_service as gnocchi_service
|
import charms.gnocchi_k8s.v0.gnocchi_service as gnocchi_service
|
||||||
import charms.keystone_k8s.v0.identity_credentials as identity_credentials
|
import charms.keystone_k8s.v0.identity_credentials as identity_credentials
|
||||||
@ -302,11 +303,13 @@ class DBHandler(RelationHandler):
|
|||||||
callback_f: Callable,
|
callback_f: Callable,
|
||||||
database: str,
|
database: str,
|
||||||
mandatory: bool = False,
|
mandatory: bool = False,
|
||||||
|
external_access: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run constructor."""
|
"""Run constructor."""
|
||||||
# a database name as requested by the charm.
|
# a database name as requested by the charm.
|
||||||
super().__init__(charm, relation_name, callback_f, mandatory)
|
super().__init__(charm, relation_name, callback_f, mandatory)
|
||||||
self.database_name = database
|
self.database_name = database
|
||||||
|
self.external_access = external_access
|
||||||
|
|
||||||
def setup_event_handler(self) -> ops.framework.Object:
|
def setup_event_handler(self) -> ops.framework.Object:
|
||||||
"""Configure event handlers for a MySQL relation."""
|
"""Configure event handlers for a MySQL relation."""
|
||||||
@ -331,6 +334,7 @@ class DBHandler(RelationHandler):
|
|||||||
self.relation_name,
|
self.relation_name,
|
||||||
self.database_name,
|
self.database_name,
|
||||||
relations_aliases=[alias],
|
relations_aliases=[alias],
|
||||||
|
external_node_connectivity=self.external_access,
|
||||||
)
|
)
|
||||||
self.framework.observe(
|
self.framework.observe(
|
||||||
# db.on[f"{alias}_database_created"], # this doesn't work because:
|
# db.on[f"{alias}_database_created"], # this doesn't work because:
|
||||||
@ -2388,3 +2392,53 @@ class ServiceReadinessProviderHandler(RelationHandler):
|
|||||||
def ready(self) -> bool:
|
def ready(self) -> bool:
|
||||||
"""Report if relation is ready."""
|
"""Report if relation is ready."""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@sunbeam_tracing.trace_type
|
||||||
|
class CinderVolumeRequiresHandler(RelationHandler):
|
||||||
|
"""Handler for Cinder Volume relation."""
|
||||||
|
|
||||||
|
interface: "sunbeam_cinder_volume.CinderVolumeRequires"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
charm: "OSBaseOperatorCharm",
|
||||||
|
relation_name: str,
|
||||||
|
backend_key: str,
|
||||||
|
callback_f: Callable,
|
||||||
|
mandatory: bool = True,
|
||||||
|
):
|
||||||
|
self.backend_key = backend_key
|
||||||
|
super().__init__(charm, relation_name, callback_f, mandatory=mandatory)
|
||||||
|
|
||||||
|
def setup_event_handler(self):
|
||||||
|
"""Configure event handlers for Cinder Volume relation."""
|
||||||
|
import charms.cinder_volume.v0.cinder_volume as sunbeam_cinder_volume
|
||||||
|
|
||||||
|
logger.debug("Setting up Cinder Volume event handler")
|
||||||
|
cinder_volume = sunbeam_tracing.trace_type(
|
||||||
|
sunbeam_cinder_volume.CinderVolumeRequires
|
||||||
|
)(
|
||||||
|
self.charm,
|
||||||
|
self.relation_name,
|
||||||
|
backend_key=self.backend_key,
|
||||||
|
)
|
||||||
|
self.framework.observe(
|
||||||
|
cinder_volume.on.ready,
|
||||||
|
self._on_cinder_volume_ready,
|
||||||
|
)
|
||||||
|
|
||||||
|
return cinder_volume
|
||||||
|
|
||||||
|
def _on_cinder_volume_ready(self, event: ops.RelationEvent) -> None:
|
||||||
|
"""Handles Cinder Volume change events."""
|
||||||
|
self.callback_f(event)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
"""Report if relation is ready."""
|
||||||
|
return self.interface.provider_ready()
|
||||||
|
|
||||||
|
def snap(self) -> str | None:
|
||||||
|
"""Return snap name."""
|
||||||
|
return self.interface.snap_name()
|
||||||
|
@ -22,6 +22,9 @@ import tempfile
|
|||||||
from typing import (
|
from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
)
|
)
|
||||||
|
from unittest.mock import (
|
||||||
|
Mock,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import ops.framework
|
import ops.framework
|
||||||
@ -363,3 +366,43 @@ class TestMultiSvcCharm(MyAPICharm):
|
|||||||
self.configure_charm,
|
self.configure_charm,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class MySnapCharm(sunbeam_charm.OSBaseOperatorCharmSnap):
|
||||||
|
"""Test charm for testing OSBaseOperatorCharmSnap."""
|
||||||
|
|
||||||
|
service_name = "mysnap"
|
||||||
|
|
||||||
|
def __init__(self, framework: "ops.framework.Framework") -> None:
|
||||||
|
"""Run constructor."""
|
||||||
|
self.seen_events = []
|
||||||
|
self.mock_snap = Mock()
|
||||||
|
super().__init__(framework)
|
||||||
|
|
||||||
|
def _log_event(self, event: "ops.framework.EventBase") -> None:
|
||||||
|
"""Log events."""
|
||||||
|
self.seen_events.append(type(event).__name__)
|
||||||
|
|
||||||
|
def _on_config_changed(self, event: "ops.framework.EventBase") -> None:
|
||||||
|
"""Log config changed event."""
|
||||||
|
self._log_event(event)
|
||||||
|
super()._on_config_changed(event)
|
||||||
|
|
||||||
|
def configure_charm(self, event: "ops.framework.EventBase") -> None:
|
||||||
|
"""Log configure_charm call."""
|
||||||
|
self._log_event(event)
|
||||||
|
super().configure_charm(event)
|
||||||
|
|
||||||
|
def get_snap(self):
|
||||||
|
"""Return mocked snap."""
|
||||||
|
return self.mock_snap
|
||||||
|
|
||||||
|
@property
|
||||||
|
def snap_name(self) -> str:
|
||||||
|
"""Return snap name."""
|
||||||
|
return "mysnap"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def snap_channel(self) -> str:
|
||||||
|
"""Return snap channel."""
|
||||||
|
return "latest/stable"
|
||||||
|
@ -489,7 +489,7 @@ class TestOSBaseOperatorMultiSVCAPICharm(_TestOSBaseOperatorAPICharm):
|
|||||||
"""Test Charm with multiple services."""
|
"""Test Charm with multiple services."""
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
"""Charm test class setip."""
|
"""Charm test class setup."""
|
||||||
super().setUp(test_charms.TestMultiSvcCharm)
|
super().setUp(test_charms.TestMultiSvcCharm)
|
||||||
|
|
||||||
def test_start_services(self) -> None:
|
def test_start_services(self) -> None:
|
||||||
@ -506,3 +506,50 @@ class TestOSBaseOperatorMultiSVCAPICharm(_TestOSBaseOperatorAPICharm):
|
|||||||
sorted(self.container_calls.started_services("my-service")),
|
sorted(self.container_calls.started_services("my-service")),
|
||||||
sorted(["apache forwarder", "my-service"]),
|
sorted(["apache forwarder", "my-service"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOSBaseOperatorCharmSnap(test_utils.CharmTestCase):
|
||||||
|
"""Test snap based charm."""
|
||||||
|
|
||||||
|
PATCHES = []
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
"""Charm test class setup."""
|
||||||
|
super().setUp(sunbeam_charm, self.PATCHES)
|
||||||
|
self.harness = test_utils.get_harness(
|
||||||
|
test_charms.MySnapCharm,
|
||||||
|
test_charms.CHARM_METADATA,
|
||||||
|
None,
|
||||||
|
charm_config=test_charms.CHARM_CONFIG,
|
||||||
|
initial_charm_config=test_charms.INITIAL_CHARM_CONFIG,
|
||||||
|
)
|
||||||
|
self.mock_event = MagicMock()
|
||||||
|
self.harness.begin()
|
||||||
|
self.addCleanup(self.harness.cleanup)
|
||||||
|
|
||||||
|
def test_set_snap_data(self) -> None:
|
||||||
|
"""Test snap set data."""
|
||||||
|
charm = self.harness.charm
|
||||||
|
snap = charm.mock_snap
|
||||||
|
snap.get.return_value = {
|
||||||
|
"settings.debug": False,
|
||||||
|
"settings.region": "RegionOne",
|
||||||
|
}
|
||||||
|
charm.set_snap_data({"settings.debug": True})
|
||||||
|
snap.set.assert_called_once_with({"settings.debug": True}, typed=True)
|
||||||
|
|
||||||
|
def test_set_snap_data_namespace(self) -> None:
|
||||||
|
"""Test snap set data under namespace."""
|
||||||
|
charm = self.harness.charm
|
||||||
|
snap = charm.mock_snap
|
||||||
|
namespace = "ceph.monostack"
|
||||||
|
snap.get.return_value = {
|
||||||
|
"auth": "cephx",
|
||||||
|
}
|
||||||
|
# check unsetting a non-existent value is passed as None
|
||||||
|
new_data = {"key": "abc", "value": None}
|
||||||
|
charm.set_snap_data(new_data, namespace=namespace)
|
||||||
|
snap.get.assert_called_once_with(namespace, typed=True)
|
||||||
|
snap.set.assert_called_once_with(
|
||||||
|
{namespace: {"key": "abc"}}, typed=True
|
||||||
|
)
|
||||||
|
@ -106,6 +106,30 @@
|
|||||||
- rebuild
|
- rebuild
|
||||||
vars:
|
vars:
|
||||||
charm: cinder-k8s
|
charm: cinder-k8s
|
||||||
|
- job:
|
||||||
|
name: charm-build-cinder-volume
|
||||||
|
description: Build sunbeam cinder-volume charm
|
||||||
|
run: playbooks/charm/build.yaml
|
||||||
|
timeout: 3600
|
||||||
|
match-on-config-updates: false
|
||||||
|
files:
|
||||||
|
- ops-sunbeam/ops_sunbeam/*
|
||||||
|
- charms/cinder-volume/*
|
||||||
|
- rebuild
|
||||||
|
vars:
|
||||||
|
charm: cinder-volume
|
||||||
|
- job:
|
||||||
|
name: charm-build-cinder-volume-ceph
|
||||||
|
description: Build sunbeam cinder-volume-ceph charm
|
||||||
|
run: playbooks/charm/build.yaml
|
||||||
|
timeout: 3600
|
||||||
|
match-on-config-updates: false
|
||||||
|
files:
|
||||||
|
- ops-sunbeam/ops_sunbeam/*
|
||||||
|
- charms/cinder-volume-ceph/*
|
||||||
|
- rebuild
|
||||||
|
vars:
|
||||||
|
charm: cinder-volume-ceph
|
||||||
- job:
|
- job:
|
||||||
name: charm-build-cinder-ceph-k8s
|
name: charm-build-cinder-ceph-k8s
|
||||||
description: Build sunbeam cinder-ceph-k8s charm
|
description: Build sunbeam cinder-ceph-k8s charm
|
||||||
@ -655,6 +679,32 @@
|
|||||||
- charmhub_token
|
- charmhub_token
|
||||||
timeout: 3600
|
timeout: 3600
|
||||||
|
|
||||||
|
- job:
|
||||||
|
name: publish-charm-cinder-volume
|
||||||
|
description: |
|
||||||
|
Publish cinder-volume built in gate pipeline.
|
||||||
|
run: playbooks/charm/publish.yaml
|
||||||
|
files:
|
||||||
|
- ops-sunbeam/ops_sunbeam/*
|
||||||
|
- charms/cinder-volume/*
|
||||||
|
- rebuild
|
||||||
|
secrets:
|
||||||
|
- charmhub_token
|
||||||
|
timeout: 3600
|
||||||
|
|
||||||
|
- job:
|
||||||
|
name: publish-charm-cinder-volume-ceph
|
||||||
|
description: |
|
||||||
|
Publish cinder-volume-ceph built in gate pipeline.
|
||||||
|
run: playbooks/charm/publish.yaml
|
||||||
|
files:
|
||||||
|
- ops-sunbeam/ops_sunbeam/*
|
||||||
|
- charms/cinder-volume-ceph/*
|
||||||
|
- rebuild
|
||||||
|
secrets:
|
||||||
|
- charmhub_token
|
||||||
|
timeout: 3600
|
||||||
|
|
||||||
- job:
|
- job:
|
||||||
name: publish-charm-designate-bind-k8s
|
name: publish-charm-designate-bind-k8s
|
||||||
description: |
|
description: |
|
||||||
|
@ -56,6 +56,10 @@
|
|||||||
nodeset: ubuntu-jammy
|
nodeset: ubuntu-jammy
|
||||||
- charm-build-cinder-ceph-k8s:
|
- charm-build-cinder-ceph-k8s:
|
||||||
nodeset: ubuntu-jammy
|
nodeset: ubuntu-jammy
|
||||||
|
- charm-build-cinder-volume:
|
||||||
|
nodeset: ubuntu-jammy
|
||||||
|
- charm-build-cinder-volume-ceph:
|
||||||
|
nodeset: ubuntu-jammy
|
||||||
- charm-build-horizon-k8s:
|
- charm-build-horizon-k8s:
|
||||||
nodeset: ubuntu-jammy
|
nodeset: ubuntu-jammy
|
||||||
- charm-build-heat-k8s:
|
- charm-build-heat-k8s:
|
||||||
@ -115,6 +119,8 @@
|
|||||||
nodeset: ubuntu-jammy
|
nodeset: ubuntu-jammy
|
||||||
- charm-build-cinder-ceph-k8s:
|
- charm-build-cinder-ceph-k8s:
|
||||||
nodeset: ubuntu-jammy
|
nodeset: ubuntu-jammy
|
||||||
|
- charm-build-cinder-volume:
|
||||||
|
nodeset: ubuntu-jammy
|
||||||
- charm-build-horizon-k8s:
|
- charm-build-horizon-k8s:
|
||||||
nodeset: ubuntu-jammy
|
nodeset: ubuntu-jammy
|
||||||
- charm-build-heat-k8s:
|
- charm-build-heat-k8s:
|
||||||
@ -178,6 +184,10 @@
|
|||||||
nodeset: ubuntu-jammy
|
nodeset: ubuntu-jammy
|
||||||
- publish-charm-cinder-ceph-k8s:
|
- publish-charm-cinder-ceph-k8s:
|
||||||
nodeset: ubuntu-jammy
|
nodeset: ubuntu-jammy
|
||||||
|
- publish-charm-cinder-volume:
|
||||||
|
nodeset: ubuntu-jammy
|
||||||
|
- publish-charm-cinder-volume-ceph:
|
||||||
|
nodeset: ubuntu-jammy
|
||||||
- publish-charm-horizon-k8s:
|
- publish-charm-horizon-k8s:
|
||||||
nodeset: ubuntu-jammy
|
nodeset: ubuntu-jammy
|
||||||
- publish-charm-heat-k8s:
|
- publish-charm-heat-k8s:
|
||||||
|
@ -1,75 +1,85 @@
|
|||||||
- secret:
|
- secret:
|
||||||
name: charmhub_token
|
name: charmhub_token
|
||||||
data:
|
data:
|
||||||
# Generated on 2025-01-28T08:30:54+00:00 with 90 days ttl
|
# Generated on 2025-02-19T21:38:12+00:00 with 90 days ttl
|
||||||
value: !encrypted/pkcs1-oaep
|
value: !encrypted/pkcs1-oaep
|
||||||
- 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
- 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
- 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
- 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
- HlEMXBGU/d6VzDpJ6RCvUn6BGtnCodtLGc8fUi9liCgA2rTiitcltF5D/wqetVYMfsaYr
|
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
|
||||||
|
@ -28,6 +28,8 @@
|
|||||||
ovn-central-k8s: 24.03/edge
|
ovn-central-k8s: 24.03/edge
|
||||||
ovn-relay-k8s: 24.03/edge
|
ovn-relay-k8s: 24.03/edge
|
||||||
cinder-k8s: 2024.1/edge
|
cinder-k8s: 2024.1/edge
|
||||||
|
cinder-volume: 2024.1/edge
|
||||||
|
cinder-volume-ceph: 2024.1/edge
|
||||||
cinder-ceph-k8s: 2024.1/edge
|
cinder-ceph-k8s: 2024.1/edge
|
||||||
horizon-k8s: 2024.1/edge
|
horizon-k8s: 2024.1/edge
|
||||||
heat-k8s: 2024.1/edge
|
heat-k8s: 2024.1/edge
|
||||||
|
Loading…
x
Reference in New Issue
Block a user