From 93eabbfa7217b323fe3449adb715aa1d1c52ba71 Mon Sep 17 00:00:00 2001
From: Guillaume Boutry <guillaume.boutry@canonical.com>
Date: Wed, 19 Feb 2025 18:08:59 +0100
Subject: [PATCH] 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>
---
 charms/cinder-volume-ceph/.sunbeam-build.yaml |  10 +
 charms/cinder-volume-ceph/CONTRIBUTING.md     |  54 ++
 charms/cinder-volume-ceph/LICENSE             | 202 +++++++
 charms/cinder-volume-ceph/README.md           |  58 ++
 charms/cinder-volume-ceph/charmcraft.yaml     | 311 ++++++++++
 charms/cinder-volume-ceph/rebuild             |   3 +
 charms/cinder-volume-ceph/requirements.txt    |  27 +
 charms/cinder-volume-ceph/src/charm.py        | 297 ++++++++++
 .../cinder-volume-ceph/tests/unit/__init__.py |  16 +
 .../unit/test_cinder_volume_ceph_charm.py     | 134 +++++
 charms/cinder-volume/.gitignore               |   1 +
 charms/cinder-volume/.sunbeam-build.yaml      |  11 +
 charms/cinder-volume/CONTRIBUTING.md          |  52 ++
 charms/cinder-volume/LICENSE                  | 202 +++++++
 charms/cinder-volume/README.md                |  55 ++
 charms/cinder-volume/charmcraft.yaml          | 106 ++++
 .../charms/cinder_volume/v0/cinder_volume.py  | 270 +++++++++
 .../lib/charms/cinder_volume/v0/py.typed      |   0
 charms/cinder-volume/rebuild                  |   3 +
 charms/cinder-volume/requirements.txt         |  21 +
 charms/cinder-volume/src/charm.py             | 285 ++++++++++
 charms/cinder-volume/tests/unit/__init__.py   |  16 +
 .../tests/unit/test_cinder_volume_charm.py    | 202 +++++++
 .../data_platform_libs/v0/data_interfaces.py  | 529 ++++++++++++++----
 ops-sunbeam/ops_sunbeam/charm.py              | 195 ++++++-
 ops-sunbeam/ops_sunbeam/relation_handlers.py  |  54 ++
 ops-sunbeam/tests/unit_tests/test_charms.py   |  43 ++
 ops-sunbeam/tests/unit_tests/test_core.py     |  49 +-
 zuul.d/jobs.yaml                              |  50 ++
 zuul.d/project-templates.yaml                 |  10 +
 zuul.d/secrets.yaml                           | 152 ++---
 zuul.d/zuul.yaml                              |   2 +
 32 files changed, 3225 insertions(+), 195 deletions(-)
 create mode 100644 charms/cinder-volume-ceph/.sunbeam-build.yaml
 create mode 100644 charms/cinder-volume-ceph/CONTRIBUTING.md
 create mode 100644 charms/cinder-volume-ceph/LICENSE
 create mode 100644 charms/cinder-volume-ceph/README.md
 create mode 100644 charms/cinder-volume-ceph/charmcraft.yaml
 create mode 100644 charms/cinder-volume-ceph/rebuild
 create mode 100644 charms/cinder-volume-ceph/requirements.txt
 create mode 100755 charms/cinder-volume-ceph/src/charm.py
 create mode 100644 charms/cinder-volume-ceph/tests/unit/__init__.py
 create mode 100644 charms/cinder-volume-ceph/tests/unit/test_cinder_volume_ceph_charm.py
 create mode 100644 charms/cinder-volume/.gitignore
 create mode 100644 charms/cinder-volume/.sunbeam-build.yaml
 create mode 100644 charms/cinder-volume/CONTRIBUTING.md
 create mode 100644 charms/cinder-volume/LICENSE
 create mode 100644 charms/cinder-volume/README.md
 create mode 100644 charms/cinder-volume/charmcraft.yaml
 create mode 100644 charms/cinder-volume/lib/charms/cinder_volume/v0/cinder_volume.py
 create mode 100644 charms/cinder-volume/lib/charms/cinder_volume/v0/py.typed
 create mode 100644 charms/cinder-volume/rebuild
 create mode 100644 charms/cinder-volume/requirements.txt
 create mode 100755 charms/cinder-volume/src/charm.py
 create mode 100644 charms/cinder-volume/tests/unit/__init__.py
 create mode 100644 charms/cinder-volume/tests/unit/test_cinder_volume_charm.py

diff --git a/charms/cinder-volume-ceph/.sunbeam-build.yaml b/charms/cinder-volume-ceph/.sunbeam-build.yaml
new file mode 100644
index 00000000..23778b93
--- /dev/null
+++ b/charms/cinder-volume-ceph/.sunbeam-build.yaml
@@ -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
diff --git a/charms/cinder-volume-ceph/CONTRIBUTING.md b/charms/cinder-volume-ceph/CONTRIBUTING.md
new file mode 100644
index 00000000..f6b44e0c
--- /dev/null
+++ b/charms/cinder-volume-ceph/CONTRIBUTING.md
@@ -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
diff --git a/charms/cinder-volume-ceph/LICENSE b/charms/cinder-volume-ceph/LICENSE
new file mode 100644
index 00000000..d6456956
--- /dev/null
+++ b/charms/cinder-volume-ceph/LICENSE
@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/charms/cinder-volume-ceph/README.md b/charms/cinder-volume-ceph/README.md
new file mode 100644
index 00000000..1f823620
--- /dev/null
+++ b/charms/cinder-volume-ceph/README.md
@@ -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
diff --git a/charms/cinder-volume-ceph/charmcraft.yaml b/charms/cinder-volume-ceph/charmcraft.yaml
new file mode 100644
index 00000000..015b6362
--- /dev/null
+++ b/charms/cinder-volume-ceph/charmcraft.yaml
@@ -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
diff --git a/charms/cinder-volume-ceph/rebuild b/charms/cinder-volume-ceph/rebuild
new file mode 100644
index 00000000..2658c31d
--- /dev/null
+++ b/charms/cinder-volume-ceph/rebuild
@@ -0,0 +1,3 @@
+# This file is used to trigger a build.
+# Change uuid to trigger a new build.
+37af2d20-53dc-11ef-97a3-b37540f14c92
diff --git a/charms/cinder-volume-ceph/requirements.txt b/charms/cinder-volume-ceph/requirements.txt
new file mode 100644
index 00000000..4fc6e6f2
--- /dev/null
+++ b/charms/cinder-volume-ceph/requirements.txt
@@ -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
diff --git a/charms/cinder-volume-ceph/src/charm.py b/charms/cinder-volume-ceph/src/charm.py
new file mode 100755
index 00000000..5d52701e
--- /dev/null
+++ b/charms/cinder-volume-ceph/src/charm.py
@@ -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)
diff --git a/charms/cinder-volume-ceph/tests/unit/__init__.py b/charms/cinder-volume-ceph/tests/unit/__init__.py
new file mode 100644
index 00000000..33cc088d
--- /dev/null
+++ b/charms/cinder-volume-ceph/tests/unit/__init__.py
@@ -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."""
diff --git a/charms/cinder-volume-ceph/tests/unit/test_cinder_volume_ceph_charm.py b/charms/cinder-volume-ceph/tests/unit/test_cinder_volume_ceph_charm.py
new file mode 100644
index 00000000..b26d80b3
--- /dev/null
+++ b/charms/cinder-volume-ceph/tests/unit/test_cinder_volume_ceph_charm.py
@@ -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:.*")
diff --git a/charms/cinder-volume/.gitignore b/charms/cinder-volume/.gitignore
new file mode 100644
index 00000000..8d9ce4f7
--- /dev/null
+++ b/charms/cinder-volume/.gitignore
@@ -0,0 +1 @@
+!lib/charms/cinder_volume/
diff --git a/charms/cinder-volume/.sunbeam-build.yaml b/charms/cinder-volume/.sunbeam-build.yaml
new file mode 100644
index 00000000..ad36ca2f
--- /dev/null
+++ b/charms/cinder-volume/.sunbeam-build.yaml
@@ -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: []
diff --git a/charms/cinder-volume/CONTRIBUTING.md b/charms/cinder-volume/CONTRIBUTING.md
new file mode 100644
index 00000000..803aa2f5
--- /dev/null
+++ b/charms/cinder-volume/CONTRIBUTING.md
@@ -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
diff --git a/charms/cinder-volume/LICENSE b/charms/cinder-volume/LICENSE
new file mode 100644
index 00000000..d6456956
--- /dev/null
+++ b/charms/cinder-volume/LICENSE
@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/charms/cinder-volume/README.md b/charms/cinder-volume/README.md
new file mode 100644
index 00000000..19f30487
--- /dev/null
+++ b/charms/cinder-volume/README.md
@@ -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
diff --git a/charms/cinder-volume/charmcraft.yaml b/charms/cinder-volume/charmcraft.yaml
new file mode 100644
index 00000000..d6a4220a
--- /dev/null
+++ b/charms/cinder-volume/charmcraft.yaml
@@ -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
diff --git a/charms/cinder-volume/lib/charms/cinder_volume/v0/cinder_volume.py b/charms/cinder-volume/lib/charms/cinder_volume/v0/cinder_volume.py
new file mode 100644
index 00000000..b0319df0
--- /dev/null
+++ b/charms/cinder-volume/lib/charms/cinder_volume/v0/cinder_volume.py
@@ -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
diff --git a/charms/cinder-volume/lib/charms/cinder_volume/v0/py.typed b/charms/cinder-volume/lib/charms/cinder_volume/v0/py.typed
new file mode 100644
index 00000000..e69de29b
diff --git a/charms/cinder-volume/rebuild b/charms/cinder-volume/rebuild
new file mode 100644
index 00000000..2658c31d
--- /dev/null
+++ b/charms/cinder-volume/rebuild
@@ -0,0 +1,3 @@
+# This file is used to trigger a build.
+# Change uuid to trigger a new build.
+37af2d20-53dc-11ef-97a3-b37540f14c92
diff --git a/charms/cinder-volume/requirements.txt b/charms/cinder-volume/requirements.txt
new file mode 100644
index 00000000..af230f0c
--- /dev/null
+++ b/charms/cinder-volume/requirements.txt
@@ -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
diff --git a/charms/cinder-volume/src/charm.py b/charms/cinder-volume/src/charm.py
new file mode 100755
index 00000000..856741d5
--- /dev/null
+++ b/charms/cinder-volume/src/charm.py
@@ -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)
diff --git a/charms/cinder-volume/tests/unit/__init__.py b/charms/cinder-volume/tests/unit/__init__.py
new file mode 100644
index 00000000..338da4d7
--- /dev/null
+++ b/charms/cinder-volume/tests/unit/__init__.py
@@ -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."""
diff --git a/charms/cinder-volume/tests/unit/test_cinder_volume_charm.py b/charms/cinder-volume/tests/unit/test_cinder_volume_charm.py
new file mode 100644
index 00000000..062c4ad3
--- /dev/null
+++ b/charms/cinder-volume/tests/unit/test_cinder_volume_charm.py
@@ -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")
diff --git a/libs/external/lib/charms/data_platform_libs/v0/data_interfaces.py b/libs/external/lib/charms/data_platform_libs/v0/data_interfaces.py
index 59a97226..97171190 100644
--- a/libs/external/lib/charms/data_platform_libs/v0/data_interfaces.py
+++ b/libs/external/lib/charms/data_platform_libs/v0/data_interfaces.py
@@ -331,10 +331,14 @@ LIBAPI = 0
 
 # Increment this PATCH version before using `charmcraft publish-lib` or reset
 # to 0 if you are raising the major API version
-LIBPATCH = 37
+LIBPATCH = 41
 
 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__)
 
 Diff = namedtuple("Diff", "added changed deleted")
@@ -351,36 +355,16 @@ REQ_SECRET_FIELDS = "requested-secrets"
 GROUP_MAPPING_FIELD = "secret_group_mapping"
 GROUP_SEPARATOR = "@"
 
-
-class SecretGroup(str):
-    """Secret groups specific type."""
+MODEL_ERRORS = {
+    "not_leader": "this unit is not the leader",
+    "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."""
-
-    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()
+##############################################################################
+# Exceptions
+##############################################################################
 
 
 class DataInterfacesError(Exception):
@@ -407,6 +391,19 @@ class IllegalOperationError(DataInterfacesError):
     """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(
     relation: Relation, member: Union[Unit, Application], field: str
 ) -> Optional[Dict[str, str]]:
@@ -482,6 +479,11 @@ def diff(event: RelationChangedEvent, bucket: Optional[Union[Unit, Application]]
     return Diff(added, changed, deleted)
 
 
+##############################################################################
+# Module decorators
+##############################################################################
+
+
 def leader_only(f):
     """Decorator to ensure that only leader can perform given operation."""
 
@@ -536,6 +538,36 @@ def either_static_or_dynamic_secrets(f):
     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):
     """Peer relations scope."""
 
@@ -543,17 +575,45 @@ class Scope(Enum):
     UNIT = "unit"
 
 
-################################################################################
-# Secrets internal caching
-################################################################################
+class SecretGroup(str):
+    """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:
     """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__(
         self,
         model: Model,
@@ -571,6 +631,95 @@ class CachedSecret:
         self.legacy_labels = legacy_labels
         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(
         self,
         content: Dict[str, str],
@@ -593,28 +742,6 @@ class CachedSecret:
         self._secret_meta = secret
         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]:
         """Getting cached secret content."""
         if not self._secret_content:
@@ -624,35 +751,14 @@ class CachedSecret:
                 except (ValueError, ModelError) as err:
                     # https://bugs.launchpad.net/juju/+bug/2042596
                     # 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(
-                        msg in str(err) for msg in known_model_errors
+                        msg in str(err) for msg in self.KNOWN_MODEL_ERRORS
                     ):
                         raise
                     # Due to: ValueError: Secret owner cannot use refresh=True
                     self._secret_content = self.meta.get_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:
         """Setting cached secret content."""
         if not self.meta:
@@ -663,7 +769,7 @@ class CachedSecret:
             return
 
         if content:
-            self._move_to_new_label_if_needed()
+            self._legacy_migration_to_new_label_if_needed()
             self.meta.set_content(content)
             self._secret_content = content
         else:
@@ -926,6 +1032,23 @@ class Data(ABC):
         """Delete data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app."""
         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
 
     @staticmethod
@@ -1178,6 +1301,16 @@ class Data(ABC):
 
         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(
         self,
         relation_ids: Optional[List[int]] = None,
@@ -1194,6 +1327,8 @@ class Data(ABC):
             a dict of the values stored in the relation data bag
                 for all relation instances (indexed by the relation ID).
         """
+        self._legacy_apply_on_fetch()
+
         if not 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
         Application databag, the functionality is limited to leaders
         """
+        self._legacy_apply_on_fetch()
+
         if not relation_name:
             relation_name = self.relation_name
 
@@ -1263,6 +1400,8 @@ class Data(ABC):
     @leader_only
     def update_relation_data(self, relation_id: int, data: dict) -> None:
         """Update the data within the relation."""
+        self._legacy_apply_on_update(list(data.keys()))
+
         relation_name = self.relation_name
         relation = self.get_relation(relation_name, relation_id)
         return self._update_relation_data(relation, data)
@@ -1270,6 +1409,8 @@ class Data(ABC):
     @leader_only
     def delete_relation_data(self, relation_id: int, fields: List[str]) -> None:
         """Remove field from the relation."""
+        self._legacy_apply_on_delete(fields)
+
         relation_name = self.relation_name
         relation = self.get_relation(relation_name, relation_id)
         return self._delete_relation_data(relation, fields)
@@ -1316,6 +1457,8 @@ class EventHandlers(Object):
 class ProviderData(Data):
     """Base provides-side of the data products relation."""
 
+    RESOURCE_FIELD = "database"
+
     def __init__(
         self,
         model: Model,
@@ -1336,8 +1479,7 @@ class ProviderData(Data):
         uri_to_databag=True,
     ) -> bool:
         """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 relation.data[self.component].get(secret_field):
+        if uri_to_databag and self.get_secret_uri(relation, group_mapping):
             logging.error("Secret for relation %s already exists, not adding again", relation.id)
             return False
 
@@ -1348,7 +1490,7 @@ class ProviderData(Data):
 
         # According to lint we may not have a Secret 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 True
@@ -1449,8 +1591,7 @@ class ProviderData(Data):
         if not relation:
             return
 
-        secret_field = self._generate_secret_field_name(group_mapping)
-        if secret_uri := relation.data[self.local_app].get(secret_field):
+        if secret_uri := self.get_secret_uri(relation, group_mapping):
             return self.secrets.get(label, secret_uri)
 
     def _fetch_specific_relation_data(
@@ -1483,6 +1624,15 @@ class ProviderData(Data):
     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."""
         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:
             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():
             secret_field = self._generate_secret_field_name(group)
-            if secret_field in params_name_list:
-                if secret_uri := relation.data[relation.app].get(secret_field):
-                    self._register_secret_to_relation(
-                        relation.name, relation.id, secret_uri, group
-                    )
+            if secret_field in params_name_list and (
+                secret_uri := self.get_secret_uri(relation, group)
+            ):
+                self._register_secret_to_relation(relation.name, relation.id, secret_uri, group)
 
     def _is_resource_created_for_relation(self, relation: Relation) -> bool:
         if not relation.app:
@@ -1618,6 +1767,17 @@ class RequirerData(Data):
         )
         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:
         """Check if the resource has been created.
 
@@ -1768,7 +1928,6 @@ class DataPeerData(RequirerData, ProviderData):
         secret_field_name: Optional[str] = None,
         deleted_label: Optional[str] = None,
     ):
-        """Manager of base client relations."""
         RequirerData.__init__(
             self,
             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.deleted_label = deleted_label
         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
         self._new_secrets = []
         self._additional_secret_group_mapping = additional_secret_group_mapping
@@ -1853,10 +2017,12 @@ class DataPeerData(RequirerData, ProviderData):
             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
         """
+        self._legacy_apply_on_update([field])
+
         full_field = self._field_to_internal_name(field, group_mapping)
         if self.secrets_enabled and full_field not in self.current_secret_fields:
             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})
 
     # 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,
     ) -> Optional[str]:
         """Public interface method to fetch secrets only."""
+        self._legacy_apply_on_fetch()
+
         full_field = self._field_to_internal_name(field, group_mapping)
         if (
             self.secrets_enabled
@@ -1876,7 +2044,7 @@ class DataPeerData(RequirerData, ProviderData):
             and field not in self.current_secret_fields
         ):
             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)
 
     @dynamic_secrets_only
@@ -1887,14 +2055,19 @@ class DataPeerData(RequirerData, ProviderData):
         group_mapping: Optional[SecretGroup] = None,
     ) -> Optional[str]:
         """Public interface method to delete secrets only."""
+        self._legacy_apply_on_delete([field])
+
         full_field = self._field_to_internal_name(field, group_mapping)
         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")
             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])
 
+    ##########################################################################
     # Helpers
+    ##########################################################################
 
     @staticmethod
     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
         }
 
-    # 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)
         if current_data is not None:
             # Check if the secret we wanna delete actually exists
@@ -1952,7 +2184,43 @@ class DataPeerData(RequirerData, ProviderData):
                     ", ".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.
 
         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]):
                 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.
 
         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
         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
             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
+    ##########################################################################
 
     def _on_relation_changed_event(self, event: RelationChangedEvent) -> None:
         """Event emitted when the relation has changed."""
@@ -2013,7 +2271,9 @@ class DataPeerData(RequirerData, ProviderData):
         """Event emitted when the secret has changed."""
         pass
 
+    ##########################################################################
     # Overrides of Relation Data handling functions
+    ##########################################################################
 
     def _generate_secret_label(
         self, relation_name: str, relation_id: int, group_mapping: SecretGroup
@@ -2050,13 +2310,14 @@ class DataPeerData(RequirerData, ProviderData):
             return
 
         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
         if group_mapping == SECRET_GROUPS.EXTRA:
             # 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
-            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)
 
     def _get_group_secret_contents(
@@ -2086,7 +2347,6 @@ class DataPeerData(RequirerData, ProviderData):
     @either_static_or_dynamic_secrets
     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."""
-        self._remove_secret_from_databag(relation, list(data.keys()))
         _, normal_fields = self._process_secret_fields(
             relation,
             self.secret_fields,
@@ -2095,7 +2355,6 @@ class DataPeerData(RequirerData, ProviderData):
             data=data,
             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}
         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:
         """Delete data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app."""
         if self.secret_fields and self.deleted_label:
-            # Legacy, backwards compatibility
-            self._check_deleted_label(relation, fields)
-
             _, normal_fields = self._process_secret_fields(
                 relation,
                 self.secret_fields,
@@ -2141,7 +2397,9 @@ class DataPeerData(RequirerData, ProviderData):
             "fetch_my_relation_data() and fetch_my_relation_field()"
         )
 
+    ##########################################################################
     # Public functions -- inherited
+    ##########################################################################
 
     fetch_my_relation_data = Data.fetch_my_relation_data
     fetch_my_relation_field = Data.fetch_my_relation_field
@@ -2606,6 +2864,14 @@ class DatabaseProviderData(ProviderData):
         """
         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):
     """Provider-side of the database relation handlers."""
@@ -2842,6 +3108,21 @@ class DatabaseRequirerEventHandlers(RequirerEventHandlers):
 
     def _on_relation_changed_event(self, event: RelationChangedEvent) -> None:
         """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.
         diff = self._diff(event)
 
@@ -3023,6 +3304,8 @@ class KafkaRequiresEvents(CharmEvents):
 class KafkaProviderData(ProviderData):
     """Provider-side of the Kafka relation."""
 
+    RESOURCE_FIELD = "topic"
+
     def __init__(self, model: Model, relation_name: str) -> None:
         super().__init__(model, relation_name)
 
@@ -3272,6 +3555,8 @@ class OpenSearchRequiresEvents(CharmEvents):
 class OpenSearchProvidesData(ProviderData):
     """Provider-side of the OpenSearch relation."""
 
+    RESOURCE_FIELD = "index"
+
     def __init__(self, model: Model, relation_name: str) -> None:
         super().__init__(model, relation_name)
 
diff --git a/ops-sunbeam/ops_sunbeam/charm.py b/ops-sunbeam/ops_sunbeam/charm.py
index de941a5c..b29ad79a 100644
--- a/ops-sunbeam/ops_sunbeam/charm.py
+++ b/ops-sunbeam/ops_sunbeam/charm.py
@@ -29,11 +29,13 @@ defines the pebble layers, manages pushing configuration to the
 containers and managing the service running in the container.
 """
 
+import functools
 import ipaddress
 import logging
 import urllib
 import urllib.parse
 from typing import (
+    TYPE_CHECKING,
     List,
     Mapping,
     Optional,
@@ -70,6 +72,9 @@ from ops.model import (
     MaintenanceStatus,
 )
 
+if TYPE_CHECKING:
+    import charms.operator_libs_linux.v2.snap as snap
+
 logger = logging.getLogger(__name__)
 
 
@@ -182,6 +187,7 @@ class OSBaseOperatorCharm(
                     self.configure_charm,
                     database_name,
                     relation_name in self.mandatory_relations,
+                    external_access=self.remote_external_access,
                 )
                 self.dbs[relation_name] = db
                 handlers.append(db)
@@ -459,7 +465,11 @@ class OSBaseOperatorCharm(
                     )
 
                     if isinstance(event, RelationBrokenEvent):
-                        _is_broken = True
+                        _is_broken = event.relation.name in (
+                            "database",
+                            "api-database",
+                            "cell-database",
+                        )
                 case "ingress-public" | "ingress-internal":
                     from charms.traefik_k8s.v2.ingress import (
                         IngressPerAppRevokedEvent,
@@ -1120,3 +1130,186 @@ class OSBaseOperatorAPICharm(OSBaseOperatorCharmK8S):
                 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
diff --git a/ops-sunbeam/ops_sunbeam/relation_handlers.py b/ops-sunbeam/ops_sunbeam/relation_handlers.py
index 0c524ebf..a7298b22 100644
--- a/ops-sunbeam/ops_sunbeam/relation_handlers.py
+++ b/ops-sunbeam/ops_sunbeam/relation_handlers.py
@@ -53,6 +53,7 @@ if typing.TYPE_CHECKING:
     import charms.ceilometer_k8s.v0.ceilometer_service as ceilometer_service
     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_volume.v0.cinder_volume as sunbeam_cinder_volume
     import charms.data_platform_libs.v0.data_interfaces as data_interfaces
     import charms.gnocchi_k8s.v0.gnocchi_service as gnocchi_service
     import charms.keystone_k8s.v0.identity_credentials as identity_credentials
@@ -302,11 +303,13 @@ class DBHandler(RelationHandler):
         callback_f: Callable,
         database: str,
         mandatory: bool = False,
+        external_access: bool = False,
     ) -> None:
         """Run constructor."""
         # a database name as requested by the charm.
         super().__init__(charm, relation_name, callback_f, mandatory)
         self.database_name = database
+        self.external_access = external_access
 
     def setup_event_handler(self) -> ops.framework.Object:
         """Configure event handlers for a MySQL relation."""
@@ -331,6 +334,7 @@ class DBHandler(RelationHandler):
             self.relation_name,
             self.database_name,
             relations_aliases=[alias],
+            external_node_connectivity=self.external_access,
         )
         self.framework.observe(
             # db.on[f"{alias}_database_created"], # this doesn't work because:
@@ -2388,3 +2392,53 @@ class ServiceReadinessProviderHandler(RelationHandler):
     def ready(self) -> bool:
         """Report if relation is ready."""
         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()
diff --git a/ops-sunbeam/tests/unit_tests/test_charms.py b/ops-sunbeam/tests/unit_tests/test_charms.py
index e351eac9..1ae01166 100644
--- a/ops-sunbeam/tests/unit_tests/test_charms.py
+++ b/ops-sunbeam/tests/unit_tests/test_charms.py
@@ -22,6 +22,9 @@ import tempfile
 from typing import (
     TYPE_CHECKING,
 )
+from unittest.mock import (
+    Mock,
+)
 
 if TYPE_CHECKING:
     import ops.framework
@@ -363,3 +366,43 @@ class TestMultiSvcCharm(MyAPICharm):
                 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"
diff --git a/ops-sunbeam/tests/unit_tests/test_core.py b/ops-sunbeam/tests/unit_tests/test_core.py
index ebfbe7ff..ce42d8e8 100644
--- a/ops-sunbeam/tests/unit_tests/test_core.py
+++ b/ops-sunbeam/tests/unit_tests/test_core.py
@@ -489,7 +489,7 @@ class TestOSBaseOperatorMultiSVCAPICharm(_TestOSBaseOperatorAPICharm):
     """Test Charm with multiple services."""
 
     def setUp(self) -> None:
-        """Charm test class setip."""
+        """Charm test class setup."""
         super().setUp(test_charms.TestMultiSvcCharm)
 
     def test_start_services(self) -> None:
@@ -506,3 +506,50 @@ class TestOSBaseOperatorMultiSVCAPICharm(_TestOSBaseOperatorAPICharm):
             sorted(self.container_calls.started_services("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
+        )
diff --git a/zuul.d/jobs.yaml b/zuul.d/jobs.yaml
index 1c080dd9..2830d130 100644
--- a/zuul.d/jobs.yaml
+++ b/zuul.d/jobs.yaml
@@ -106,6 +106,30 @@
       - rebuild
     vars:
       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:
     name: charm-build-cinder-ceph-k8s
     description: Build sunbeam cinder-ceph-k8s charm
@@ -655,6 +679,32 @@
       - charmhub_token
     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:
     name: publish-charm-designate-bind-k8s
     description: |
diff --git a/zuul.d/project-templates.yaml b/zuul.d/project-templates.yaml
index 08d2a7b3..53aaf18f 100644
--- a/zuul.d/project-templates.yaml
+++ b/zuul.d/project-templates.yaml
@@ -56,6 +56,10 @@
             nodeset: ubuntu-jammy
         - charm-build-cinder-ceph-k8s:
             nodeset: ubuntu-jammy
+        - charm-build-cinder-volume:
+            nodeset: ubuntu-jammy
+        - charm-build-cinder-volume-ceph:
+            nodeset: ubuntu-jammy
         - charm-build-horizon-k8s:
             nodeset: ubuntu-jammy
         - charm-build-heat-k8s:
@@ -115,6 +119,8 @@
             nodeset: ubuntu-jammy
         - charm-build-cinder-ceph-k8s:
             nodeset: ubuntu-jammy
+        - charm-build-cinder-volume:
+            nodeset: ubuntu-jammy
         - charm-build-horizon-k8s:
             nodeset: ubuntu-jammy
         - charm-build-heat-k8s:
@@ -178,6 +184,10 @@
                 nodeset: ubuntu-jammy
             - publish-charm-cinder-ceph-k8s:
                 nodeset: ubuntu-jammy
+            - publish-charm-cinder-volume:
+                nodeset: ubuntu-jammy
+            - publish-charm-cinder-volume-ceph:
+                nodeset: ubuntu-jammy
             - publish-charm-horizon-k8s:
                 nodeset: ubuntu-jammy
             - publish-charm-heat-k8s:
diff --git a/zuul.d/secrets.yaml b/zuul.d/secrets.yaml
index 1ed53a26..8c995470 100644
--- a/zuul.d/secrets.yaml
+++ b/zuul.d/secrets.yaml
@@ -1,75 +1,85 @@
 - secret:
     name: charmhub_token
     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
-        - 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
+        - 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
diff --git a/zuul.d/zuul.yaml b/zuul.d/zuul.yaml
index b34109af..267d683c 100644
--- a/zuul.d/zuul.yaml
+++ b/zuul.d/zuul.yaml
@@ -28,6 +28,8 @@
         ovn-central-k8s: 24.03/edge
         ovn-relay-k8s: 24.03/edge
         cinder-k8s: 2024.1/edge
+        cinder-volume: 2024.1/edge
+        cinder-volume-ceph: 2024.1/edge
         cinder-ceph-k8s: 2024.1/edge
         horizon-k8s: 2024.1/edge
         heat-k8s: 2024.1/edge